hetanshwaghela commited on
Commit
1fa857f
·
1 Parent(s): 170b9b6

feat(llm): add OpenCode Go (OpenAI-compatible) provider for ingestion

Browse files

Provider-swappable LlmPort now supports AI_PROVIDER=opencode via
@ai-sdk/openai-compatible against https://opencode.ai/zen/go/v1.
- intake -> deepseek-v4-flash (tool-calling structured output)
- vision evidence parse -> qwen3.6-plus (image input)
- OpenRouter path unchanged (default); response-healing stays OpenRouter-only
- capability registry: opencode_llm available when OPENCODE_API_KEY set
- .env.example documents OPENCODE_API_KEY/OPENCODE_BASE_URL + model examples

Resolves OpenRouter free-tier rate-limit wall. Verified live end-to-end:
Telegram text -> deepseek-v4-flash -> structured facts; real Telegram
screenshot -> qwen3.6-plus vision OCR -> ParsedEvidence -> 2 evidence
claims with support levels.

infra/.env.example CHANGED
@@ -14,10 +14,30 @@ API_BASE_URL=http://localhost:3001
14
  WEBHOOK_BASE_URL=http://localhost:3001
15
 
16
  # --- AI provider ---
 
 
 
17
  AI_PROVIDER=openrouter
 
 
18
  OPENROUTER_API_KEY=
 
 
 
19
  AI_MODEL=google/gemini-2.0-flash-exp:free
20
  AI_VISION_MODEL=google/gemini-2.0-flash-exp:free
 
 
 
 
 
 
 
 
 
 
 
 
21
  # Legacy key kept for compatibility
22
  AI_PROVIDER_API_KEY=
23
 
 
14
  WEBHOOK_BASE_URL=http://localhost:3001
15
 
16
  # --- AI provider ---
17
+ # Set AI_PROVIDER to switch between providers:
18
+ # openrouter (default) — uses @openrouter/ai-sdk-provider with response-healing plugin
19
+ # opencode | openai-compatible — uses @ai-sdk/openai-compatible pointed at OPENCODE_BASE_URL
20
  AI_PROVIDER=openrouter
21
+
22
+ # --- OpenRouter (AI_PROVIDER=openrouter) ---
23
  OPENROUTER_API_KEY=
24
+ # Example model IDs for openrouter:
25
+ # AI_MODEL=google/gemini-2.0-flash-exp:free
26
+ # AI_VISION_MODEL=google/gemini-2.0-flash-exp:free
27
  AI_MODEL=google/gemini-2.0-flash-exp:free
28
  AI_VISION_MODEL=google/gemini-2.0-flash-exp:free
29
+
30
+ # --- OpenCode Go (AI_PROVIDER=opencode) ---
31
+ # OpenAI-compatible gateway at https://opencode.ai/zen/go/v1
32
+ # All listed models support tool-calling; generateObject uses tool-calling mode.
33
+ # Text intake model (no vision): deepseek-v4-flash
34
+ # Vision/evidence models (text+image): qwen3.6-plus, mimo-v2.5, kimi-k2.6
35
+ OPENCODE_API_KEY=
36
+ OPENCODE_BASE_URL=https://opencode.ai/zen/go/v1
37
+ # Example model overrides for opencode provider:
38
+ # AI_MODEL=deepseek-v4-flash
39
+ # AI_VISION_MODEL=qwen3.6-plus
40
+
41
  # Legacy key kept for compatibility
42
  AI_PROVIDER_API_KEY=
43
 
packages/adapters/package.json CHANGED
@@ -13,6 +13,7 @@
13
  "build": "tsc --noEmit"
14
  },
15
  "dependencies": {
 
16
  "@courtmitra/contracts": "workspace:*",
17
  "@courtmitra/ports": "workspace:*",
18
  "@courtmitra/prompts": "workspace:*",
 
13
  "build": "tsc --noEmit"
14
  },
