Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 API key>",
```
[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:

Expand Down
29 changes: 25 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>;
opencodeProvider?: string;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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": "<OrcaRouter API key>"
// // 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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -747,6 +756,7 @@ type RuntimeConfig = ReturnType<typeof buildConfig>;
interface AutoCaptureProviderRuntimeConfig {
opencodeProvider?: string;
opencodeModel?: string;
memoryProvider?: string;
memoryModel?: string;
memoryApiUrl?: string;
memoryApiKey?: string;
Expand Down Expand Up @@ -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");
Expand Down
13 changes: 12 additions & 1 deletion src/services/ai/ai-provider-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<number> {
Expand Down
9 changes: 7 additions & 2 deletions src/services/ai/provider-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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");

Expand Down
28 changes: 23 additions & 5 deletions src/services/ai/providers/openai-chat-completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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[],
Expand Down Expand Up @@ -177,11 +195,11 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
toolSchema: ChatCompletionTool,
sessionId: string
): Promise<ToolCallResult> {
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,
});
}
Expand Down Expand Up @@ -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",
Expand All @@ -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),
Expand Down
78 changes: 78 additions & 0 deletions src/services/ai/providers/orcarouter.ts
Original file line number Diff line number Diff line change
@@ -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": "<OrcaRouter API key>"
*
* `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;
}
}
2 changes: 1 addition & 1 deletion src/services/ai/session/session-types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
23 changes: 23 additions & 0 deletions tests/ai-provider-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined;
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
Expand Down
Loading