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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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).

Expand Down
10 changes: 9 additions & 1 deletion docs/configuration/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
30 changes: 30 additions & 0 deletions docs/configuration/providers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,36 @@ export OPENAI_EMBEDDINGS_API_KEY=<your-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.
</Warning>

<Tab title="OrcaRouter">

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=<your-key>
```

</Tab>
</Tabs>

Expand Down
32 changes: 32 additions & 0 deletions src/providers/orcarouter.ts
Original file line number Diff line number Diff line change
@@ -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<number[]> {
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<number[][]> {
await this.embed("");
return [];
}
}
1 change: 1 addition & 0 deletions src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export const PROVIDER_MODELS: Record<string, string> = {
openai: "gpt-4o",
ollama: "llama3.1",
minimax: "MiniMax-M2.7",
orcarouter: "openai/gpt-4o-mini",
copilot: "gpt-4o",
};

Expand Down
9 changes: 5 additions & 4 deletions src/utils/embedding-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set([
"anthropic",
Expand Down
1 change: 1 addition & 0 deletions src/utils/provider-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const PROVIDER_KEY_VARS: Record<string, string | null> = {
openai: "OPENAI_API_KEY",
ollama: null,
minimax: "MINIMAX_API_KEY",
orcarouter: "ORCAROUTER_API_KEY",
copilot: "GITHUB_TOKEN",
};

Expand Down
22 changes: 18 additions & 4 deletions src/utils/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
*
* 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";
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 {
Expand Down Expand Up @@ -56,7 +57,7 @@ export interface LLMProvider {
embedBatch?(texts: string[], inputType?: EmbeddingInputType): Promise<number[][]>;
}

const SUPPORTED_PROVIDERS: ReadonlySet<string> = new Set(["anthropic", "claude-agent", "openai", "ollama", "minimax", "copilot"]);
const SUPPORTED_PROVIDERS: ReadonlySet<string> = new Set(["anthropic", "claude-agent", "openai", "ollama", "minimax", "copilot", "orcarouter"]);

/**
* Construct the provider named `providerName`, independent of which provider is
Expand Down Expand Up @@ -86,6 +87,8 @@ export function buildProvider(providerName: string): LLMProvider {
});
case "minimax":
return getMiniMaxProvider();
case "orcarouter":
return getOrcaRouterProvider();
case "copilot":
return getCopilotProvider();
default:
Expand All @@ -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];
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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");
}
14 changes: 9 additions & 5 deletions test/embed-batch-providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion test/embedding-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
15 changes: 15 additions & 0 deletions test/provider-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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";
Expand Down
Loading