From 2b0dad38d926e96e49a2f0d73c4405aa7ee60618 Mon Sep 17 00:00:00 2001 From: Marc-oss-hub Date: Sun, 16 Aug 2026 12:27:31 +0800 Subject: [PATCH] feat(providers): add OrcaRouter as an OpenAI-compatible provider Add a named `orcarouter` provider that mirrors the existing MiniMax wiring: a fixed base URL (`https://api.orcarouter.ai/v1`) on an OpenAI-compatible client, a namespaced default model (`openai/gpt-4o-mini`), and fail-closed embeddings since the gateway's embeddings endpoint is unverified. - src/providers/orcarouter.ts: OrcaRouterProvider extends OpenAIProvider - provider factory: SUPPORTED_PROVIDERS + getOrcaRouterProvider() (requires ORCAROUTER_API_KEY) - provider guard: ORCAROUTER_API_KEY key-var entry - docs: providers.mdx tab, environment-variables.mdx reference, README row - tests: factory + fail-closed embeddings, mirroring MiniMax Co-Authored-By: Claude Signed-off-by: Marc-oss-hub --- README.md | 3 +- docs/configuration/environment-variables.mdx | 10 +++++- docs/configuration/providers.mdx | 30 ++++++++++++++++++ src/providers/orcarouter.ts | 32 ++++++++++++++++++++ src/utils/constants.ts | 1 + src/utils/embedding-provider.ts | 9 +++--- src/utils/provider-guard.ts | 1 + src/utils/provider.ts | 22 +++++++++++--- test/embed-batch-providers.test.ts | 14 ++++++--- test/embedding-provider.test.ts | 2 +- test/provider-factory.test.ts | 15 +++++++++ 11 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 src/providers/orcarouter.ts diff --git a/README.md b/README.md index bc9f2f7f..72b42f29 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Do not use llmwiki as a general static-site generator, a heavy ontology database - **SDK.** `createWiki({ root })` drives ingest, compile, query, context, status, export, eval, and OKF import/export from TypeScript without shelling out. - **Open Knowledge Format exchange.** Export and import OKF bundles for portable, markdown-native knowledge exchange. External OKF imports are staged through the review queue by default; trusted bundles can be written live explicitly. - **Other portable exports.** Export JSON, JSON-LD, GraphML, Marp slides, and `llms.txt` for downstream systems. -- **Provider portable.** Anthropic, Claude Agent SDK local login, OpenAI-compatible servers, Ollama, GitHub Copilot, and local OpenAI-compatible runtimes. +- **Provider portable.** Anthropic, Claude Agent SDK local login, OpenAI-compatible servers, Ollama, GitHub Copilot, OrcaRouter, and local OpenAI-compatible runtimes. ## Configurable Lifecycle Profiles (CLP) @@ -274,6 +274,7 @@ Provider selection is environment-driven: | OpenAI-compatible | `LLMWIKI_PROVIDER=openai`, `OPENAI_API_KEY`, optional `OPENAI_BASE_URL` | | Ollama | `LLMWIKI_PROVIDER=ollama`, `OLLAMA_HOST` | | GitHub Copilot | `LLMWIKI_PROVIDER=copilot`, `GITHUB_TOKEN=$(gh auth token)` | +| OrcaRouter | `LLMWIKI_PROVIDER=orcarouter`, `ORCAROUTER_API_KEY` | See [`docs/configuration/providers.mdx`](docs/configuration/providers.mdx) and [`docs/configuration/environment-variables.mdx`](docs/configuration/environment-variables.mdx). diff --git a/docs/configuration/environment-variables.mdx b/docs/configuration/environment-variables.mdx index e5520e7d..9d0d58d3 100644 --- a/docs/configuration/environment-variables.mdx +++ b/docs/configuration/environment-variables.mdx @@ -14,7 +14,7 @@ You don't need to set everything - only the variables relevant to your chosen pr | Variable | Default | Description | |---|---|---| -| `LLMWIKI_PROVIDER` | `anthropic` | Provider to use. One of: `anthropic`, `openai`, `ollama`, `minimax`, `copilot`, `claude-agent` | +| `LLMWIKI_PROVIDER` | `anthropic` | Provider to use. One of: `anthropic`, `openai`, `ollama`, `minimax`, `copilot`, `claude-agent`, `orcarouter` | | `LLMWIKI_MODEL` | Provider default | Model name to use. Overrides the provider's built-in default model | | `LLMWIKI_EMBEDDING_PROVIDER` | Active chat provider | Backend serving embeddings, independent of `LLMWIKI_PROVIDER`. One of `anthropic`, `claude-agent`, `openai`, `ollama` | | `LLMWIKI_EMBEDDING_MODEL` | Provider default | Embedding model to use. Applies only when the effective embedding provider is `openai` or `ollama` - ignored for `anthropic` and `claude-agent`, even when `LLMWIKI_EMBEDDING_PROVIDER` names one of them explicitly | @@ -79,6 +79,14 @@ Either `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` satisfies authentication - --- +## OrcaRouter + +| Variable | Required | Description | +|---|---|---| +| `ORCAROUTER_API_KEY` | Yes (for `orcarouter` provider) | API key for the [OrcaRouter](https://www.orcarouter.ai) gateway (`sk-orca-...`). Model names are namespaced by upstream provider (e.g. `openai/gpt-4o-mini`); the default model is `openai/gpt-4o-mini` and `LLMWIKI_MODEL` overrides it | + +--- + ## Voyage (embeddings) | Variable | Required | Description | diff --git a/docs/configuration/providers.mdx b/docs/configuration/providers.mdx index 527edd31..58d229a3 100644 --- a/docs/configuration/providers.mdx +++ b/docs/configuration/providers.mdx @@ -220,6 +220,36 @@ export OPENAI_EMBEDDINGS_API_KEY= `GITHUB_TOKEN` must be a GitHub OAuth token obtained via `gh auth token` after running `gh auth refresh --scopes copilot`. Classic personal access tokens (PATs) are rejected by the Copilot API and will not work. + + +The `orcarouter` provider routes calls through [OrcaRouter](https://www.orcarouter.ai), a hosted multi-provider AI gateway that exposes an OpenAI-compatible API. Model names are namespaced by upstream provider (for example `openai/gpt-4o-mini`), and the built-in default model is `openai/gpt-4o-mini`. + +**Variables** + +| Variable | Required | Description | +|---|---|---| +| `LLMWIKI_PROVIDER` | Yes | Set to `orcarouter` | +| `ORCAROUTER_API_KEY` | Yes | Your OrcaRouter API key (`sk-orca-...`) | +| `LLMWIKI_MODEL` | No | Model name override. OrcaRouter requires a namespaced id such as `openai/gpt-4o-mini` or `anthropic/claude-sonnet-4-6` | + +**Setup** + +```bash +export LLMWIKI_PROVIDER=orcarouter +export ORCAROUTER_API_KEY=sk-orca-... +llmwiki compile +``` + +**Embeddings** + +OrcaRouter's embeddings endpoint is unverified in llmwiki, so the provider fails closed instead of inheriting OpenAI's embedding semantics. To keep OrcaRouter for chat while using embeddings, route them to another backend with `LLMWIKI_EMBEDDING_PROVIDER` - see [Environment Variables](/configuration/environment-variables): + +```bash +export LLMWIKI_PROVIDER=orcarouter +export LLMWIKI_EMBEDDING_PROVIDER=openai +export OPENAI_EMBEDDINGS_API_KEY= +``` + diff --git a/src/providers/orcarouter.ts b/src/providers/orcarouter.ts new file mode 100644 index 00000000..d959e5aa --- /dev/null +++ b/src/providers/orcarouter.ts @@ -0,0 +1,32 @@ +/** + * OrcaRouter LLM provider implementation. + * + * Extends OpenAIProvider since OrcaRouter exposes an OpenAI-compatible API. + * Overrides only the constructor to set OrcaRouter's base URL and API key. + */ + +import { OpenAIProvider } from "./openai.js"; + +/** OrcaRouter API base URL. */ +const ORCAROUTER_BASE_URL = "https://api.orcarouter.ai/v1"; + +/** OrcaRouter-backed LLM provider using the OpenAI-compatible endpoint. */ +export class OrcaRouterProvider extends OpenAIProvider { + constructor(model: string, apiKey: string) { + super(model, { baseURL: ORCAROUTER_BASE_URL, apiKey }); + } + + /** OrcaRouter embedding support is unverified; fail closed instead of inheriting OpenAI semantics. */ + override async embed(_text: string): Promise { + throw new Error( + "OrcaRouter provider does not support embeddings in llmwiki yet.\n" + + " For semantic search, use LLMWIKI_PROVIDER=openai, anthropic, claude-agent, or ollama.", + ); + } + + /** OrcaRouter batch embeddings are unsupported for the same reason as single embeddings. */ + override async embedBatch(_texts: string[]): Promise { + await this.embed(""); + return []; + } +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index eb651189..09dfee4b 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -73,6 +73,7 @@ export const PROVIDER_MODELS: Record = { openai: "gpt-4o", ollama: "llama3.1", minimax: "MiniMax-M2.7", + orcarouter: "openai/gpt-4o-mini", copilot: "gpt-4o", }; diff --git a/src/utils/embedding-provider.ts b/src/utils/embedding-provider.ts index c14b0e93..7d78e8dc 100644 --- a/src/utils/embedding-provider.ts +++ b/src/utils/embedding-provider.ts @@ -25,10 +25,11 @@ import type { LLMProvider } from "./provider.js"; import { buildProvider, getActiveProviderName, getProvider } from "./provider.js"; /** - * Providers that can actually serve embeddings. `minimax` and `copilot` override - * `embed()` to throw because their APIs expose no embeddings endpoint, so naming - * one here would only defer a guaranteed failure to compile time. Matches the - * EMBEDDING_MODELS / EMBED_BATCH_SIZES keys in constants.ts. + * Providers that can actually serve embeddings. `minimax`, `copilot`, and + * `orcarouter` override `embed()` to throw because their APIs expose no + * embeddings endpoint, so naming one here would only defer a guaranteed failure + * to compile time. Matches the EMBEDDING_MODELS / EMBED_BATCH_SIZES keys in + * constants.ts. */ const EMBEDDING_CAPABLE_PROVIDERS: ReadonlySet = new Set([ "anthropic", diff --git a/src/utils/provider-guard.ts b/src/utils/provider-guard.ts index fb8e1eda..4edca73d 100644 --- a/src/utils/provider-guard.ts +++ b/src/utils/provider-guard.ts @@ -46,6 +46,7 @@ const PROVIDER_KEY_VARS: Record = { openai: "OPENAI_API_KEY", ollama: null, minimax: "MINIMAX_API_KEY", + orcarouter: "ORCAROUTER_API_KEY", copilot: "GITHUB_TOKEN", }; diff --git a/src/utils/provider.ts b/src/utils/provider.ts index 0582da20..67dc4dbf 100644 --- a/src/utils/provider.ts +++ b/src/utils/provider.ts @@ -3,7 +3,7 @@ * * Defines the LLMProvider interface and a factory function that reads * LLMWIKI_PROVIDER and LLMWIKI_MODEL env vars to instantiate the - * appropriate backend (Anthropic, OpenAI, Ollama, or MiniMax). + * appropriate backend (Anthropic, OpenAI, Ollama, MiniMax, or OrcaRouter). */ import { DEFAULT_PROVIDER, PROVIDER_MODELS, OLLAMA_DEFAULT_HOST } from "./constants.js"; @@ -11,6 +11,7 @@ import { AnthropicProvider } from "../providers/anthropic.js"; import { OpenAIProvider } from "../providers/openai.js"; import { OllamaProvider } from "../providers/ollama.js"; import { MiniMaxProvider } from "../providers/minimax.js"; +import { OrcaRouterProvider } from "../providers/orcarouter.js"; import { CopilotProvider } from "../providers/copilot.js"; import { ClaudeAgentProvider } from "../providers/claude-agent.js"; import { @@ -56,7 +57,7 @@ export interface LLMProvider { embedBatch?(texts: string[], inputType?: EmbeddingInputType): Promise; } -const SUPPORTED_PROVIDERS: ReadonlySet = new Set(["anthropic", "claude-agent", "openai", "ollama", "minimax", "copilot"]); +const SUPPORTED_PROVIDERS: ReadonlySet = new Set(["anthropic", "claude-agent", "openai", "ollama", "minimax", "copilot", "orcarouter"]); /** * Construct the provider named `providerName`, independent of which provider is @@ -86,6 +87,8 @@ export function buildProvider(providerName: string): LLMProvider { }); case "minimax": return getMiniMaxProvider(); + case "orcarouter": + return getOrcaRouterProvider(); case "copilot": return getCopilotProvider(); default: @@ -107,7 +110,7 @@ function readOptionalEnv(name: string): string | undefined { return value ? value : undefined; } -function getModelForProvider(providerName: "openai" | "ollama" | "minimax" | "copilot"): string { +function getModelForProvider(providerName: "openai" | "ollama" | "minimax" | "orcarouter" | "copilot"): string { return process.env.LLMWIKI_MODEL ?? PROVIDER_MODELS[providerName]; } @@ -122,6 +125,17 @@ function getMiniMaxProvider(): MiniMaxProvider { return new MiniMaxProvider(getModelForProvider("minimax"), apiKey); } +function getOrcaRouterProvider(): OrcaRouterProvider { + const apiKey = process.env.ORCAROUTER_API_KEY; + if (!apiKey) { + throw new Error( + "OrcaRouter provider requires ORCAROUTER_API_KEY environment variable.\n" + + ' Set it with: export ORCAROUTER_API_KEY=your_key', + ); + } + return new OrcaRouterProvider(getModelForProvider("orcarouter"), apiKey); +} + function getCopilotProvider(): CopilotProvider { const apiKey = process.env.GITHUB_TOKEN; if (!apiKey) { @@ -185,5 +199,5 @@ export function resolveActiveModelId(): string { if (providerName === "anthropic") { return resolveAnthropicModelFromEnv() ?? PROVIDER_MODELS.anthropic; } - return getModelForProvider(providerName as "openai" | "ollama" | "minimax" | "copilot"); + return getModelForProvider(providerName as "openai" | "ollama" | "minimax" | "orcarouter" | "copilot"); } diff --git a/test/embed-batch-providers.test.ts b/test/embed-batch-providers.test.ts index 4c246082..af9b76c5 100644 --- a/test/embed-batch-providers.test.ts +++ b/test/embed-batch-providers.test.ts @@ -11,6 +11,7 @@ import { OpenAIProvider } from "../src/providers/openai.js"; import { voyageEmbed, voyageEmbedBatch } from "../src/providers/voyage-embed.js"; import { CopilotProvider } from "../src/providers/copilot.js"; import { MiniMaxProvider } from "../src/providers/minimax.js"; +import { OrcaRouterProvider } from "../src/providers/orcarouter.js"; // Build a provider and stub its embeddingsClient.embeddings.create. function providerWithEmbeddings(create: (args: unknown) => unknown): OpenAIProvider { @@ -100,14 +101,17 @@ describe("CopilotProvider.embedBatch", () => { }); }); -describe("MiniMaxProvider embeddings", () => { - it("throws explicit unsupported errors instead of inheriting OpenAI embeddings", async () => { - const p = new MiniMaxProvider("MiniMax-M2.7", "test-key"); +describe("providers without an embeddings endpoint", () => { + it.each<[string, () => OpenAIProvider, RegExp]>([ + ["MiniMax", () => new MiniMaxProvider("MiniMax-M2.7", "test-key"), /MiniMax.*does not support embeddings/i], + ["OrcaRouter", () => new OrcaRouterProvider("openai/gpt-4o-mini", "test-key"), /OrcaRouter.*does not support embeddings/i], + ])("%s throws explicit unsupported errors instead of inheriting OpenAI embeddings", async (_name, make, message) => { + const p = make(); Reflect.set(p, "embeddingsClient", { embeddings: { create: async () => ({ data: [{ embedding: [1] }] }) }, }); - await expect(p.embed("a")).rejects.toThrow(/MiniMax.*does not support embeddings/i); - await expect(p.embedBatch!(["a"])).rejects.toThrow(/MiniMax.*does not support embeddings/i); + await expect(p.embed("a")).rejects.toThrow(message); + await expect(p.embedBatch!(["a"])).rejects.toThrow(message); }); }); diff --git a/test/embedding-provider.test.ts b/test/embedding-provider.test.ts index 650e5845..408bf0f8 100644 --- a/test/embedding-provider.test.ts +++ b/test/embedding-provider.test.ts @@ -76,7 +76,7 @@ describe("getEmbeddingProvider — explicit override", () => { }); it("rejects providers whose embed() throws, naming the valid values", () => { - for (const name of ["copilot", "minimax", "not-a-provider"]) { + for (const name of ["copilot", "minimax", "orcarouter", "not-a-provider"]) { setEnv({ LLMWIKI_EMBEDDING_PROVIDER: name }); expect(() => getEmbeddingProvider()).toThrow(/anthropic.*claude-agent.*openai.*ollama/s); } diff --git a/test/provider-factory.test.ts b/test/provider-factory.test.ts index e1a2b686..8e8eb5d7 100644 --- a/test/provider-factory.test.ts +++ b/test/provider-factory.test.ts @@ -12,6 +12,7 @@ import { AnthropicProvider } from "../src/providers/anthropic.js"; import { OpenAIProvider } from "../src/providers/openai.js"; import { OllamaProvider } from "../src/providers/ollama.js"; import { MiniMaxProvider } from "../src/providers/minimax.js"; +import { OrcaRouterProvider } from "../src/providers/orcarouter.js"; const TEST_SETTINGS_PATH_ENV = "LLMWIKI_CLAUDE_SETTINGS_PATH"; const tempDirs: string[] = []; @@ -63,6 +64,7 @@ describe("getProvider", () => { delete process.env.OLLAMA_EMBEDDINGS_HOST; delete process.env[TEST_SETTINGS_PATH_ENV]; delete process.env.MINIMAX_API_KEY; + delete process.env.ORCAROUTER_API_KEY; for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); @@ -134,6 +136,19 @@ describe("getProvider", () => { expect(() => getProvider()).toThrow("MINIMAX_API_KEY"); }); + it("returns OrcaRouterProvider when LLMWIKI_PROVIDER=orcarouter", () => { + process.env.LLMWIKI_PROVIDER = "orcarouter"; + process.env.ORCAROUTER_API_KEY = "test-key"; + const provider = getProvider(); + expect(provider).toBeInstanceOf(OrcaRouterProvider); + }); + + it("throws when ORCAROUTER_API_KEY is absent for orcarouter provider", () => { + process.env.LLMWIKI_PROVIDER = "orcarouter"; + delete process.env.ORCAROUTER_API_KEY; + expect(() => getProvider()).toThrow("ORCAROUTER_API_KEY"); + }); + it("respects LLMWIKI_MODEL override", () => { process.env.LLMWIKI_PROVIDER = "openai"; process.env.LLMWIKI_MODEL = "gpt-4-turbo";