From 3ece4d5665520f395e8c7e864b138a54e5123e3b Mon Sep 17 00:00:00 2001 From: dennyho0917 Date: Sat, 24 Oct 2026 14:11:10 +0800 Subject: [PATCH 1/2] feat(ai): add API Route memory provider preset Signed-off-by: dennyho0917 --- README.md | 8 +- i18n/README.ar.md | 6 + i18n/README.de.md | 6 + i18n/README.nl.md | 6 + i18n/README.tr.md | 6 + i18n/README.zh.md | 6 + opencode-mem.example.jsonc | 13 +- src/config.ts | 23 ++- src/services/ai/ai-provider-factory.ts | 5 + src/services/ai/provider-config.ts | 3 +- src/services/ai/providers/api-route.ts | 67 ++++++++ src/services/ai/session/session-types.ts | 1 + src/types/index.ts | 1 + tests/ai-provider-config.test.ts | 24 +++ tests/api-route-provider.test.ts | 206 +++++++++++++++++++++++ tests/config-resolution.test.ts | 53 ++++++ 16 files changed, 426 insertions(+), 8 deletions(-) create mode 100644 src/services/ai/providers/api-route.ts create mode 100644 tests/api-route-provider.test.ts diff --git a/README.md b/README.md index 3f6dbec4..139e28c5 100644 --- a/README.md +++ b/README.md @@ -501,6 +501,12 @@ Manual `memoryProvider` modes: "memoryApiKey": "env://CHEAPER_INFERENCE_API_KEY", ``` When this provider is selected, auto-capture / profile prompts, model responses, and relevant conversation context are transmitted to `https://api.cheaperinference.com`. +- `api-route`: OpenAI-compatible Chat Completions preset for [API Route](https://www.api-route.com/en/docs/quickstart). `memoryApiUrl` and `memoryModel` are optional — they default to `https://global.api-route.com/v1` and `gpt-6.1-sol`. If `memoryApiKey` is omitted, `API_ROUTE_API_KEY` from the environment is used. Example: + ```jsonc + "memoryProvider": "api-route", + "memoryApiKey": "env://API_ROUTE_API_KEY", + ``` + When this provider is selected, auto-capture / profile prompts, model responses, and relevant conversation context are transmitted to `https://global.api-route.com`. Troubleshooting: @@ -508,7 +514,7 @@ Troubleshooting: - If auto-capture reports that a provider is not connected, confirm the provider name with `opencode providers list` and configure that provider in opencode first. - If a proxy or custom provider returns plain text instead of structured/tool output, choose another model/provider or use one of the manual provider modes above. - For models that reject `temperature`, add `"memoryTemperature": false` when using manual API configuration. -- For models that reject forced tool calls (`tool_choice: "required"`, e.g. some thinking modes), add `"forceToolChoice": false` when using `openai-chat` / `orcarouter` / `atlas-cloud` / `cheaper-inference`. +- For models that reject forced tool calls (`tool_choice: "required"`, e.g. some thinking modes), add `"forceToolChoice": false` when using `openai-chat` / `orcarouter` / `atlas-cloud` / `cheaper-inference` / `api-route`. - For `opencodeProvider` / `opencodeModel` (e.g. DeepSeek V4 thinking), OpenCode still sends forced `tool_choice` for structured output. opencode-mem disables thinking on the internal `opencode-mem-structured` agent (and re-applies that in `chat.params` after variant merge) so auto-capture and profile learning can complete. Your interactive chat agent is unchanged. If capture still fails with a thinking/`tool_choice` error, pick a non-thinking model for `opencodeModel` or configure a complete manual fallback (`memoryModel` + `memoryApiUrl`). - **`opencode-claude-auth` / Claude Code:** auto-capture uses OpenCode with your authenticated `anthropic` provider. Forced `format: json_schema` often loops with Claude-auth, so opencode-mem uses an auth-preserving **text-JSON** path for `opencodeProvider: "anthropic"` (no forced `StructuredOutput` tools; reply is parsed with Zod). A step watchdog still aborts runaway internal sessions after 2 steps. If capture still fails, configure a complete manual Anthropic API-key fallback (`memoryProvider: "anthropic"` + `memoryModel` + `memoryApiUrl` + `memoryApiKey`) — Claude Pro/Max OAuth cannot be reused outside OpenCode. - **Unsupported platforms:** Intel Mac (`darwin/x64`) is not supported — `@tursodatabase/database` and fixed `onnxruntime-node` releases (pinned `1.30.0`) ship no x64 native binding. Use Apple Silicon, Linux, or Windows, or a remote embedding endpoint via `embeddingApiUrl` + `embeddingApiKey`. MLX is not supported. diff --git a/i18n/README.ar.md b/i18n/README.ar.md index 586d55fc..030ae12b 100644 --- a/i18n/README.ar.md +++ b/i18n/README.ar.md @@ -488,6 +488,12 @@ memory({ mode: "import", inputPath: "./memories.json" }); "memoryApiKey": "env://CHEAPER_INFERENCE_API_KEY", ``` عند اختيار هذا المزود، تُرسل مطالبات الالتقاط التلقائي / الملف، واستجابات النموذج، وسياق المحادثة ذي الصلة إلى `https://api.cheaperinference.com`. +- `api-route`: إعداد مسبق لـ OpenAI-compatible Chat Completions لـ [API Route](https://www.api-route.com/en/docs/quickstart). `memoryApiUrl` و`memoryModel` اختياريان — الافتراضي `https://global.api-route.com/v1` و`gpt-6.1-sol`. إذا حُذف `memoryApiKey`، يُستخدم `API_ROUTE_API_KEY` من البيئة. مثال: + ```jsonc + "memoryProvider": "api-route", + "memoryApiKey": "env://API_ROUTE_API_KEY", + ``` + عند اختيار هذا المزود، تُرسل مطالبات الالتقاط التلقائي / الملف، واستجابات النموذج، وسياق المحادثة ذي الصلة إلى `https://global.api-route.com`. استكشاف الأخطاء: diff --git a/i18n/README.de.md b/i18n/README.de.md index e883a5ea..de5621e3 100644 --- a/i18n/README.de.md +++ b/i18n/README.de.md @@ -488,6 +488,12 @@ Manuelle `memoryProvider`-Modi: "memoryApiKey": "env://CHEAPER_INFERENCE_API_KEY", ``` Wenn dieser Provider gewählt ist, werden Auto-Capture- / Profil-Prompts, Modellantworten und relevanter Gesprächskontext an `https://api.cheaperinference.com` übertragen. +- `api-route`: OpenAI-kompatibles Chat-Completions-Preset für [API Route](https://www.api-route.com/en/docs/quickstart). `memoryApiUrl` und `memoryModel` sind optional — sie defaulten auf `https://global.api-route.com/v1` und `gpt-6.1-sol`. Fehlt `memoryApiKey`, wird `API_ROUTE_API_KEY` aus der Umgebung verwendet. Beispiel: + ```jsonc + "memoryProvider": "api-route", + "memoryApiKey": "env://API_ROUTE_API_KEY", + ``` + Wenn dieser Provider gewählt ist, werden Auto-Capture- / Profil-Prompts, Modellantworten und relevanter Gesprächskontext an `https://global.api-route.com` übertragen. Fehlerbehebung: diff --git a/i18n/README.nl.md b/i18n/README.nl.md index 01b5167f..7aa9ff17 100644 --- a/i18n/README.nl.md +++ b/i18n/README.nl.md @@ -489,6 +489,12 @@ Handmatige `memoryProvider`-modi: "memoryApiKey": "env://CHEAPER_INFERENCE_API_KEY", ``` Wanneer deze provider is geselecteerd, worden auto-capture- / profielprompts, modelantwoorden en relevante conversatiecontext verzonden naar `https://api.cheaperinference.com`. +- `api-route`: OpenAI-compatibel Chat Completions-preset voor [API Route](https://www.api-route.com/en/docs/quickstart). `memoryApiUrl` en `memoryModel` zijn optioneel — ze defaulten naar `https://global.api-route.com/v1` en `gpt-6.1-sol`. Als `memoryApiKey` wordt weggelaten, wordt `API_ROUTE_API_KEY` uit de omgeving gebruikt. Voorbeeld: + ```jsonc + "memoryProvider": "api-route", + "memoryApiKey": "env://API_ROUTE_API_KEY", + ``` + Wanneer deze provider is geselecteerd, worden auto-capture- / profielprompts, modelantwoorden en relevante conversatiecontext verzonden naar `https://global.api-route.com`. Probleemoplossing: diff --git a/i18n/README.tr.md b/i18n/README.tr.md index 70a5c018..c5c93086 100644 --- a/i18n/README.tr.md +++ b/i18n/README.tr.md @@ -490,6 +490,12 @@ Manuel `memoryProvider` modları: "memoryApiKey": "env://CHEAPER_INFERENCE_API_KEY", ``` Bu sağlayıcı seçildiğinde otomatik yakalama / profil prompt’ları, model yanıtları ve ilgili konuşma bağlamı `https://api.cheaperinference.com` adresine iletilir. +- `api-route`: [API Route](https://www.api-route.com/en/docs/quickstart) için OpenAI uyumlu Chat Completions ön ayarı. `memoryApiUrl` ve `memoryModel` isteğe bağlıdır — varsayılanları `https://global.api-route.com/v1` ve `gpt-6.1-sol`’dir. `memoryApiKey` atlanırsa ortamdan `API_ROUTE_API_KEY` kullanılır. Örnek: + ```jsonc + "memoryProvider": "api-route", + "memoryApiKey": "env://API_ROUTE_API_KEY", + ``` + Bu sağlayıcı seçildiğinde otomatik yakalama / profil prompt’ları, model yanıtları ve ilgili konuşma bağlamı `https://global.api-route.com` adresine iletilir. Sorun giderme: diff --git a/i18n/README.zh.md b/i18n/README.zh.md index d85bd844..400636e1 100644 --- a/i18n/README.zh.md +++ b/i18n/README.zh.md @@ -485,6 +485,12 @@ memory({ mode: "import", inputPath: "./memories.json" }); "memoryApiKey": "env://CHEAPER_INFERENCE_API_KEY", ``` 选择此提供商时,自动捕获 / 画像提示、模型响应与相关对话上下文会传输到 `https://api.cheaperinference.com`。 +- `api-route`:面向 [API Route](https://www.api-route.com/en/docs/quickstart) 的 OpenAI 兼容 Chat Completions 预设。`memoryApiUrl` 与 `memoryModel` 可选 — 它们默认为 `https://global.api-route.com/v1` 与 `gpt-6.1-sol`。若省略 `memoryApiKey`,则使用环境中的 `API_ROUTE_API_KEY`。示例: + ```jsonc + "memoryProvider": "api-route", + "memoryApiKey": "env://API_ROUTE_API_KEY", + ``` + 选择此提供商时,自动捕获 / 画像提示、模型响应与相关对话上下文会传输到 `https://global.api-route.com`。 故障排除: diff --git a/opencode-mem.example.jsonc b/opencode-mem.example.jsonc index f63b3ca1..8a1a9a3b 100644 --- a/opencode-mem.example.jsonc +++ b/opencode-mem.example.jsonc @@ -189,7 +189,7 @@ // Faster opencode failover: also lower "opencodeTimeoutMs" (default 90000). // "autoCaptureProviderOrder": "external-first", - // Provider type: "atlas-cloud" | "cheaper-inference" | "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter" + // Provider type: "api-route" | "atlas-cloud" | "cheaper-inference" | "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter" // Note: "openai-chat" is a generic OpenAI API-compatible mode. // Any service that follows the OpenAI Chat Completions API can use it via custom "memoryApiUrl". "memoryProvider": "openai-chat", @@ -271,6 +271,15 @@ // // conversation context are sent to api.cheaperinference.com. // // "memoryModel": "gpt-5.4-mini" + // API Route (OpenAI-compatible Chat Completions preset, with session support): + // "memoryProvider": "api-route" + // "memoryApiKey": "env://API_ROUTE_API_KEY" + // // memoryApiUrl and memoryModel are optional — they default to + // // https://global.api-route.com/v1 and "gpt-6.1-sol". + // // When selected, auto-capture/profile prompts, responses, and relevant + // // conversation context are sent to global.api-route.com. + // // "memoryModel": "gpt-6.1-sol" + // Groq (OpenAI-compatible, use openai-chat provider): // "memoryProvider": "openai-chat" // "memoryModel": "llama-3.3-70b-versatile" @@ -299,7 +308,7 @@ // Set to false and add "memoryTemperature": false in config when using such models "memoryTemperature": 0.3, - // Force tool calls on openai-chat / orcarouter / atlas-cloud / cheaper-inference (tool_choice: "required"). Default true. + // Force tool calls on openai-chat / orcarouter / atlas-cloud / cheaper-inference / api-route (tool_choice: "required"). Default true. // Some thinking/reasoning models reject forced tool choice — set false to fall back to "auto": // "forceToolChoice": false, diff --git a/src/config.ts b/src/config.ts index 51e90cea..e87481de 100644 --- a/src/config.ts +++ b/src/config.ts @@ -74,6 +74,7 @@ interface OpenCodeMemConfig { autoCaptureProviderOrder?: "opencode-first" | "external-first"; memoryProvider?: | "atlas-cloud" + | "api-route" | "cheaper-inference" | "openai-chat" | "openai-responses" @@ -224,6 +225,7 @@ const DEFAULTS: Required< memoryApiKey?: string; memoryProvider?: | "atlas-cloud" + | "api-route" | "cheaper-inference" | "openai-chat" | "openai-responses" @@ -507,7 +509,7 @@ const CONFIG_TEMPLATE = `{ "autoCaptureEnabled": true, - // Provider type: "atlas-cloud" | "cheaper-inference" | "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter" + // Provider type: "api-route" | "atlas-cloud" | "cheaper-inference" | "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter" // Note: "openai-chat" is a generic OpenAI API-compatible mode. // Any service that follows the OpenAI Chat Completions API can use it via custom "memoryApiUrl". "memoryProvider": "openai-chat", @@ -589,6 +591,15 @@ const CONFIG_TEMPLATE = `{ // // conversation context are sent to api.cheaperinference.com. // // "memoryModel": "gpt-5.4-mini" + // API Route (OpenAI-compatible Chat Completions preset, with session support): + // "memoryProvider": "api-route" + // "memoryApiKey": "env://API_ROUTE_API_KEY" + // // memoryApiUrl and memoryModel are optional — they default to + // // https://global.api-route.com/v1 and "gpt-6.1-sol". + // // When selected, auto-capture/profile prompts, responses, and relevant + // // conversation context are sent to global.api-route.com. + // // "memoryModel": "gpt-6.1-sol" + // Groq (OpenAI-compatible, use openai-chat provider): // "memoryProvider": "openai-chat" // "memoryModel": "llama-3.3-70b-versatile" @@ -624,7 +635,7 @@ const CONFIG_TEMPLATE = `{ // Set to false and add "memoryTemperature": false in config when using such models "memoryTemperature": 0.3, - // Force tool calls on openai-chat / orcarouter / atlas-cloud / cheaper-inference (tool_choice: "required"). Default true. + // Force tool calls on openai-chat / orcarouter / atlas-cloud / cheaper-inference / api-route (tool_choice: "required"). Default true. // Some thinking/reasoning models reject forced tool choice — set false to fall back to "auto": // "forceToolChoice": false, @@ -1004,6 +1015,7 @@ export function normalizeInjectionMarkers(value: string[] | undefined): string[] function buildConfig(fileConfig: OpenCodeMemConfig) { const memoryProvider = (fileConfig.memoryProvider ?? "openai-chat") as | "atlas-cloud" + | "api-route" | "cheaper-inference" | "openai-chat" | "openai-responses" @@ -1016,7 +1028,9 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { ? process.env.ATLASCLOUD_API_KEY : memoryProvider === "cheaper-inference" ? process.env.CHEAPER_INFERENCE_API_KEY - : undefined); + : memoryProvider === "api-route" + ? process.env.API_ROUTE_API_KEY + : undefined); const embeddingModel = fileConfig.embeddingModel ?? DEFAULTS.embeddingModel; const embeddingPreset = getEmbeddingModelPreset(embeddingModel); const embeddingDimensions = @@ -1245,7 +1259,8 @@ export function getAutoCaptureProviderStatus( if ( config.memoryProvider === "orcarouter" || config.memoryProvider === "atlas-cloud" || - config.memoryProvider === "cheaper-inference" + config.memoryProvider === "cheaper-inference" || + config.memoryProvider === "api-route" ) { if (!hasMemoryApiKey) issues.push("memoryApiKey is not configured"); if (hasPlaceholderMemoryApiKey) issues.push("memoryApiKey contains a placeholder value"); diff --git a/src/services/ai/ai-provider-factory.ts b/src/services/ai/ai-provider-factory.ts index 94e0b207..422cabbf 100644 --- a/src/services/ai/ai-provider-factory.ts +++ b/src/services/ai/ai-provider-factory.ts @@ -5,6 +5,7 @@ import { AnthropicMessagesProvider } from "./providers/anthropic-messages.js"; import { MiniMaxProvider } from "./providers/minimax.js"; import { GoogleGeminiProvider } from "./providers/google-gemini.js"; import { AtlasCloudProvider } from "./providers/atlas-cloud.js"; +import { APIRouteProvider } from "./providers/api-route.js"; import { CheaperInferenceProvider } from "./providers/cheaper-inference.js"; import { OrcaRouterProvider } from "./providers/orcarouter.js"; import { aiSessionManager } from "./session/ai-session-manager.js"; @@ -16,6 +17,9 @@ export class AIProviderFactory { case "atlas-cloud": return new AtlasCloudProvider(config, aiSessionManager); + case "api-route": + return new APIRouteProvider(config, aiSessionManager); + case "cheaper-inference": return new CheaperInferenceProvider(config, aiSessionManager); @@ -46,6 +50,7 @@ export class AIProviderFactory { return [ "atlas-cloud", "cheaper-inference", + "api-route", "openai-chat", "openai-responses", "anthropic", diff --git a/src/services/ai/provider-config.ts b/src/services/ai/provider-config.ts index 0420136a..20b29b72 100644 --- a/src/services/ai/provider-config.ts +++ b/src/services/ai/provider-config.ts @@ -32,7 +32,8 @@ export function buildMemoryProviderConfig( const isPresetProvider = config.memoryProvider === "orcarouter" || config.memoryProvider === "atlas-cloud" || - config.memoryProvider === "cheaper-inference"; + config.memoryProvider === "cheaper-inference" || + config.memoryProvider === "api-route"; if (!memoryModel && !isPresetProvider) issues.push("missing memoryModel"); if (!memoryApiUrl && !isPresetProvider) issues.push("missing memoryApiUrl"); diff --git a/src/services/ai/providers/api-route.ts b/src/services/ai/providers/api-route.ts new file mode 100644 index 00000000..effbba46 --- /dev/null +++ b/src/services/ai/providers/api-route.ts @@ -0,0 +1,67 @@ +import { OpenAIChatCompletionProvider } from "./openai-chat-completion.js"; +import type { ProviderConfig } from "./base-provider.js"; +import type { AISessionManager } from "../session/ai-session-manager.js"; +import type { AIProviderType } from "../session/session-types.js"; + +/** API Route OpenAI-compatible endpoint used when `memoryApiUrl` is omitted. */ +export const API_ROUTE_API_URL = "https://global.api-route.com/v1"; + +/** + * Default model when `memoryModel` is omitted. API Route model IDs are + * sent unchanged (e.g. `gpt-6.1-sol`). + */ +export const API_ROUTE_DEFAULT_MODEL = "gpt-6.1-sol"; + +/** + * API Route provider. + * + * [API Route](https://www.api-route.com/en/docs/quickstart) exposes an OpenAI-compatible + * Chat Completions API. This provider reuses the OpenAI Chat Completions request + * handling and only overrides the resolved endpoint, default model, and the + * session provider tag, so API Route sessions are distinguishable in the + * session store and diagnostics. + * + * Users configure it as: + * "memoryProvider": "api-route" + * "memoryApiKey": "env://API_ROUTE_API_KEY" + * + * `memoryApiUrl` and `memoryModel` are optional — they default to the API Route endpoint and `gpt-6.1-sol` respectively. + * + * When this provider is selected, auto-capture / profile prompts, model + * responses, and relevant conversation context are transmitted to + * `https://global.api-route.com`. + */ +export class APIRouteProvider extends OpenAIChatCompletionProvider { + constructor(config: ProviderConfig, aiSessionManager: AISessionManager) { + super(config, aiSessionManager); + } + + override getProviderName(): string { + return "api-route"; + } + + protected override sessionProviderTag(): AIProviderType { + return "api-route"; + } + + /** + * Resolve the OpenAI-compatible endpoint. + * + * Defaults to the API Route API when `memoryApiUrl` is not configured, + * so a minimal config only needs `memoryProvider` + `memoryApiKey`. + */ + override resolveEndpoint(): string { + const base = (this.config.apiUrl || "").trim().replace(/\/+$/, ""); + return base || API_ROUTE_API_URL; + } + + /** + * Resolve the model ID to send to API Route. + * + * Defaults to `gpt-6.1-sol` when `memoryModel` is not configured. + */ + override resolveModel(): string { + const model = (this.config.model || "").trim(); + return model || API_ROUTE_DEFAULT_MODEL; + } +} diff --git a/src/services/ai/session/session-types.ts b/src/services/ai/session/session-types.ts index a9bbf21b..f7d607d1 100644 --- a/src/services/ai/session/session-types.ts +++ b/src/services/ai/session/session-types.ts @@ -1,5 +1,6 @@ export type AIProviderType = | "atlas-cloud" + | "api-route" | "cheaper-inference" | "openai-chat" | "openai-responses" diff --git a/src/types/index.ts b/src/types/index.ts index d7240cf1..2d65fd40 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -19,6 +19,7 @@ export interface MemoryMetadata { export type AIProviderType = | "atlas-cloud" + | "api-route" | "cheaper-inference" | "openai-chat" | "openai-responses" diff --git a/tests/ai-provider-config.test.ts b/tests/ai-provider-config.test.ts index 13e5ead3..e12a40c1 100644 --- a/tests/ai-provider-config.test.ts +++ b/tests/ai-provider-config.test.ts @@ -212,6 +212,30 @@ describe("AI provider config", () => { ).toThrow("missing memoryApiKey"); }); + it("builds api-route config from only an API key, defaulting model and endpoint", () => { + const providerConfig = buildMemoryProviderConfig({ + memoryProvider: "api-route", + memoryApiKey: "route-test-key", + }); + + expect(providerConfig).toEqual({ + model: "", + apiUrl: "", + apiKey: "route-test-key", + forceToolChoice: undefined, + maxIterations: undefined, + iterationTimeout: undefined, + }); + }); + + it("still requires an API key for the api-route provider", () => { + expect(() => + buildMemoryProviderConfig({ + memoryProvider: "api-route", + }) + ).toThrow("missing memoryApiKey"); + }); + it("omits temperature for openai-chat when memoryTemperature is false", async () => { let capturedBody: Record | undefined; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { diff --git a/tests/api-route-provider.test.ts b/tests/api-route-provider.test.ts new file mode 100644 index 00000000..6f6a88a7 --- /dev/null +++ b/tests/api-route-provider.test.ts @@ -0,0 +1,206 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + APIRouteProvider, + API_ROUTE_API_URL, + API_ROUTE_DEFAULT_MODEL, +} from "../src/services/ai/providers/api-route.js"; +import { AIProviderFactory } from "../src/services/ai/ai-provider-factory.js"; +import type { ChatCompletionTool } from "../src/services/ai/tools/tool-schema.js"; + +const toolSchema: ChatCompletionTool = { + type: "function", + function: { + name: "save_memories", + description: "Save memories", + parameters: { + type: "object", + properties: {}, + required: [], + }, + }, +}; + +class FakeSessionManager { + private readonly session = { id: "session-1" }; + private readonly messages: any[] = []; + lastCreateSessionArgs: any; + + getSession(sessionId?: string, provider?: string): any { + void sessionId; + void provider; + return null; + } + + createSession(args: any): any { + this.lastCreateSessionArgs = args; + return this.session; + } + + getMessages(): any[] { + return this.messages; + } + + getLastSequence(): number { + return this.messages.length - 1; + } + + addMessage(message: any): void { + this.messages.push(message); + } +} + +function makeProvider( + overrides: Record = {}, + sessionManager = new FakeSessionManager() +) { + return { + provider: new APIRouteProvider( + { + model: "", + apiUrl: "", + apiKey: "route-test-key", + ...overrides, + }, + sessionManager as any + ), + sessionManager, + }; +} + +describe("APIRouteProvider", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("reports the api-route provider name", () => { + const { provider } = makeProvider(); + expect(provider.getProviderName()).toBe("api-route"); + expect(provider.supportsSession()).toBe(true); + }); + + it("defaults to the API Route endpoint when apiUrl is not configured", () => { + const { provider } = makeProvider(); + expect(provider.resolveEndpoint()).toBe(API_ROUTE_API_URL); + }); + + it("strips a trailing slash from a configured apiUrl", () => { + const { provider } = makeProvider({ apiUrl: "https://proxy.example.com/v1/" }); + expect(provider.resolveEndpoint()).toBe("https://proxy.example.com/v1"); + }); + + it("defaults to gpt-6.1-sol", () => { + const { provider } = makeProvider(); + expect(provider.resolveModel()).toBe(API_ROUTE_DEFAULT_MODEL); + }); + + it("returns a configured model unchanged", () => { + const { provider } = makeProvider({ model: "claude-sonnet-5" }); + expect(provider.resolveModel()).toBe("claude-sonnet-5"); + }); + + it("records the api-route session provider tag", async () => { + globalThis.fetch = (async () => + ({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "login fail", + }) as Response) as typeof fetch; + + const { provider, sessionManager } = makeProvider(); + await provider.executeToolCall("system", "user", toolSchema, "session-id"); + + expect(sessionManager.lastCreateSessionArgs?.provider).toBe("api-route"); + }); + + it("targets /chat/completions on API Route and authenticates with Bearer", async () => { + let capturedUrl: string | undefined; + let capturedHeaders: Record | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + capturedUrl = String(input); + capturedHeaders = init?.headers as Record; + return { + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "login fail", + } as Response; + }) as typeof fetch; + + const { provider } = makeProvider(); + await provider.executeToolCall("system", "user", toolSchema, "session-id"); + + expect(capturedUrl).toBe(`${API_ROUTE_API_URL}/chat/completions`); + expect(capturedHeaders?.["Authorization"]).toBe("Bearer route-test-key"); + }); + + it("sends the resolved default model in the request body", async () => { + let capturedBody: Record | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body ?? "{}")); + return { + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "login fail", + } as Response; + }) as typeof fetch; + + const { provider } = makeProvider(); + await provider.executeToolCall("system", "user", toolSchema, "session-id"); + + expect(capturedBody?.model).toBe(API_ROUTE_DEFAULT_MODEL); + expect(capturedBody?.tool_choice).toBe("required"); + expect(Array.isArray(capturedBody?.messages)).toBe(true); + expect(Array.isArray(capturedBody?.tools)).toBe(true); + }); + + it("extracts tool input from an OpenAI-format response", async () => { + globalThis.fetch = (async () => + ({ + ok: true, + status: 200, + json: async () => ({ + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "save_memories", + arguments: JSON.stringify({ memory: "captured fact" }), + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }), + }) as Response) as typeof fetch; + + const { provider } = makeProvider(); + const result = await provider.executeToolCall("system", "user", toolSchema, "session-id"); + + expect(result.success).toBe(true); + expect((result.data as any).memory).toBe("captured fact"); + }); +}); + +describe("AIProviderFactory api-route wiring", () => { + it("creates a API Route provider and lists it as supported", () => { + const provider = AIProviderFactory.createProvider("api-route", { + model: "", + apiUrl: "", + apiKey: "route-test-key", + }); + expect(provider).toBeInstanceOf(APIRouteProvider); + expect(provider.getProviderName()).toBe("api-route"); + expect(AIProviderFactory.getSupportedProviders()).toContain("api-route"); + }); +}); diff --git a/tests/config-resolution.test.ts b/tests/config-resolution.test.ts index e204cb33..4543df6c 100644 --- a/tests/config-resolution.test.ts +++ b/tests/config-resolution.test.ts @@ -437,6 +437,59 @@ describe("project-scoped config resolution", () => { }) as any; }); + expect(() => initConfig("/my/project")).toThrow( + /Project config cannot set remote provider fields: memoryProvider/ + ); + }); + it("resolves API Route API key from API_ROUTE_API_KEY and marks manual ready", () => { + const originalApiKey = process.env.API_ROUTE_API_KEY; + process.env.API_ROUTE_API_KEY = "route-test-key"; + + try { + existsSpy = spyOn(fs, "existsSync").mockReturnValue(true); + readSpy = spyOn(fs, "readFileSync").mockImplementation((p) => { + const path = normalizePath(p); + if (path.includes(".opencode/opencode-mem")) { + return JSON.stringify({ autoCaptureEnabled: true }) as any; + } + return JSON.stringify({ memoryProvider: "api-route" }) as any; + }); + + initConfig("/my/project"); + + expect(CONFIG.memoryProvider).toBe("api-route"); + expect(CONFIG.memoryModel).toBeUndefined(); + expect(CONFIG.memoryApiUrl).toBeUndefined(); + expect(CONFIG.memoryApiKey).toBe("route-test-key"); + expect(CONFIG.autoCaptureProviderStatus).toEqual({ + ready: true, + mode: "manual", + issues: [], + }); + } finally { + if (originalApiKey === undefined) { + delete process.env.API_ROUTE_API_KEY; + } else { + process.env.API_ROUTE_API_KEY = originalApiKey; + } + } + }); + + it("refuses a project config that switches memoryProvider to API Route", () => { + existsSpy = spyOn(fs, "existsSync").mockReturnValue(true); + readSpy = spyOn(fs, "readFileSync").mockImplementation((p) => { + const path = normalizePath(p); + if (path.includes(".opencode/opencode-mem")) { + return JSON.stringify({ memoryProvider: "api-route" }) as any; + } + return JSON.stringify({ + memoryProvider: "openai-chat", + memoryModel: "gpt-global", + memoryApiUrl: "https://api.openai.com/v1", + memoryApiKey: "global-openai-secret", + }) as any; + }); + expect(() => initConfig("/my/project")).toThrow( /Project config cannot set remote provider fields: memoryProvider/ ); From cf796b625ed657cd225dde88fdb0c31912a1ba10 Mon Sep 17 00:00:00 2001 From: EyJunge1 <149941075+EyJunge1@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:02:42 +0200 Subject: [PATCH 2/2] fix(ai): sync api-route docs and test wording Include api-route in translated forceToolChoice troubleshooting and use "an API Route" in the factory test title. --- i18n/README.ar.md | 2 +- i18n/README.de.md | 2 +- i18n/README.nl.md | 2 +- i18n/README.tr.md | 2 +- i18n/README.zh.md | 2 +- tests/api-route-provider.test.ts | 2 +- tests/config-resolution.test.ts | 1 + 7 files changed, 7 insertions(+), 6 deletions(-) diff --git a/i18n/README.ar.md b/i18n/README.ar.md index 030ae12b..20ea936d 100644 --- a/i18n/README.ar.md +++ b/i18n/README.ar.md @@ -501,7 +501,7 @@ memory({ mode: "import", inputPath: "./memories.json" }); - إذا أبلغ الالتقاط التلقائي أن مزودًا غير متصل، أكّد اسم المزود بـ `opencode providers list` وأعد ذلك المزود في opencode أولًا. - إذا أعاد وكيل أو مزود مخصص نصًا عاديًا بدل مخرجات منظمة/أدوات، اختر نموذجًا/مزودًا آخر أو استخدم أحد أوضاع المزود اليدوية أعلاه. - للنماذج التي ترفض `temperature`، أضف `"memoryTemperature": false` عند استخدام إعداد API اليدوي. -- للنماذج التي ترفض استدعاءات الأدوات المفروضة (`tool_choice: "required"`، مثل بعض أوضاع التفكير)، أضف `"forceToolChoice": false` عند استخدام `openai-chat` / `orcarouter` / `atlas-cloud` / `cheaper-inference`. +- للنماذج التي ترفض استدعاءات الأدوات المفروضة (`tool_choice: "required"`، مثل بعض أوضاع التفكير)، أضف `"forceToolChoice": false` عند استخدام `openai-chat` / `orcarouter` / `atlas-cloud` / `cheaper-inference` / `api-route`. - لـ `opencodeProvider` / `opencodeModel` (مثل DeepSeek V4 thinking)، ما زال OpenCode يرسل `tool_choice` مفروضًا للمخرجات المنظمة. يعطّل opencode-mem التفكير على الوكيل الداخلي `opencode-mem-structured` (ويعيده في `chat.params` بعد دمج المتغير) حتى يكتمل الالتقاط التلقائي وتعلّم الملف. وكيل الدردشة التفاعلي لديك دون تغيير. إذا فشل الالتقاط ما زال بخطأ تفكير/`tool_choice`، اختر نموذجًا غير تفكير لـ `opencodeModel` أو أعد رجوعًا يدويًا كاملًا (`memoryModel` + `memoryApiUrl`). - **`opencode-claude-auth` / Claude Code:** يستخدم الالتقاط التلقائي OpenCode مع مزودك `anthropic` المصادق. غالبًا ما يتكرر `format: json_schema` المفروض مع Claude-auth، لذا يستخدم opencode-mem مسار **نص-JSON** يحافظ على المصادقة لـ `opencodeProvider: "anthropic"` (بدون أدوات `StructuredOutput` مفروضة؛ تُحلَّل الرد بـ Zod). ما زال مراقب الخطوات يجهض الجلسات الداخلية الجامحة بعد خطوتين. إذا فشل الالتقاط ما زال، أعد رجوع Anthropic بمفتاح API يدوي كامل (`memoryProvider: "anthropic"` + `memoryModel` + `memoryApiUrl` + `memoryApiKey`) — لا يمكن إعادة استخدام Claude Pro/Max OAuth خارج OpenCode. - **المنصات غير المدعومة:** Intel Mac (`darwin/x64`) غير مدعوم — `@tursodatabase/database` وإصدارات `onnxruntime-node` الثابتة (المثبّت `1.30.0`) لا توفّر ربطًا أصليًا لـ x64. استخدم Apple Silicon أو Linux أو Windows، أو نقطة نهاية تضمين بعيدة عبر `embeddingApiUrl` + `embeddingApiKey`. MLX غير مدعوم. diff --git a/i18n/README.de.md b/i18n/README.de.md index de5621e3..f94d14df 100644 --- a/i18n/README.de.md +++ b/i18n/README.de.md @@ -501,7 +501,7 @@ Fehlerbehebung: - Meldet Auto-Capture, dass ein Provider nicht verbunden ist, den Provider-Namen mit `opencode providers list` bestätigen und diesen Provider zuerst in opencode konfigurieren. - Gibt ein Proxy oder Custom-Provider Klartext statt strukturierter/Tool-Ausgabe zurück, ein anderes Modell/einen anderen Provider wählen oder einen der manuellen Provider-Modi oben nutzen. - Für Modelle, die `temperature` ablehnen, bei manueller API-Konfiguration `"memoryTemperature": false` hinzufügen. -- Für Modelle, die erzwungene Tool-Calls ablehnen (`tool_choice: "required"`, z. B. manche Thinking-Modi), bei `openai-chat` / `orcarouter` / `atlas-cloud` / `cheaper-inference` `"forceToolChoice": false` hinzufügen. +- Für Modelle, die erzwungene Tool-Calls ablehnen (`tool_choice: "required"`, z. B. manche Thinking-Modi), bei `openai-chat` / `orcarouter` / `atlas-cloud` / `cheaper-inference` / `api-route` `"forceToolChoice": false` hinzufügen. - Bei `opencodeProvider` / `opencodeModel` (z. B. DeepSeek V4 Thinking) sendet OpenCode weiterhin erzwungenes `tool_choice` für strukturierte Ausgabe. opencode-mem deaktiviert Thinking auf dem internen Agenten `opencode-mem-structured` (und wendet das in `chat.params` nach Variant-Merge erneut an), damit Auto-Capture und Profil-Lernen abschließen können. Dein interaktiver Chat-Agent bleibt unverändert. Scheitert Capture weiterhin mit einem Thinking-/`tool_choice`-Fehler, für `opencodeModel` ein Non-Thinking-Modell wählen oder einen vollständigen manuellen Fallback konfigurieren (`memoryModel` + `memoryApiUrl`). - **`opencode-claude-auth` / Claude Code:** Auto-Capture nutzt OpenCode mit deinem authentifizierten `anthropic`-Provider. Erzwungenes `format: json_schema` loopt oft mit Claude-Auth, daher nutzt opencode-mem einen Auth-erhaltenden **Text-JSON**-Pfad für `opencodeProvider: "anthropic"` (keine erzwungenen `StructuredOutput`-Tools; Antwort wird mit Zod geparst). Ein Step-Watchdog bricht runaway interne Sitzungen nach 2 Schritten weiterhin ab. Scheitert Capture weiterhin, einen vollständigen manuellen Anthropic-API-Key-Fallback konfigurieren (`memoryProvider: "anthropic"` + `memoryModel` + `memoryApiUrl` + `memoryApiKey`) — Claude Pro/Max OAuth lässt sich außerhalb von OpenCode nicht wiederverwenden. - **Nicht unterstützte Plattformen:** Intel Mac (`darwin/x64`) wird nicht unterstützt — `@tursodatabase/database` und feste `onnxruntime-node`-Releases (gepinnt `1.30.0`) liefern kein natives x64-Binding. Apple Silicon, Linux oder Windows nutzen, oder einen Remote-Embedding-Endpoint über `embeddingApiUrl` + `embeddingApiKey`. MLX wird nicht unterstützt. diff --git a/i18n/README.nl.md b/i18n/README.nl.md index 7aa9ff17..cfce6413 100644 --- a/i18n/README.nl.md +++ b/i18n/README.nl.md @@ -502,7 +502,7 @@ Probleemoplossing: - Als auto-capture meldt dat een provider niet verbonden is, bevestig de providernaam met `opencode providers list` en configureer die provider eerst in opencode. - Als een proxy of custom provider platte tekst teruggeeft in plaats van structured/tool-output, kies een ander model/provider of gebruik een van de handmatige providermodi hierboven. - Voor modellen die `temperature` weigeren, voeg `"memoryTemperature": false` toe bij handmatige API-configuratie. -- Voor modellen die geforceerde tool calls weigeren (`tool_choice: "required"`, bijv. sommige thinking-modi), voeg `"forceToolChoice": false` toe bij `openai-chat` / `orcarouter` / `atlas-cloud` / `cheaper-inference`. +- Voor modellen die geforceerde tool calls weigeren (`tool_choice: "required"`, bijv. sommige thinking-modi), voeg `"forceToolChoice": false` toe bij `openai-chat` / `orcarouter` / `atlas-cloud` / `cheaper-inference` / `api-route`. - Voor `opencodeProvider` / `opencodeModel` (bijv. DeepSeek V4 thinking) stuurt OpenCode nog steeds geforceerde `tool_choice` voor structured output. opencode-mem schakelt thinking uit op de interne `opencode-mem-structured`-agent (en past dat opnieuw toe in `chat.params` na variant-merge) zodat auto-capture en profiel-leren kunnen voltooien. Je interactieve chatagent blijft ongewijzigd. Als capture nog steeds faalt met een thinking/`tool_choice`-fout, kies een non-thinking model voor `opencodeModel` of configureer een complete handmatige fallback (`memoryModel` + `memoryApiUrl`). - **`opencode-claude-auth` / Claude Code:** auto-capture gebruikt OpenCode met je geauthenticeerde `anthropic`-provider. Geforceerde `format: json_schema` loopt vaak vast met Claude-auth, dus opencode-mem gebruikt een auth-behoudend **text-JSON**-pad voor `opencodeProvider: "anthropic"` (geen geforceerde `StructuredOutput`-tools; antwoord wordt met Zod geparsed). Een step-watchdog breekt runaway interne sessies nog steeds af na 2 stappen. Als capture nog steeds faalt, configureer een complete handmatige Anthropic API-key-fallback (`memoryProvider: "anthropic"` + `memoryModel` + `memoryApiUrl` + `memoryApiKey`) — Claude Pro/Max OAuth kan niet buiten OpenCode worden hergebruikt. - **Niet-ondersteunde platforms:** Intel Mac (`darwin/x64`) wordt niet ondersteund — `@tursodatabase/database` en vaste `onnxruntime-node`-releases (vastgezet `1.30.0`) leveren geen x64 native binding. Gebruik Apple Silicon, Linux of Windows, of een remote embedding-endpoint via `embeddingApiUrl` + `embeddingApiKey`. MLX wordt niet ondersteund. diff --git a/i18n/README.tr.md b/i18n/README.tr.md index c5c93086..be0eaeca 100644 --- a/i18n/README.tr.md +++ b/i18n/README.tr.md @@ -503,7 +503,7 @@ Sorun giderme: - Otomatik yakalama bir sağlayıcının bağlı olmadığını bildirirse, sağlayıcı adını `opencode providers list` ile doğrulayın ve önce o sağlayıcıyı opencode’da yapılandırın. - Bir proxy veya özel sağlayıcı yapılandırılmış/araç çıktısı yerine düz metin döndürürse, başka bir model/sağlayıcı seçin veya yukarıdaki manuel sağlayıcı modlarından birini kullanın. - `temperature` reddeden modeller için manuel API yapılandırması kullanırken `"memoryTemperature": false` ekleyin. -- Zorlanmış araç çağrılarını (`tool_choice: "required"`, örn. bazı düşünme modları) reddeden modeller için `openai-chat` / `orcarouter` / `atlas-cloud` / `cheaper-inference` kullanırken `"forceToolChoice": false` ekleyin. +- Zorlanmış araç çağrılarını (`tool_choice: "required"`, örn. bazı düşünme modları) reddeden modeller için `openai-chat` / `orcarouter` / `atlas-cloud` / `cheaper-inference` / `api-route` kullanırken `"forceToolChoice": false` ekleyin. - `opencodeProvider` / `opencodeModel` için (örn. DeepSeek V4 thinking), OpenCode yapılandırılmış çıktı için hâlâ zorlanmış `tool_choice` gönderir. opencode-mem dahili `opencode-mem-structured` ajanında düşünmeyi devre dışı bırakır (ve varyant birleştirmesinden sonra bunu `chat.params` içinde yeniden uygular); böylece otomatik yakalama ve profil öğrenme tamamlanabilir. Etkileşimli sohbet ajanınız değişmez. Yakalama hâlâ bir thinking/`tool_choice` hatasıyla başarısız olursa, `opencodeModel` için düşünmeyen bir model seçin veya eksiksiz bir manuel yedek yapılandırın (`memoryModel` + `memoryApiUrl`). - **`opencode-claude-auth` / Claude Code:** otomatik yakalama, kimlik doğrulanmış `anthropic` sağlayıcınızla OpenCode kullanır. Zorlanmış `format: json_schema` Claude-auth ile sık sık döngüye girer; bu nedenle opencode-mem `opencodeProvider: "anthropic"` için kimlik doğrulamayı koruyan bir **metin-JSON** yolu kullanır (zorlanmış `StructuredOutput` araçları yok; yanıt Zod ile ayrıştırılır). Bir adım watchdog’u hâlâ 2 adımdan sonra kaçak dahili oturumları iptal eder. Yakalama hâlâ başarısız olursa, eksiksiz bir manuel Anthropic API anahtarı yedeği yapılandırın (`memoryProvider: "anthropic"` + `memoryModel` + `memoryApiUrl` + `memoryApiKey`) — Claude Pro/Max OAuth OpenCode dışında yeniden kullanılamaz. - **Desteklenmeyen platformlar:** Intel Mac (`darwin/x64`) desteklenmez — `@tursodatabase/database` ve sabit `onnxruntime-node` sürümleri (sabitlenmiş `1.30.0`) x64 yerel bağlayıcı yayınlamaz. Apple Silicon, Linux veya Windows kullanın; ya da `embeddingApiUrl` + `embeddingApiKey` ile uzak bir embedding uç noktası. MLX desteklenmez. diff --git a/i18n/README.zh.md b/i18n/README.zh.md index 400636e1..522ab926 100644 --- a/i18n/README.zh.md +++ b/i18n/README.zh.md @@ -498,7 +498,7 @@ memory({ mode: "import", inputPath: "./memories.json" }); - 若自动捕获报告提供商未连接,请用 `opencode providers list` 确认提供商名称,并先在 opencode 中配置该提供商。 - 若代理或自定义提供商返回纯文本而非结构化/工具输出,请选择另一模型/提供商,或使用上方手动提供商模式之一。 - 对于拒绝 `temperature` 的模型,在使用手动 API 配置时添加 `"memoryTemperature": false`。 -- 对于拒绝强制工具调用(`tool_choice: "required"`,例如某些思考模式)的模型,在使用 `openai-chat` / `orcarouter` / `atlas-cloud` / `cheaper-inference` 时添加 `"forceToolChoice": false`。 +- 对于拒绝强制工具调用(`tool_choice: "required"`,例如某些思考模式)的模型,在使用 `openai-chat` / `orcarouter` / `atlas-cloud` / `cheaper-inference` / `api-route` 时添加 `"forceToolChoice": false`。 - 对于 `opencodeProvider` / `opencodeModel`(例如 DeepSeek V4 thinking),OpenCode 仍会为结构化输出发送强制的 `tool_choice`。opencode-mem 在内部 `opencode-mem-structured` 代理上禁用思考(并在变体合并后在 `chat.params` 中重新应用),以便自动捕获与画像学习可以完成。你的交互式聊天代理不变。若捕获仍因 thinking/`tool_choice` 错误失败,请为 `opencodeModel` 选择非思考模型,或配置完整的手动回退(`memoryModel` + `memoryApiUrl`)。 - **`opencode-claude-auth` / Claude Code:** 自动捕获使用带有你已认证 `anthropic` 提供商的 OpenCode。强制的 `format: json_schema` 常与 Claude-auth 循环,因此 opencode-mem 对 `opencodeProvider: "anthropic"` 使用保留认证的 **text-JSON** 路径(无强制 `StructuredOutput` 工具;用 Zod 解析回复)。步骤看门狗仍会在 2 步后中止失控的内部会话。若捕获仍失败,请配置完整的手动 Anthropic API 密钥回退(`memoryProvider: "anthropic"` + `memoryModel` + `memoryApiUrl` + `memoryApiKey`)— Claude Pro/Max OAuth 无法在 OpenCode 外重用。 - **不支持的平台:** 不支持 Intel Mac(`darwin/x64`) — `@tursodatabase/database` 与固定版本的 `onnxruntime-node` 发行版(固定 `1.30.0`)不提供 x64 原生绑定。请使用 Apple Silicon、Linux 或 Windows,或通过 `embeddingApiUrl` + `embeddingApiKey` 使用远程嵌入端点。不支持 MLX。 diff --git a/tests/api-route-provider.test.ts b/tests/api-route-provider.test.ts index 6f6a88a7..fea3cfcc 100644 --- a/tests/api-route-provider.test.ts +++ b/tests/api-route-provider.test.ts @@ -193,7 +193,7 @@ describe("APIRouteProvider", () => { }); describe("AIProviderFactory api-route wiring", () => { - it("creates a API Route provider and lists it as supported", () => { + it("creates an API Route provider and lists it as supported", () => { const provider = AIProviderFactory.createProvider("api-route", { model: "", apiUrl: "", diff --git a/tests/config-resolution.test.ts b/tests/config-resolution.test.ts index 4543df6c..52f4b7be 100644 --- a/tests/config-resolution.test.ts +++ b/tests/config-resolution.test.ts @@ -441,6 +441,7 @@ describe("project-scoped config resolution", () => { /Project config cannot set remote provider fields: memoryProvider/ ); }); + it("resolves API Route API key from API_ROUTE_API_KEY and marks manual ready", () => { const originalApiKey = process.env.API_ROUTE_API_KEY; process.env.API_ROUTE_API_KEY = "route-test-key";