15
  "dependencies": {
16
+ "@ai-sdk/openai-compatible": "^2.0.48",
17
  "@courtmitra/contracts": "workspace:*",
18
  "@courtmitra/ports": "workspace:*",
19
  "@courtmitra/prompts": "workspace:*",
packages/adapters/src/index.ts CHANGED
@@ -35,6 +35,7 @@ export function getCapabilities(): AdapterCapability[] {
35
  const waToken = process.env["WHATSAPP_ACCESS_TOKEN"];
36
  const waPhoneId = process.env["WHATSAPP_PHONE_NUMBER_ID"];
37
  const openrouterKey = process.env["OPENROUTER_API_KEY"];
 
38
 
39
  const CAPABILITY_REGISTRY: AdapterCapability[] = [
40
  {
@@ -61,6 +62,14 @@ export function getCapabilities(): AdapterCapability[] {
61
  requiredEnv: ["OPENROUTER_API_KEY"],
62
  fallbackAction: "draft",
63
  },
 
 
 
 
 
 
 
 
64
  {
65
  adapter: "resend_email",
66
  action: "send_email",
 
35
  const waToken = process.env["WHATSAPP_ACCESS_TOKEN"];
36
  const waPhoneId = process.env["WHATSAPP_PHONE_NUMBER_ID"];
37
  const openrouterKey = process.env["OPENROUTER_API_KEY"];
38
+ const opencodeKey = process.env["OPENCODE_API_KEY"];
39
 
40
  const CAPABILITY_REGISTRY: AdapterCapability[] = [
41
  {
 
62
  requiredEnv: ["OPENROUTER_API_KEY"],
63
  fallbackAction: "draft",
64
  },
65
+ {
66
+ adapter: "opencode_llm",
67
+ action: "generate_structured",
68
+ status: opencodeKey ? "available" : "blocked",
69
+ reason: opencodeKey ? undefined : "OPENCODE_API_KEY not set.",
70
+ requiredEnv: ["OPENCODE_API_KEY"],
71
+ fallbackAction: "draft",
72
+ },
73
  {
74
  adapter: "resend_email",
75
  action: "send_email",
packages/adapters/src/llm/vercel-ai.ts CHANGED
@@ -1,15 +1,29 @@
1
  /**
2
- * LlmPort implementation backed by Vercel AI SDK 6 + OpenRouter.
 
 
 
 
 
 
 
3
  *
4
  * Doc sources confirmed:
5
  * - generateObject: /openrouterteam/ai-sdk-provider — uses `generateObject` from `ai`
6
  * with `schema` (Zod) and `model = openrouter(modelId, { plugins: [{ id: 'response-healing' }] })`.
 
 
 
 
 
7
  * - Image parts: /vercel/ai — user message content uses `{ type: 'image', image: DataContent | URL, mediaType?: string }`.
8
- * - generateObject still exported in ai@6.x (verified from node_modules).
9
  * - Response metadata: `result.response.modelId`, usage: `result.usage.inputTokens` / `outputTokens`.
10
  */
11
  import { createOpenRouter } from "@openrouter/ai-sdk-provider";
 
12
  import { generateObject } from "ai";
 
13
  import type { LlmPort, LlmStructuredRequest, LlmStructuredResult } from "@courtmitra/ports";
14
  import { renderPrompt } from "@courtmitra/prompts";
15
  import type { ZodType } from "zod";
@@ -33,6 +47,35 @@ export class VercelAiLlmAdapter implements LlmPort {
33
  return this._attempt(req, modelId, renderedPrompt, true);
34
  }
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  private async _attempt<T>(
37
  req: LlmStructuredRequest<T>,
38
  modelId: string,
@@ -67,9 +110,7 @@ export class VercelAiLlmAdapter implements LlmPort {
67
  // Cast to ZodType for generateObject which expects ZodType at runtime
68
  const zodSchema = req.schema as unknown as ZodType<T>;
69
 
70
- const model = this.openrouter(modelId, {
71
- plugins: [{ id: "response-healing" }],
72
- });
73
 
74
  try {
75
  const result = await generateObject({
 
1
  /**
2
+ * LlmPort implementation backed by Vercel AI SDK 6 + pluggable provider.
3
+ *
4
+ * Supported providers (AI_PROVIDER env var):
5
+ * - "openrouter" (default): @openrouter/ai-sdk-provider with response-healing plugin.
6
+ * - "opencode" | "openai-compatible": @ai-sdk/openai-compatible pointed at
7
+ * OPENCODE_BASE_URL (default: https://opencode.ai/zen/go/v1).
8
+ * Object generation uses tool-calling mode (supportsStructuredOutputs: false,
9
+ * which is the default) — all confirmed OpenCode Go models support tool-calling.
10
  *
11
  * Doc sources confirmed:
12
  * - generateObject: /openrouterteam/ai-sdk-provider — uses `generateObject` from `ai`
13
  * with `schema` (Zod) and `model = openrouter(modelId, { plugins: [{ id: 'response-healing' }] })`.
14
+ * - @ai-sdk/openai-compatible v2.0.48: `createOpenAICompatible({ name, baseURL, apiKey })`
15
+ * returns a provider; call `provider(modelId)` for a chat model. `supportsStructuredOutputs`
16
+ * defaults to false → generateObject falls back to tool-calling mode, which works with
17
+ * deepseek-v4-flash / qwen3.6-plus / mimo-v2.5 / kimi-k2.6.
18
+ * Source: /websites/ai-sdk_dev + @ai-sdk/openai-compatible dist/index.d.ts (v2.0.48).
19
  * - Image parts: /vercel/ai — user message content uses `{ type: 'image', image: DataContent | URL, mediaType?: string }`.
20
+ * - generateObject still exported in ai@6.x (verified from node_modules ai@6.0.191).
21
  * - Response metadata: `result.response.modelId`, usage: `result.usage.inputTokens` / `outputTokens`.
22
  */
23
  import { createOpenRouter } from "@openrouter/ai-sdk-provider";
24
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
25
  import { generateObject } from "ai";
26
+ import type { LanguageModel } from "ai";
27
  import type { LlmPort, LlmStructuredRequest, LlmStructuredResult } from "@courtmitra/ports";
28
  import { renderPrompt } from "@courtmitra/prompts";
29
  import type { ZodType } from "zod";
 
47
  return this._attempt(req, modelId, renderedPrompt, true);
48
  }
49
 
50
+ /**
51
+ * Build the language model instance based on AI_PROVIDER env var.
52
+ * - "openrouter" (default): OpenRouter with response-healing plugin.
53
+ * - "opencode" | "openai-compatible": @ai-sdk/openai-compatible pointed at
54
+ * OPENCODE_BASE_URL. supportsStructuredOutputs defaults to false so
55
+ * generateObject falls back to tool-calling mode (all OpenCode Go models
56
+ * support tool-calling).
57
+ */
58
+ private _buildModel(modelId: string): LanguageModel {
59
+ const provider = process.env["AI_PROVIDER"] ?? "openrouter";
60
+
61
+ if (provider === "opencode" || provider === "openai-compatible") {
62
+ const oc = createOpenAICompatible({
63
+ name: "opencode-go",
64
+ baseURL: process.env["OPENCODE_BASE_URL"] ?? "https://opencode.ai/zen/go/v1",
65
+ apiKey: process.env["OPENCODE_API_KEY"],
66
+ // supportsStructuredOutputs intentionally left as false (default) so
67
+ // generateObject uses tool-calling mode, which all listed OpenCode Go
68
+ // models support (deepseek-v4-flash, qwen3.6-plus, mimo-v2.5, kimi-k2.6).
69
+ });
70
+ return oc(modelId);
71
+ }
72
+
73
+ // Default: OpenRouter with response-healing plugin
74
+ return this.openrouter(modelId, {
75
+ plugins: [{ id: "response-healing" }],
76
+ });
77
+ }
78
+
79
  private async _attempt<T>(
80
  req: LlmStructuredRequest<T>,
81
  modelId: string,
 
110
  // Cast to ZodType for generateObject which expects ZodType at runtime
111
  const zodSchema = req.schema as unknown as ZodType<T>;
112
 
113
+ const model = this._buildModel(modelId);
 
 
114
 
115
  try {
116
  const result = await generateObject({
pnpm-lock.yaml CHANGED
@@ -152,6 +152,9 @@ importers:
152
 
153
  packages/adapters:
154
  dependencies:
 
 
 
155
  '@courtmitra/contracts':
156
  specifier: workspace:*
157
  version: link:../contracts
@@ -250,6 +253,12 @@ packages:
250
  peerDependencies:
251
  zod: ^3.25.76 || ^4.1.8
252
 
 
 
 
 
 
 
253
  '@ai-sdk/provider-utils@4.0.27':
254
  resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==}
255
  engines: {node: '>=18'}
@@ -3198,6 +3207,12 @@ snapshots:
3198
  '@vercel/oidc': 3.2.0
3199
  zod: 4.4.3
3200
 
 
 
 
 
 
 
3201
  '@ai-sdk/provider-utils@4.0.27(zod@4.4.3)':
3202
  dependencies:
3203
  '@ai-sdk/provider': 3.0.10
 
152
 
153
  packages/adapters:
154
  dependencies:
155
+ '@ai-sdk/openai-compatible':
156
+ specifier: ^2.0.48
157
+ version: 2.0.48(zod@4.4.3)
158
  '@courtmitra/contracts':
159
  specifier: workspace:*
160
  version: link:../contracts
 
253
  peerDependencies:
254
  zod: ^3.25.76 || ^4.1.8
255
 
256
+ '@ai-sdk/openai-compatible@2.0.48':
257
+ resolution: {integrity: sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q==}
258
+ engines: {node: '>=18'}
259
+ peerDependencies:
260
+ zod: ^3.25.76 || ^4.1.8
261
+
262
  '@ai-sdk/provider-utils@4.0.27':
263
  resolution: {integrity: sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==}
264
  engines: {node: '>=18'}
 
3207
  '@vercel/oidc': 3.2.0
3208
  zod: 4.4.3
3209
 
3210
+ '@ai-sdk/openai-compatible@2.0.48(zod@4.4.3)':
3211
+ dependencies:
3212
+ '@ai-sdk/provider': 3.0.10
3213
+ '@ai-sdk/provider-utils': 4.0.27(zod@4.4.3)
3214
+ zod: 4.4.3
3215
+
3216
  '@ai-sdk/provider-utils@4.0.27(zod@4.4.3)':
3217
  dependencies:
3218
  '@ai-sdk/provider': 3.0.10