Skip to content

Commit cadb5f9

Browse files
authored
🤖 feat: add OpenRouter provider support (#550)
1 parent 97f490b commit cadb5f9

File tree

16 files changed

+1594
-149
lines changed

16 files changed

+1594
-149
lines changed

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# Required for integration tests when TEST_INTEGRATION=1
55
ANTHROPIC_API_KEY=sk-ant-...
66
OPENAI_API_KEY=sk-proj-...
7+
OPENROUTER_API_KEY=sk-or-v1-...
78

89
# Optional: Set to 1 to run integration tests
910
# Integration tests require API keys to be set

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ Here are some specific use cases we enable:
3737
- **Local**: git worktrees on your local machine ([docs](https://cmux.io/local.html))
3838
- **SSH**: regular git clones on a remote server ([docs](https://cmux.io/ssh.html))
3939
- Multi-model (`sonnet-4-*`, `gpt-5-*`, `opus-4-*`)
40-
- Ollama supported for local LLMs ([docs](https://cmux.io/models.html))
40+
- Ollama supported for local LLMs ([docs](https://cmux.io/models.html#ollama-local))
41+
- OpenRouter supported for long-tail of LLMs ([docs](https://cmux.io/models.html#openrouter-cloud))
4142
- Supporting UI and keybinds for efficiently managing a suite of agents
4243
- Rich markdown outputs (mermaid diagrams, LaTeX, etc.)
4344

bun.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"dependencies": {
77
"@ai-sdk/anthropic": "^2.0.29",
88
"@ai-sdk/openai": "^2.0.52",
9+
"@openrouter/ai-sdk-provider": "^1.2.1",
910
"@radix-ui/react-dialog": "^1.1.15",
1011
"@radix-ui/react-dropdown-menu": "^2.1.16",
1112
"@radix-ui/react-scroll-area": "^1.2.10",
@@ -405,6 +406,8 @@
405406

406407
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
407408

409+
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.2.1", "", { "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-sDc+/tlEM9VTsYlZ3YMwD9AHinSNusdLFGQhtb50eo5r68U/yBixEHRsKEevqSspiX3V6J06hU7C25t4KE9iag=="],
410+
408411
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
409412

410413
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],

docs/AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,11 @@ This project uses **Make** as the primary build orchestrator. See `Makefile` for
224224
- Always run `make typecheck` after making changes to verify types (checks both main and renderer)
225225
- **⚠️ CRITICAL: Unit tests MUST be colocated with the code they test** - Place `*.test.ts` files in the same directory as the implementation file (e.g., `src/utils/foo.test.ts` next to `src/utils/foo.ts`). Tests in `./tests/` are ONLY for integration/E2E tests that require complex setup.
226226
- **Don't test simple mapping operations** - If the test just verifies the code does what it obviously does from reading it, skip the test.
227+
-**Bad**: `expect(REGISTRY.foo).toBe("bar")` - This just duplicates the implementation
228+
-**Good**: `expect(Object.keys(REGISTRY).length).toBeGreaterThan(0)` - Tests an invariant
229+
-**Bad**: `expect(isValid("foo")).toBe(true)` for every valid value - Duplicates implementation
230+
-**Good**: `expect(isValid("invalid")).toBe(false)` - Tests boundary/error cases
231+
- **Rule of thumb**: If changing the implementation requires changing the test in the same way, the test is probably useless
227232
- Strive to decompose complex logic away from the components and into `.src/utils/`
228233
- utils should be either pure functions or easily isolated (e.g. if they operate on the FS they accept
229234
a path). Testing them should not require complex mocks or setup.

docs/models.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,79 @@ GPT-5 family of models:
2727

2828
TODO: add issue link here.
2929

30+
#### OpenRouter (Cloud)
31+
32+
Access 300+ models from multiple providers through a single API:
33+
34+
- `openrouter:z-ai/glm-4.6`
35+
- `openrouter:anthropic/claude-3.5-sonnet`
36+
- `openrouter:google/gemini-2.0-flash-thinking-exp`
37+
- `openrouter:deepseek/deepseek-chat`
38+
- `openrouter:openai/gpt-4o`
39+
- Any model from [OpenRouter Models](https://openrouter.ai/models)
40+
41+
**Setup:**
42+
43+
1. Get your API key from [openrouter.ai](https://openrouter.ai/)
44+
2. Add to `~/.cmux/providers.jsonc`:
45+
46+
```jsonc
47+
{
48+
"openrouter": {
49+
"apiKey": "sk-or-v1-...",
50+
},
51+
}
52+
```
53+
54+
**Provider Routing (Advanced):**
55+
56+
OpenRouter can route requests to specific infrastructure providers (Cerebras, Fireworks, Together, etc.). Configure provider preferences in `~/.cmux/providers.jsonc`:
57+
58+
```jsonc
59+
{
60+
"openrouter": {
61+
"apiKey": "sk-or-v1-...",
62+
// Use Cerebras for ultra-fast inference
63+
"order": ["Cerebras", "Fireworks"], // Try in order
64+
"allow_fallbacks": true, // Allow other providers if unavailable
65+
},
66+
}
67+
```
68+
69+
Or require a specific provider (no fallbacks):
70+
71+
```jsonc
72+
{
73+
"openrouter": {
74+
"apiKey": "sk-or-v1-...",
75+
"order": ["Cerebras"], // Only try Cerebras
76+
"allow_fallbacks": false, // Fail if Cerebras unavailable
77+
},
78+
}
79+
```
80+
81+
**Provider Routing Options:**
82+
83+
- `order`: Array of provider names to try in priority order (e.g., `["Cerebras", "Fireworks"]`)
84+
- `allow_fallbacks`: Boolean - whether to fall back to other providers (default: `true`)
85+
- `only`: Array - restrict to only these providers
86+
- `ignore`: Array - exclude specific providers
87+
- `require_parameters`: Boolean - only use providers supporting all your request parameters
88+
- `data_collection`: `"allow"` or `"deny"` - control whether providers can store/train on your data
89+
90+
See [OpenRouter Provider Routing docs](https://openrouter.ai/docs/features/provider-routing) for details.
91+
92+
**Reasoning Models:**
93+
94+
OpenRouter supports reasoning models like Claude Sonnet Thinking. Use the thinking slider to control reasoning effort:
95+
96+
- **Off**: No extended reasoning
97+
- **Low**: Quick reasoning for straightforward tasks
98+
- **Medium**: Standard reasoning for moderate complexity (default)
99+
- **High**: Deep reasoning for complex problems
100+
101+
The thinking level is passed to OpenRouter as `reasoning.effort` and works with any reasoning-capable model. See [OpenRouter Reasoning docs](https://openrouter.ai/docs/use-cases/reasoning-tokens) for details.
102+
30103
#### Ollama (Local)
31104

32105
Run models locally with Ollama. No API key required:
@@ -68,6 +141,10 @@ All providers are configured in `~/.cmux/providers.jsonc`. Example configuration
68141
"openai": {
69142
"apiKey": "sk-...",
70143
},
144+
// Required for OpenRouter models
145+
"openrouter": {
146+
"apiKey": "sk-or-v1-...",
147+
},
71148
// Optional for Ollama (only needed for custom URL)
72149
"ollama": {
73150
"baseUrl": "http://your-server:11434/api",

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"dependencies": {
4848
"@ai-sdk/anthropic": "^2.0.29",
4949
"@ai-sdk/openai": "^2.0.52",
50+
"@openrouter/ai-sdk-provider": "^1.2.1",
5051
"@radix-ui/react-dialog": "^1.1.15",
5152
"@radix-ui/react-dropdown-menu": "^2.1.16",
5253
"@radix-ui/react-scroll-area": "^1.2.10",

src/constants/providers.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Test that provider registry structure is correct
3+
*/
4+
5+
import { describe, test, expect } from "bun:test";
6+
import { PROVIDER_REGISTRY, SUPPORTED_PROVIDERS, isValidProvider } from "./providers";
7+
8+
describe("Provider Registry", () => {
9+
test("registry is not empty", () => {
10+
expect(Object.keys(PROVIDER_REGISTRY).length).toBeGreaterThan(0);
11+
});
12+
13+
test("all registry values are import functions", () => {
14+
// Registry should map provider names to async import functions
15+
for (const importFn of Object.values(PROVIDER_REGISTRY)) {
16+
expect(typeof importFn).toBe("function");
17+
expect(importFn.constructor.name).toBe("AsyncFunction");
18+
}
19+
});
20+
21+
test("SUPPORTED_PROVIDERS array stays in sync with registry keys", () => {
22+
// If these don't match, derived array is out of sync
23+
expect(SUPPORTED_PROVIDERS.length).toBe(Object.keys(PROVIDER_REGISTRY).length);
24+
});
25+
26+
test("isValidProvider rejects invalid providers", () => {
27+
expect(isValidProvider("invalid")).toBe(false);
28+
expect(isValidProvider("")).toBe(false);
29+
expect(isValidProvider("gpt-4")).toBe(false);
30+
});
31+
});

src/constants/providers.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Typed import helpers for provider packages
3+
*
4+
* These functions provide type-safe dynamic imports for provider packages.
5+
* TypeScript can infer the correct module type from literal string imports,
6+
* giving consuming code full type safety for provider constructors.
7+
*/
8+
9+
/**
10+
* Dynamically import the Anthropic provider package
11+
*/
12+
export async function importAnthropic() {
13+
return await import("@ai-sdk/anthropic");
14+
}
15+
16+
/**
17+
* Dynamically import the OpenAI provider package
18+
*/
19+
export async function importOpenAI() {
20+
return await import("@ai-sdk/openai");
21+
}
22+
23+
/**
24+
* Dynamically import the Ollama provider package
25+
*/
26+
export async function importOllama() {
27+
return await import("ollama-ai-provider-v2");
28+
}
29+
30+
/**
31+
* Dynamically import the OpenRouter provider package
32+
*/
33+
export async function importOpenRouter() {
34+
return await import("@openrouter/ai-sdk-provider");
35+
}
36+
37+
/**
38+
* Centralized provider registry mapping provider names to their import functions
39+
*
40+
* This is the single source of truth for supported providers. By mapping to import
41+
* functions rather than package strings, we eliminate duplication while maintaining
42+
* perfect type safety.
43+
*
44+
* When adding a new provider:
45+
* 1. Create an importXxx() function above
46+
* 2. Add entry mapping provider name to the import function
47+
* 3. Implement provider handling in aiService.ts createModel()
48+
* 4. Runtime check will fail if provider in registry but no handler
49+
*/
50+
export const PROVIDER_REGISTRY = {
51+
anthropic: importAnthropic,
52+
openai: importOpenAI,
53+
ollama: importOllama,
54+
openrouter: importOpenRouter,
55+
} as const;
56+
57+
/**
58+
* Union type of all supported provider names
59+
*/
60+
export type ProviderName = keyof typeof PROVIDER_REGISTRY;
61+
62+
/**
63+
* Array of all supported provider names (for UI lists, iteration, etc.)
64+
*/
65+
export const SUPPORTED_PROVIDERS = Object.keys(PROVIDER_REGISTRY) as ProviderName[];
66+
67+
/**
68+
* Type guard to check if a string is a valid provider name
69+
*/
70+
export function isValidProvider(provider: string): provider is ProviderName {
71+
return provider in PROVIDER_REGISTRY;
72+
}

0 commit comments

Comments
 (0)