From bdabf8c0cbc0b42479350b8d9badd5c375166bd5 Mon Sep 17 00:00:00 2001 From: Marc-oss-hub Date: Sun, 16 Aug 2026 21:19:09 +0800 Subject: [PATCH] feat: add named orcarouter memory provider mode Add an `orcarouter` memoryProvider mode backed by a dedicated OrcaRouterProvider that extends the OpenAI Chat Completions provider. OrcaRouter (https://www.orcarouter.ai) is an OpenAI-compatible model gateway that requires namespaced model IDs. When `memoryApiUrl` and `memoryModel` are omitted they default to the gateway endpoint (https://api.orcarouter.ai/v1) and the `orcarouter/auto` routing alias, so a minimal config only needs `memoryProvider` + `memoryApiKey`. A bare model name is rejected with a hint instead of surfacing a gateway-side model_not_found error. The provider is tagged `orcarouter` in the AI session store and diagnostics. The base OpenAIChatCompletionProvider gains protected sessionProviderTag / resolveEndpoint / resolveModel hooks so subclasses can override endpoint, model, and session tagging without duplicating request handling. Co-Authored-By: Claude --- README.md | 6 + src/config.ts | 29 ++- src/services/ai/ai-provider-factory.ts | 13 +- src/services/ai/provider-config.ts | 9 +- .../ai/providers/openai-chat-completion.ts | 28 ++- src/services/ai/providers/orcarouter.ts | 78 +++++++ src/services/ai/session/session-types.ts | 2 +- src/types/index.ts | 2 +- tests/ai-provider-config.test.ts | 23 ++ tests/orcarouter-provider.test.ts | 214 ++++++++++++++++++ 10 files changed, 390 insertions(+), 14 deletions(-) create mode 100644 src/services/ai/providers/orcarouter.ts create mode 100644 tests/orcarouter-provider.test.ts diff --git a/README.md b/README.md index 9483ddb3..42b6ce25 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,12 @@ Manual `memoryProvider` modes: - `openai-responses`: OpenAI Responses API with function-call output. - `anthropic`: Anthropic Messages API with tool use. - `minimax`: MiniMax Anthropic Messages-compatible endpoint. Set `memoryApiUrl` to the global endpoint (`https://api.minimax.io`) or the China endpoint (`https://api.minimaxi.com`); the `/anthropic/v1/messages` path and `x-api-key` header are applied automatically. MiniMax text models such as `MiniMax-M3` support the adaptive thinking modes used by this plugin via `memoryExtraParams`. +- `orcarouter`: OpenAI-compatible model gateway with namespaced model IDs. `memoryApiUrl` and `memoryModel` are optional — they default to `https://api.orcarouter.ai/v1` and `orcarouter/auto` (a routing alias that selects a capable model per request). If you set `memoryModel`, use a namespaced ID such as `openai/gpt-5.5` or `deepseek/deepseek-v4-flash`; OrcaRouter rejects bare model names. Example: + ```jsonc + "memoryProvider": "orcarouter", + "memoryApiKey": "", + ``` + [OrcaRouter](https://www.orcarouter.ai) also runs gateway-level, zero-trust security for AI agents on the same endpoint — screening every prompt/response and governing every tool call on a default-deny basis, with no application code changes. Troubleshooting: diff --git a/src/config.ts b/src/config.ts index d983d5b4..b6784ad1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,7 +44,7 @@ interface OpenCodeMemConfig { autoCaptureMaxRetries?: number; autoCaptureMaxContextBytes?: number; autoCaptureLanguage?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax"; + memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter"; memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; @@ -127,7 +127,7 @@ const DEFAULTS: Required< memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax"; + memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter"; memoryTemperature?: number | false; memoryExtraParams?: Record; opencodeProvider?: string; @@ -349,7 +349,7 @@ const CONFIG_TEMPLATE = `{ "autoCaptureEnabled": true, - // Provider type: "openai-chat" | "openai-responses" | "anthropic" | "minimax" + // Provider type: "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", @@ -402,6 +402,15 @@ const CONFIG_TEMPLATE = `{ // // Optional adaptive thinking for MiniMax-M3: // "memoryExtraParams": { "thinking": { "type": "adaptive" } } + // OrcaRouter (OpenAI-compatible gateway, namespaced model IDs, with session support): + // "memoryProvider": "orcarouter" + // "memoryApiKey": "" + // // memoryApiUrl and memoryModel are optional — they default to + // // https://api.orcarouter.ai/v1 and "orcarouter/auto" (a routing alias). + // // OrcaRouter rejects bare model names, so if you set memoryModel, use a + // // namespaced ID such as "openai/gpt-5.5" or "deepseek/deepseek-v4-flash". + // "memoryModel": "openai/gpt-5.5" + // Groq (OpenAI-compatible, use openai-chat provider): // "memoryProvider": "openai-chat" // "memoryModel": "llama-3.3-70b-versatile" @@ -641,7 +650,7 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { autoCaptureMaxContextBytes, autoCaptureLanguage: fileConfig.autoCaptureLanguage, memoryProvider: (fileConfig.memoryProvider ?? "openai-chat") as - "openai-chat" | "openai-responses" | "anthropic" | "minimax", + "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter", memoryModel: fileConfig.memoryModel, memoryApiUrl: fileConfig.memoryApiUrl, memoryApiKey, @@ -747,6 +756,7 @@ type RuntimeConfig = ReturnType; interface AutoCaptureProviderRuntimeConfig { opencodeProvider?: string; opencodeModel?: string; + memoryProvider?: string; memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; @@ -779,6 +789,17 @@ export function getAutoCaptureProviderStatus( const hasMemoryApiKey = hasValue(config.memoryApiKey); const hasPlaceholderMemoryApiKey = isPlaceholderApiKey(config.memoryApiKey); + // The orcarouter provider presets its endpoint and default model, so only + // an API key is required for the manual fallback path. + if (config.memoryProvider === "orcarouter") { + if (!hasMemoryApiKey) issues.push("memoryApiKey is not configured"); + if (hasPlaceholderMemoryApiKey) issues.push("memoryApiKey contains a placeholder value"); + if (hasMemoryApiKey && !hasPlaceholderMemoryApiKey) { + return { ready: true, mode: "manual", issues: [] }; + } + return { ready: false, issues }; + } + if (!hasMemoryModel) issues.push("memoryModel is not configured"); if (!hasMemoryApiUrl) issues.push("memoryApiUrl is not configured"); if (!hasMemoryApiKey) issues.push("memoryApiKey is not configured"); diff --git a/src/services/ai/ai-provider-factory.ts b/src/services/ai/ai-provider-factory.ts index c3b5e08c..cf8de0c0 100644 --- a/src/services/ai/ai-provider-factory.ts +++ b/src/services/ai/ai-provider-factory.ts @@ -4,6 +4,7 @@ import { OpenAIResponsesProvider } from "./providers/openai-responses.js"; import { AnthropicMessagesProvider } from "./providers/anthropic-messages.js"; import { MiniMaxProvider } from "./providers/minimax.js"; import { GoogleGeminiProvider } from "./providers/google-gemini.js"; +import { OrcaRouterProvider } from "./providers/orcarouter.js"; import { aiSessionManager } from "./session/ai-session-manager.js"; import type { AIProviderType } from "./session/session-types.js"; @@ -25,13 +26,23 @@ export class AIProviderFactory { case "google-gemini": return new GoogleGeminiProvider(config, aiSessionManager); + case "orcarouter": + return new OrcaRouterProvider(config, aiSessionManager); + default: throw new Error(`Unknown provider type: ${providerType}`); } } static getSupportedProviders(): AIProviderType[] { - return ["openai-chat", "openai-responses", "anthropic", "minimax", "google-gemini"]; + return [ + "openai-chat", + "openai-responses", + "anthropic", + "minimax", + "google-gemini", + "orcarouter", + ]; } static async cleanupExpiredSessions(): Promise { diff --git a/src/services/ai/provider-config.ts b/src/services/ai/provider-config.ts index 7042eb20..6563e77b 100644 --- a/src/services/ai/provider-config.ts +++ b/src/services/ai/provider-config.ts @@ -2,6 +2,7 @@ import type { ProviderConfig } from "./providers/base-provider.js"; import { isPlaceholderApiKey } from "./api-key-placeholder.js"; interface MemoryProviderRuntimeConfig { + memoryProvider?: string; memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; @@ -25,8 +26,12 @@ export function buildMemoryProviderConfig( const memoryApiKey = config.memoryApiKey; const issues: string[] = []; - if (!memoryModel) issues.push("missing memoryModel"); - if (!memoryApiUrl) issues.push("missing memoryApiUrl"); + // The orcarouter provider presets its own endpoint and default model, so + // memoryModel / memoryApiUrl are optional there. An API key is always required. + const isOrcaRouter = config.memoryProvider === "orcarouter"; + + if (!memoryModel && !isOrcaRouter) issues.push("missing memoryModel"); + if (!memoryApiUrl && !isOrcaRouter) issues.push("missing memoryApiUrl"); if (!memoryApiKey) issues.push("missing memoryApiKey"); if (isPlaceholderApiKey(memoryApiKey)) issues.push("replace the placeholder memoryApiKey value"); diff --git a/src/services/ai/providers/openai-chat-completion.ts b/src/services/ai/providers/openai-chat-completion.ts index 48545daf..9ae645a7 100644 --- a/src/services/ai/providers/openai-chat-completion.ts +++ b/src/services/ai/providers/openai-chat-completion.ts @@ -5,7 +5,7 @@ import { applySafeExtraParams, } from "./base-provider.js"; import type { AISessionManager } from "../session/ai-session-manager.js"; -import type { AIMessage } from "../session/session-types.js"; +import type { AIMessage, AIProviderType } from "../session/session-types.js"; import type { ChatCompletionTool } from "../tools/tool-schema.js"; import { log } from "../../logger.js"; import { UserProfileValidator } from "../validators/user-profile-validator.js"; @@ -110,6 +110,24 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider { return true; } + /** Provider tag used for AI session storage and diagnostics. */ + protected sessionProviderTag(): AIProviderType { + return "openai-chat"; + } + + /** + * Resolve the OpenAI-compatible API base URL. + * Trailing slashes are stripped so `${base}/chat/completions` is well-formed. + */ + protected resolveEndpoint(): string { + return (this.config.apiUrl || "").trim().replace(/\/+$/, ""); + } + + /** Resolve the model ID sent in the request body. */ + protected resolveModel(): string { + return this.config.model; + } + private async addToolResponse( sessionId: string, messages: APIMessage[], @@ -177,11 +195,11 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider { toolSchema: ChatCompletionTool, sessionId: string ): Promise { - let session = await this.aiSessionManager.getSession(sessionId, "openai-chat"); + let session = await this.aiSessionManager.getSession(sessionId, this.sessionProviderTag()); if (!session) { session = await this.aiSessionManager.createSession({ - provider: "openai-chat", + provider: this.sessionProviderTag(), sessionId, }); } @@ -243,7 +261,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider { try { const requestBody: RequestBody = { - model: this.config.model, + model: this.resolveModel(), messages, tools: [toolSchema], tool_choice: "auto", @@ -265,7 +283,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider { headers.Authorization = `Bearer ${this.config.apiKey}`; } - const response = await fetch(`${this.config.apiUrl}/chat/completions`, { + const response = await fetch(`${this.resolveEndpoint()}/chat/completions`, { method: "POST", headers, body: JSON.stringify(requestBody), diff --git a/src/services/ai/providers/orcarouter.ts b/src/services/ai/providers/orcarouter.ts new file mode 100644 index 00000000..95056a99 --- /dev/null +++ b/src/services/ai/providers/orcarouter.ts @@ -0,0 +1,78 @@ +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"; + +/** OrcaRouter OpenAI-compatible endpoint used when `memoryApiUrl` is omitted. */ +export const ORCAROUTER_API_URL = "https://api.orcarouter.ai/v1"; + +/** + * Default model when `memoryModel` is omitted. `orcarouter/auto` is the + * gateway's routing alias — it picks a capable upstream model per request + * (including structured / tool-call output, which auto-capture relies on). + */ +export const ORCAROUTER_DEFAULT_MODEL = "orcarouter/auto"; + +/** + * OrcaRouter provider. + * + * [OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible model + * gateway. It rejects bare model names, so the gateway requires namespaced + * model IDs such as `orcarouter/auto`, `deepseek/deepseek-v4-flash`, or + * `openai/gpt-5.5`. This provider reuses the OpenAI Chat Completions request + * handling and only overrides the resolved endpoint, the model resolution + * (validating the namespace), and the session provider tag, so OrcaRouter is + * distinguishable in the session store and diagnostics. + * + * Users configure it as: + * "memoryProvider": "orcarouter" + * "memoryApiKey": "" + * + * `memoryApiUrl` and `memoryModel` are optional — they default to the gateway + * endpoint and `orcarouter/auto` respectively. + */ +export class OrcaRouterProvider extends OpenAIChatCompletionProvider { + constructor(config: ProviderConfig, aiSessionManager: AISessionManager) { + super(config, aiSessionManager); + } + + override getProviderName(): string { + return "orcarouter"; + } + + protected override sessionProviderTag(): AIProviderType { + return "orcarouter"; + } + + /** + * Resolve the OpenAI-compatible endpoint. + * + * Defaults to the OrcaRouter gateway 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 || ORCAROUTER_API_URL; + } + + /** + * Resolve the model ID to send to the gateway. + * + * Defaults to the `orcarouter/auto` routing alias when `memoryModel` is not + * configured. OrcaRouter rejects bare model names (e.g. `gpt-4o-mini`), so a + * namespaced ID is required — fail with a helpful message instead of a + * gateway-side `model_not_found` error. + */ + override resolveModel(): string { + const model = (this.config.model || "").trim(); + if (!model) { + return ORCAROUTER_DEFAULT_MODEL; + } + if (!model.includes("/")) { + throw new Error( + `OrcaRouter requires a namespaced memoryModel (e.g. "orcarouter/auto", "openai/gpt-5.5", "deepseek/deepseek-v4-flash"). Got: ${model}` + ); + } + return model; + } +} diff --git a/src/services/ai/session/session-types.ts b/src/services/ai/session/session-types.ts index 6e162d07..db4676c7 100644 --- a/src/services/ai/session/session-types.ts +++ b/src/services/ai/session/session-types.ts @@ -1,5 +1,5 @@ export type AIProviderType = - "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini"; + "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini" | "orcarouter"; export interface AIMessage { id?: number; diff --git a/src/types/index.ts b/src/types/index.ts index 5c6fc744..f6a0e820 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,4 +18,4 @@ export interface MemoryMetadata { } export type AIProviderType = - "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini"; + "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini" | "orcarouter"; diff --git a/tests/ai-provider-config.test.ts b/tests/ai-provider-config.test.ts index b866dd07..32f664dc 100644 --- a/tests/ai-provider-config.test.ts +++ b/tests/ai-provider-config.test.ts @@ -127,6 +127,29 @@ describe("AI provider config", () => { ).toThrow("missing memoryApiKey"); }); + it("builds orcarouter config from only an API key, defaulting model and endpoint", () => { + const providerConfig = buildMemoryProviderConfig({ + memoryProvider: "orcarouter", + memoryApiKey: "sk-orca-test", + }); + + expect(providerConfig).toEqual({ + model: "", + apiUrl: "", + apiKey: "sk-orca-test", + maxIterations: undefined, + iterationTimeout: undefined, + }); + }); + + it("still requires an API key for the orcarouter provider", () => { + expect(() => + buildMemoryProviderConfig({ + memoryProvider: "orcarouter", + }) + ).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/orcarouter-provider.test.ts b/tests/orcarouter-provider.test.ts new file mode 100644 index 00000000..38de5bb9 --- /dev/null +++ b/tests/orcarouter-provider.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + OrcaRouterProvider, + ORCAROUTER_API_URL, + ORCAROUTER_DEFAULT_MODEL, +} from "../src/services/ai/providers/orcarouter.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 OrcaRouterProvider( + { + model: "", + apiUrl: "", + apiKey: "sk-orca-test", + ...overrides, + }, + sessionManager as any + ), + sessionManager, + }; +} + +describe("OrcaRouterProvider", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("reports the orcarouter provider name", () => { + const { provider } = makeProvider(); + expect(provider.getProviderName()).toBe("orcarouter"); + expect(provider.supportsSession()).toBe(true); + }); + + it("defaults to the OrcaRouter gateway endpoint when apiUrl is not configured", () => { + const { provider } = makeProvider(); + expect(provider.resolveEndpoint()).toBe(ORCAROUTER_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 the orcarouter/auto routing model", () => { + const { provider } = makeProvider(); + expect(provider.resolveModel()).toBe(ORCAROUTER_DEFAULT_MODEL); + }); + + it("returns a namespaced configured model unchanged", () => { + const { provider } = makeProvider({ model: "deepseek/deepseek-v4-flash" }); + expect(provider.resolveModel()).toBe("deepseek/deepseek-v4-flash"); + }); + + it("rejects a bare model name with a namespacing hint", () => { + const { provider } = makeProvider({ model: "gpt-4o-mini" }); + expect(() => provider.resolveModel()).toThrow(/namespaced memoryModel/); + }); + + it("records the orcarouter 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("orcarouter"); + }); + + it("targets /chat/completions on the gateway 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(`${ORCAROUTER_API_URL}/chat/completions`); + expect(capturedHeaders?.["Authorization"]).toBe("Bearer sk-orca-test"); + }); + + 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(ORCAROUTER_DEFAULT_MODEL); + expect(capturedBody?.tool_choice).toBe("auto"); + expect(Array.isArray(capturedBody?.messages)).toBe(true); + expect(Array.isArray(capturedBody?.tools)).toBe(true); + }); + + it("extracts tool input from an OpenAI-format response", async () => { + let capturedBody: Record | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body ?? "{}")); + return { + 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(capturedBody?.model).toBe(ORCAROUTER_DEFAULT_MODEL); + expect(result.success).toBe(true); + expect((result.data as any).memory).toBe("captured fact"); + }); +}); + +describe("AIProviderFactory orcarouter wiring", () => { + it("creates an OrcaRouter provider and lists it as supported", () => { + const provider = AIProviderFactory.createProvider("orcarouter", { + model: "", + apiUrl: "", + apiKey: "sk-orca-test", + }); + expect(provider.getProviderName()).toBe("orcarouter"); + expect(AIProviderFactory.getSupportedProviders()).toContain("orcarouter"); + }); +});