Merge b714819604 into 4583f88626
This commit is contained in:
commit
dcddeea3b3
@ -33,3 +33,43 @@ moltbot onboard --auth-choice apiKey --token-provider openrouter --token "$OPENR
|
|||||||
- Model refs are `openrouter/<provider>/<model>`.
|
- Model refs are `openrouter/<provider>/<model>`.
|
||||||
- For more model/provider options, see [/concepts/model-providers](/concepts/model-providers).
|
- For more model/provider options, see [/concepts/model-providers](/concepts/model-providers).
|
||||||
- OpenRouter uses a Bearer token with your API key under the hood.
|
- OpenRouter uses a Bearer token with your API key under the hood.
|
||||||
|
|
||||||
|
## Provider routing
|
||||||
|
|
||||||
|
OpenRouter supports provider routing via the `compat.openRouterRouting` field, which controls which upstream providers handle your requests. Configure this in `agents.defaults.models` for individual models.
|
||||||
|
|
||||||
|
### Configuration options
|
||||||
|
|
||||||
|
- `compat.openRouterRouting.only`: List of provider slugs to exclusively use for this request (e.g., `["anthropic", "openai"]`).
|
||||||
|
- `compat.openRouterRouting.order`: List of provider slugs to try in sequence (e.g., `["anthropic", "openai"]`).
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
env: { OPENROUTER_API_KEY: "sk-or-..." },
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "openrouter/anthropic/claude-sonnet-4-5" },
|
||||||
|
models: {
|
||||||
|
"openrouter/anthropic/claude-sonnet-4-5": {
|
||||||
|
alias: "Claude Sonnet",
|
||||||
|
compat: {
|
||||||
|
openRouterRouting: {
|
||||||
|
only: ["anthropic"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"openrouter/openai/gpt-5.2": {
|
||||||
|
alias: "GPT-5.2",
|
||||||
|
compat: {
|
||||||
|
openRouterRouting: {
|
||||||
|
order: ["anthropic", "openai"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@ -164,7 +164,7 @@
|
|||||||
"@line/bot-sdk": "^10.6.0",
|
"@line/bot-sdk": "^10.6.0",
|
||||||
"@lydell/node-pty": "1.2.0-beta.3",
|
"@lydell/node-pty": "1.2.0-beta.3",
|
||||||
"@mariozechner/pi-agent-core": "0.49.3",
|
"@mariozechner/pi-agent-core": "0.49.3",
|
||||||
"@mariozechner/pi-ai": "0.49.3",
|
"@mariozechner/pi-ai": "0.50.3",
|
||||||
"@mariozechner/pi-coding-agent": "0.49.3",
|
"@mariozechner/pi-coding-agent": "0.49.3",
|
||||||
"@mariozechner/pi-tui": "0.49.3",
|
"@mariozechner/pi-tui": "0.49.3",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
|
|||||||
2610
pnpm-lock.yaml
generated
2610
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
117
src/agents/models-config.openrouter-routing.test.ts
Normal file
117
src/agents/models-config.openrouter-routing.test.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
|
|
||||||
|
describe("OpenRouter routing", () => {
|
||||||
|
it("config accepts openRouterRouting with 'only' field", () => {
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
env: {
|
||||||
|
OPENROUTER_API_KEY: "sk-or-test-key",
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "openrouter/anthropic/claude-sonnet-4-5" },
|
||||||
|
models: {
|
||||||
|
"openrouter/anthropic/claude-sonnet-4-5": {
|
||||||
|
alias: "Claude Sonnet",
|
||||||
|
compat: {
|
||||||
|
openRouterRouting: {
|
||||||
|
only: ["anthropic"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const modelKey = "openrouter/anthropic/claude-sonnet-4-5";
|
||||||
|
const modelConfig = cfg.agents?.defaults?.models?.[modelKey];
|
||||||
|
|
||||||
|
expect(modelConfig?.compat?.openRouterRouting).toEqual({
|
||||||
|
only: ["anthropic"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("config accepts openRouterRouting with 'order' field", () => {
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
env: {
|
||||||
|
OPENROUTER_API_KEY: "sk-or-test-key",
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "openrouter/openai/gpt-5.2" },
|
||||||
|
models: {
|
||||||
|
"openrouter/openai/gpt-5.2": {
|
||||||
|
alias: "GPT-5.2",
|
||||||
|
compat: {
|
||||||
|
openRouterRouting: {
|
||||||
|
order: ["anthropic", "openai"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const modelKey = "openrouter/openai/gpt-5.2";
|
||||||
|
const modelConfig = cfg.agents?.defaults?.models?.[modelKey];
|
||||||
|
|
||||||
|
expect(modelConfig?.compat?.openRouterRouting).toEqual({
|
||||||
|
order: ["anthropic", "openai"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates openRouterRouting config shape", () => {
|
||||||
|
const testCases = [
|
||||||
|
{
|
||||||
|
name: "only with single provider",
|
||||||
|
config: { only: ["anthropic"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only with multiple providers",
|
||||||
|
config: { only: ["anthropic", "openai"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "order with single provider",
|
||||||
|
config: { order: ["anthropic"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "order with multiple providers",
|
||||||
|
config: { order: ["anthropic", "openai"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty routing",
|
||||||
|
config: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both only and order",
|
||||||
|
config: { only: ["anthropic"], order: ["openai"] },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const testCase of testCases) {
|
||||||
|
const cfg: MoltbotConfig = {
|
||||||
|
env: { OPENROUTER_API_KEY: "sk-or-test-key" },
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "openrouter/anthropic/claude-sonnet-4-5" },
|
||||||
|
models: {
|
||||||
|
"openrouter/anthropic/claude-sonnet-4-5": {
|
||||||
|
compat: {
|
||||||
|
openRouterRouting: testCase.config,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
const modelConfig =
|
||||||
|
cfg.agents?.defaults?.models?.["openrouter/anthropic/claude-sonnet-4-5"];
|
||||||
|
expect(modelConfig?.compat?.openRouterRouting).toEqual(testCase.config);
|
||||||
|
}).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
42
src/config/openrouter-routing-example.test.ts
Normal file
42
src/config/openrouter-routing-example.test.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
it("example: openRouterRouting configuration", () => {
|
||||||
|
const config = {
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "openrouter/anthropic/claude-sonnet-4-5" },
|
||||||
|
models: {
|
||||||
|
"openrouter/anthropic/claude-sonnet-4-5": {
|
||||||
|
alias: "Claude Sonnet",
|
||||||
|
compat: {
|
||||||
|
openRouterRouting: {
|
||||||
|
only: ["anthropic"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"openrouter/openai/gpt-5.2": {
|
||||||
|
alias: "GPT-5.2",
|
||||||
|
compat: {
|
||||||
|
openRouterRouting: {
|
||||||
|
order: ["anthropic", "openai"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
config.agents.defaults.models?.["openrouter/anthropic/claude-sonnet-4-5"]?.compat
|
||||||
|
?.openRouterRouting,
|
||||||
|
).toEqual({
|
||||||
|
only: ["anthropic"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
config.agents.defaults.models?.["openrouter/openai/gpt-5.2"]?.compat?.openRouterRouting,
|
||||||
|
).toEqual({
|
||||||
|
order: ["anthropic", "openai"],
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -16,6 +16,21 @@ export type AgentModelEntryConfig = {
|
|||||||
alias?: string;
|
alias?: string;
|
||||||
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
|
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
|
||||||
params?: Record<string, unknown>;
|
params?: Record<string, unknown>;
|
||||||
|
/** Model compatibility overrides (e.g., OpenRouter routing preferences). */
|
||||||
|
compat?: ModelCompatEntryConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModelCompatEntryConfig = {
|
||||||
|
supportsStore?: boolean;
|
||||||
|
supportsDeveloperRole?: boolean;
|
||||||
|
supportsReasoningEffort?: boolean;
|
||||||
|
maxTokensField?: "max_completion_tokens" | "max_tokens";
|
||||||
|
openRouterRouting?: OpenRouterRoutingConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpenRouterRoutingConfig = {
|
||||||
|
only?: string[];
|
||||||
|
order?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AgentModelListConfig = {
|
export type AgentModelListConfig = {
|
||||||
|
|||||||
@ -37,6 +37,24 @@ export const AgentDefaultsSchema = z
|
|||||||
alias: z.string().optional(),
|
alias: z.string().optional(),
|
||||||
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
|
/** Provider-specific API parameters (e.g., GLM-4.7 thinking mode). */
|
||||||
params: z.record(z.string(), z.unknown()).optional(),
|
params: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
/** Model compatibility overrides (e.g., OpenRouter routing preferences). */
|
||||||
|
compat: z
|
||||||
|
.object({
|
||||||
|
supportsStore: z.boolean().optional(),
|
||||||
|
supportsDeveloperRole: z.boolean().optional(),
|
||||||
|
supportsReasoningEffort: z.boolean().optional(),
|
||||||
|
maxTokensField: z
|
||||||
|
.union([z.literal("max_completion_tokens"), z.literal("max_tokens")])
|
||||||
|
.optional(),
|
||||||
|
openRouterRouting: z
|
||||||
|
.object({
|
||||||
|
only: z.array(z.string()).optional(),
|
||||||
|
order: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -19,6 +19,12 @@ export const ModelCompatSchema = z
|
|||||||
maxTokensField: z
|
maxTokensField: z
|
||||||
.union([z.literal("max_completion_tokens"), z.literal("max_tokens")])
|
.union([z.literal("max_completion_tokens"), z.literal("max_tokens")])
|
||||||
.optional(),
|
.optional(),
|
||||||
|
openRouterRouting: z
|
||||||
|
.object({
|
||||||
|
only: z.array(z.string()).optional(),
|
||||||
|
order: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional();
|
.optional();
|
||||||
|
|||||||
98
src/config/zod-schema.openrouter-routing.test.ts
Normal file
98
src/config/zod-schema.openrouter-routing.test.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { withTempHome } from "./test-helpers.js";
|
||||||
|
|
||||||
|
describe("OpenRouter routing integration", () => {
|
||||||
|
it("validates and loads openRouterRouting config from moltbot.json", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const configDir = path.join(home, ".clawdbot");
|
||||||
|
await fs.mkdir(configDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(configDir, "moltbot.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "openrouter/anthropic/claude-sonnet-4-5" },
|
||||||
|
models: {
|
||||||
|
"openrouter/anthropic/claude-sonnet-4-5": {
|
||||||
|
alias: "Claude Sonnet",
|
||||||
|
compat: {
|
||||||
|
openRouterRouting: {
|
||||||
|
only: ["anthropic"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"openrouter/openai/gpt-5.2": {
|
||||||
|
alias: "GPT-5.2",
|
||||||
|
compat: {
|
||||||
|
openRouterRouting: {
|
||||||
|
order: ["anthropic", "openai"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { loadConfig } = await import("./config.js");
|
||||||
|
const cfg = loadConfig();
|
||||||
|
|
||||||
|
expect(cfg.agents?.defaults?.model?.primary).toBe("openrouter/anthropic/claude-sonnet-4-5");
|
||||||
|
|
||||||
|
const sonnetModel = cfg.agents?.defaults?.models?.["openrouter/anthropic/claude-sonnet-4-5"];
|
||||||
|
expect(sonnetModel?.alias).toBe("Claude Sonnet");
|
||||||
|
expect(sonnetModel?.compat?.openRouterRouting).toEqual({
|
||||||
|
only: ["anthropic"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const gptModel = cfg.agents?.defaults?.models?.["openrouter/openai/gpt-5.2"];
|
||||||
|
expect(gptModel?.alias).toBe("GPT-5.2");
|
||||||
|
expect(gptModel?.compat?.openRouterRouting).toEqual({
|
||||||
|
order: ["anthropic", "openai"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts openRouterRouting with empty config", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const configDir = path.join(home, ".clawdbot");
|
||||||
|
await fs.mkdir(configDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(configDir, "moltbot.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
model: { primary: "openrouter/anthropic/claude-sonnet-4-5" },
|
||||||
|
models: {
|
||||||
|
"openrouter/anthropic/claude-sonnet-4-5": {
|
||||||
|
compat: {
|
||||||
|
openRouterRouting: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const { loadConfig } = await import("./config.js");
|
||||||
|
const cfg = loadConfig();
|
||||||
|
|
||||||
|
const model = cfg.agents?.defaults?.models?.["openrouter/anthropic/claude-sonnet-4-5"];
|
||||||
|
expect(model?.compat?.openRouterRouting).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user