Spaces:
Sleeping
Sleeping
Commit ·
1fa857f
1
Parent(s): 170b9b6
feat(llm): add OpenCode Go (OpenAI-compatible) provider for ingestion
Browse filesProvider-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 +20 -0
- packages/adapters/package.json +1 -0
- packages/adapters/src/index.ts +9 -0
- packages/adapters/src/llm/vercel-ai.ts +46 -5
- pnpm-lock.yaml +15 -0
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 +
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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
|