mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
Add launch-at-login support and route merge handling
This commit is contained in:
parent
d45e88387a
commit
89d2d2394c
15 changed files with 409 additions and 10 deletions
|
|
@ -579,6 +579,10 @@ function pickConfig(value: Partial<AppConfig>): LoadedAppConfig {
|
|||
if (typeof value.autoStart === "boolean") {
|
||||
config.autoStart = value.autoStart;
|
||||
}
|
||||
const launchAtLogin = (value as Record<string, unknown>).launchAtLogin;
|
||||
if (typeof launchAtLogin === "boolean") {
|
||||
config.launchAtLogin = launchAtLogin;
|
||||
}
|
||||
if (isObject(value.gateway)) {
|
||||
const gateway = value.gateway as Record<string, unknown>;
|
||||
const gatewayConfig: Partial<AppConfig["gateway"]> = {};
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { getProviderAccountSnapshots, invalidateProviderAccountSnapshotCache, te
|
|||
import { detectProviderIcon } from "./provider-icons";
|
||||
import { fetchProviderManifest } from "./provider-manifest-service";
|
||||
import { getLocalAgentProviderCandidates, importLocalAgentProvider } from "./local-agent-provider-service";
|
||||
import { isLaunchAtLoginSupported, syncLaunchAtLogin } from "./launch-at-login";
|
||||
import { getProviderCatalogModels } from "./provider-model-catalog";
|
||||
import { getProviderPresets } from "./presets";
|
||||
import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "./provider-probe";
|
||||
|
|
@ -62,6 +63,7 @@ ipcMain.handle(IPC_CHANNELS.appGetInfo, () => {
|
|||
configFile: CONFIG_FILE,
|
||||
dataDir: DATADIR,
|
||||
gatewayConfigFile: GATEWAY_CONFIG_FILE,
|
||||
launchAtLoginSupported: isLaunchAtLoginSupported(),
|
||||
name: APP_NAME,
|
||||
platform: process.platform,
|
||||
requestLogsDbFile: REQUEST_LOGS_DB_FILE,
|
||||
|
|
@ -253,7 +255,19 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig, opt
|
|||
throw new Error(certificateStatus.message);
|
||||
}
|
||||
}
|
||||
const launchAtLoginChanged = Boolean(config.launchAtLogin) !== Boolean(previousConfig.launchAtLogin);
|
||||
let savedConfig = await saveAppConfig(config);
|
||||
if (launchAtLoginChanged) {
|
||||
try {
|
||||
syncLaunchAtLogin(savedConfig);
|
||||
} catch (error) {
|
||||
await saveAppConfig({
|
||||
...savedConfig,
|
||||
launchAtLogin: previousConfig.launchAtLogin
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig);
|
||||
savedConfig = syncedClaudeAppConfig.config;
|
||||
let runtimeStatus = gatewayService.getStatus();
|
||||
|
|
|
|||
28
src/main/launch-at-login.ts
Normal file
28
src/main/launch-at-login.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { app } from "electron";
|
||||
import type { AppConfig } from "../shared/app";
|
||||
|
||||
export function isLaunchAtLoginSupported(platform = process.platform): boolean {
|
||||
return platform === "darwin" || platform === "win32";
|
||||
}
|
||||
|
||||
export function syncLaunchAtLogin(config: Pick<AppConfig, "launchAtLogin">): void {
|
||||
if (!isLaunchAtLoginSupported()) {
|
||||
return;
|
||||
}
|
||||
app.setLoginItemSettings(loginItemSettings(Boolean(config.launchAtLogin)));
|
||||
}
|
||||
|
||||
function loginItemSettings(openAtLogin: boolean): Electron.Settings {
|
||||
const settings: Electron.Settings = {
|
||||
openAtLogin
|
||||
};
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
settings.openAsHidden = false;
|
||||
return settings;
|
||||
}
|
||||
|
||||
settings.path = process.execPath;
|
||||
settings.args = process.defaultApp ? [app.getAppPath()] : [];
|
||||
return settings;
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { gatewayService } from "../server/gateway/service";
|
|||
import "./ipc";
|
||||
import { applyProfileConfig } from "./profile-service";
|
||||
import { ensureCcrCliLauncher } from "./profile-launch-service";
|
||||
import { syncLaunchAtLogin } from "./launch-at-login";
|
||||
import { proxyService } from "../server/proxy/service";
|
||||
import trayController from "./tray-controller";
|
||||
import { appUpdateService } from "./update-service";
|
||||
|
|
@ -182,6 +183,11 @@ function startConfiguredServices(reason: string): Promise<void> {
|
|||
} catch (error) {
|
||||
console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`);
|
||||
}
|
||||
try {
|
||||
syncLaunchAtLogin(config);
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync launch-at-login setting during ${reason}: ${formatError(error)}`);
|
||||
}
|
||||
const status = await gatewayService.start(config);
|
||||
if (status.state === "error") {
|
||||
console.error(`Failed to start gateway during ${reason}: ${status.lastError}`);
|
||||
|
|
|
|||
|
|
@ -497,6 +497,7 @@ function getCliAppInfo(): AppInfo {
|
|||
configFile: CONFIG_FILE,
|
||||
dataDir: DATADIR,
|
||||
gatewayConfigFile: GATEWAY_CONFIG_FILE,
|
||||
launchAtLoginSupported: false,
|
||||
name: APP_NAME,
|
||||
platform: process.platform,
|
||||
requestLogsDbFile: REQUEST_LOGS_DB_FILE,
|
||||
|
|
|
|||
|
|
@ -2040,6 +2040,13 @@ function App() {
|
|||
}));
|
||||
}
|
||||
|
||||
function changeLaunchAtLogin(launchAtLogin: boolean) {
|
||||
updateConfig((config) => ({
|
||||
...config,
|
||||
launchAtLogin
|
||||
}));
|
||||
}
|
||||
|
||||
function changeTrayIconPreference(value: string) {
|
||||
const trayIcon = normalizeTrayIconPreference(value);
|
||||
if (trayIcon === "progress" && !normalizeTrayBalanceProgressConfig(draftConfig.trayBalanceProgress)) {
|
||||
|
|
@ -3120,9 +3127,10 @@ function App() {
|
|||
botConfigs: draftConfig.botConfigs,
|
||||
copy,
|
||||
initialPage: settingsInitialPage,
|
||||
traySupported,
|
||||
languagePreference,
|
||||
launchAtLogin: Boolean(draftConfig.launchAtLogin),
|
||||
onChangeBotConfigs: changeBotConfigs,
|
||||
onChangeLaunchAtLogin: changeLaunchAtLogin,
|
||||
onChangeLanguage: changeLanguagePreference,
|
||||
onChangeObservability: changeObservabilityConfig,
|
||||
onChangeTheme: changeThemePreference,
|
||||
|
|
@ -3138,6 +3146,7 @@ function App() {
|
|||
providerAccountSnapshots,
|
||||
trayBalanceProgress: normalizeTrayBalanceProgressConfig(draftConfig.trayBalanceProgress),
|
||||
trayIconPreference: draftConfig.trayIcon || "random",
|
||||
traySupported,
|
||||
trayWidgets: normalizeTrayWidgets(draftConfig.trayWidgets ?? DEFAULT_TRAY_WIDGETS, draftConfig.trayWindowModules, draftConfig.trayComponentVariants)
|
||||
} : undefined}
|
||||
update={updateDialogOpen ? {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ export function AppSettingsDialog({
|
|||
copy,
|
||||
initialPage = "appearance",
|
||||
languagePreference,
|
||||
launchAtLogin,
|
||||
onChangeBotConfigs,
|
||||
onChangeLaunchAtLogin,
|
||||
onChangeObservability,
|
||||
onChangeTrayBalanceProgress,
|
||||
onChangeLanguage,
|
||||
|
|
@ -47,7 +49,9 @@ export function AppSettingsDialog({
|
|||
copy: AppCopy;
|
||||
initialPage?: SettingsPageId;
|
||||
languagePreference: AppLanguagePreference;
|
||||
launchAtLogin: boolean;
|
||||
onChangeBotConfigs: (configs: BotGatewaySavedConfig[]) => void;
|
||||
onChangeLaunchAtLogin: (checked: boolean) => void;
|
||||
onChangeObservability: (patch: Partial<AppConfig["observability"]>) => void;
|
||||
onChangeTrayBalanceProgress: (config: TrayBalanceProgressConfig) => void;
|
||||
onChangeLanguage: (value: string) => void;
|
||||
|
|
@ -77,6 +81,9 @@ export function AppSettingsDialog({
|
|||
<AppearanceSettingsPage
|
||||
copy={copy}
|
||||
languagePreference={languagePreference}
|
||||
launchAtLogin={launchAtLogin}
|
||||
launchAtLoginSupported={appInfo.launchAtLoginSupported}
|
||||
onChangeLaunchAtLogin={onChangeLaunchAtLogin}
|
||||
onChangeLanguage={onChangeLanguage}
|
||||
onChangeTheme={onChangeTheme}
|
||||
systemLanguage={systemLanguage}
|
||||
|
|
@ -261,6 +268,9 @@ function SettingsPageButton({
|
|||
function AppearanceSettingsPage({
|
||||
copy,
|
||||
languagePreference,
|
||||
launchAtLogin,
|
||||
launchAtLoginSupported,
|
||||
onChangeLaunchAtLogin,
|
||||
onChangeLanguage,
|
||||
onChangeTheme,
|
||||
systemLanguage,
|
||||
|
|
@ -269,6 +279,9 @@ function AppearanceSettingsPage({
|
|||
}: {
|
||||
copy: AppCopy;
|
||||
languagePreference: AppLanguagePreference;
|
||||
launchAtLogin: boolean;
|
||||
launchAtLoginSupported: boolean;
|
||||
onChangeLaunchAtLogin: (checked: boolean) => void;
|
||||
onChangeLanguage: (value: string) => void;
|
||||
onChangeTheme: (value: string) => void;
|
||||
systemLanguage: ResolvedLanguage;
|
||||
|
|
@ -296,6 +309,15 @@ function AppearanceSettingsPage({
|
|||
<Field label={copy.settings.language}>
|
||||
<SelectControl onChange={onChangeLanguage} options={languageOptions} value={languagePreference} />
|
||||
</Field>
|
||||
{launchAtLoginSupported ? (
|
||||
<SettingsSwitchRow
|
||||
checked={launchAtLogin}
|
||||
description={copy.settings.launchAtLoginDescription}
|
||||
icon={Power}
|
||||
label={copy.settings.launchAtLogin}
|
||||
onChange={onChangeLaunchAtLogin}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -314,14 +336,14 @@ function ObservabilitySettingsPage({
|
|||
<div className={cn(settingsPageContentWidthClassName, "grid grid-cols-1 gap-5")}>
|
||||
<h3 className="text-[15px] font-semibold text-foreground">{copy.settings.observability}</h3>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<ObservabilitySwitchRow
|
||||
<SettingsSwitchRow
|
||||
checked={observability.requestLogs}
|
||||
description={copy.settings.requestLogsDescription}
|
||||
icon={Database}
|
||||
label={copy.settings.requestLogs}
|
||||
onChange={(requestLogs) => onChange({ requestLogs })}
|
||||
/>
|
||||
<ObservabilitySwitchRow
|
||||
<SettingsSwitchRow
|
||||
checked={observability.agentAnalysis}
|
||||
description={copy.settings.agentAnalysisDescription}
|
||||
icon={Activity}
|
||||
|
|
@ -333,7 +355,7 @@ function ObservabilitySettingsPage({
|
|||
);
|
||||
}
|
||||
|
||||
function ObservabilitySwitchRow({
|
||||
function SettingsSwitchRow({
|
||||
checked,
|
||||
description,
|
||||
icon: Icon,
|
||||
|
|
|
|||
|
|
@ -407,6 +407,7 @@ export function normalizeConfig(config: AppConfig): AppConfig {
|
|||
...(config.gateway || {}),
|
||||
coreHost: fallbackConfig.gateway.coreHost
|
||||
},
|
||||
launchAtLogin: Boolean(config.launchAtLogin),
|
||||
observability: normalizeObservabilityConfig(config.observability),
|
||||
proxy: {
|
||||
...fallbackConfig.proxy,
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const fallbackInfo: AppInfo = {
|
|||
configFile: "Browser preview",
|
||||
dataDir: "Browser preview",
|
||||
gatewayConfigFile: "Browser preview",
|
||||
launchAtLoginSupported: /^Mac|^Win/i.test(navigator.platform),
|
||||
name: "Claude Code Router",
|
||||
platform: navigator.platform,
|
||||
requestLogsDbFile: "Browser preview",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export type AppCopy = {
|
|||
languageChinese: string;
|
||||
languageEnglish: string;
|
||||
languageSystem: string;
|
||||
launchAtLogin: string;
|
||||
launchAtLoginDescription: string;
|
||||
observability: string;
|
||||
requestLogs: string;
|
||||
requestLogsDescription: string;
|
||||
|
|
@ -119,6 +121,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
languageChinese: "Chinese",
|
||||
languageEnglish: "English",
|
||||
languageSystem: "System",
|
||||
launchAtLogin: "Launch at login",
|
||||
launchAtLoginDescription: "Open Claude Code Router automatically when you sign in to this computer.",
|
||||
observability: "Logs & Observability",
|
||||
requestLogs: "Request logs",
|
||||
requestLogsDescription: "Record gateway requests and show the Logs page.",
|
||||
|
|
@ -461,6 +465,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
|||
languageChinese: "中文",
|
||||
languageEnglish: "英文",
|
||||
languageSystem: "跟随系统",
|
||||
launchAtLogin: "开机自启",
|
||||
launchAtLoginDescription: "登录系统后自动打开 Claude Code Router。",
|
||||
observability: "日志与观测",
|
||||
requestLogs: "请求日志",
|
||||
requestLogsDescription: "记录网关请求并显示日志页。",
|
||||
|
|
|
|||
|
|
@ -198,21 +198,45 @@ function resolveConfiguredRouteDecision(
|
|||
|
||||
const router = config.Router;
|
||||
const builtInDecision = resolveBuiltInAgentRouteDecision(request, config);
|
||||
if (builtInDecision) {
|
||||
return builtInDecision;
|
||||
}
|
||||
|
||||
const rules = router.rules ?? [];
|
||||
for (const rule of rules) {
|
||||
const decision = resolveRouterRule(rule, request, router);
|
||||
if (decision) {
|
||||
return decision;
|
||||
return builtInDecision ? mergeConfiguredRouteDecisions(builtInDecision, decision) : decision;
|
||||
}
|
||||
}
|
||||
|
||||
if (builtInDecision) {
|
||||
return builtInDecision;
|
||||
}
|
||||
|
||||
return { fallback: router.fallback, model: explicitModel, reason: "default" };
|
||||
}
|
||||
|
||||
function mergeConfiguredRouteDecisions(
|
||||
base: ConfiguredRouteDecision,
|
||||
override: ConfiguredRouteDecision
|
||||
): ConfiguredRouteDecision {
|
||||
const rewrites = [
|
||||
...configuredRouteDecisionRewrites(base),
|
||||
...configuredRouteDecisionRewrites(override)
|
||||
];
|
||||
return {
|
||||
fallback: override.fallback ?? base.fallback,
|
||||
model: override.model ?? base.model,
|
||||
reason: override.reason,
|
||||
...(rewrites.length === 1 ? { rewrite: rewrites[0] } : {}),
|
||||
...(rewrites.length > 0 ? { rewrites } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function configuredRouteDecisionRewrites(decision: ConfiguredRouteDecision): RouterRuleRewrite[] {
|
||||
if (decision.rewrites?.length) {
|
||||
return decision.rewrites;
|
||||
}
|
||||
return decision.rewrite ? [decision.rewrite] : [];
|
||||
}
|
||||
|
||||
function resolveBuiltInClaudeCodeSubagentRouteDecision(
|
||||
request: MutableRequestLike,
|
||||
config: AppConfig
|
||||
|
|
|
|||
|
|
@ -2192,6 +2192,58 @@ function applyProviderCapabilityRouting(input: {
|
|||
};
|
||||
}
|
||||
|
||||
export function prepareGatewayUpstreamAttemptForTest(input: {
|
||||
body: Record<string, unknown>;
|
||||
config: AppConfig;
|
||||
fallback?: RouterFallbackConfig;
|
||||
headers: Record<string, string>;
|
||||
method: string;
|
||||
path: string;
|
||||
routedModel?: string;
|
||||
}): {
|
||||
body?: Record<string, unknown>;
|
||||
credentialChain?: string[];
|
||||
credentialIds?: string[];
|
||||
credentialProtocol?: GatewayProviderProtocol;
|
||||
fallback: RouterFallbackConfig;
|
||||
headers?: Record<string, string>;
|
||||
logicalProvider?: string;
|
||||
model?: string;
|
||||
routedModel?: string;
|
||||
} {
|
||||
const headers = { ...input.headers };
|
||||
const providerCapabilityRouting = applyProviderCapabilityRouting({
|
||||
body: Buffer.from(`${JSON.stringify(input.body)}\n`, "utf8"),
|
||||
config: input.config,
|
||||
fallback: input.fallback ?? input.config.Router.fallback,
|
||||
headers,
|
||||
path: input.path,
|
||||
routedModel: input.routedModel
|
||||
});
|
||||
const attempt = prepareUpstreamCredentialAttempt({
|
||||
attempt: {
|
||||
body: providerCapabilityRouting.body,
|
||||
index: 0,
|
||||
model: normalizeRouteSelector(providerCapabilityRouting.routedModel)
|
||||
},
|
||||
config: input.config,
|
||||
headers,
|
||||
method: input.method,
|
||||
path: input.path
|
||||
});
|
||||
return {
|
||||
body: parseJsonObjectSafe(attempt.body),
|
||||
credentialChain: attempt.credentialChain,
|
||||
credentialIds: attempt.credentialIds,
|
||||
credentialProtocol: attempt.credentialProtocol,
|
||||
fallback: providerCapabilityRouting.fallback,
|
||||
headers: attempt.headers,
|
||||
logicalProvider: attempt.logicalProvider,
|
||||
model: attempt.model,
|
||||
routedModel: providerCapabilityRouting.routedModel
|
||||
};
|
||||
}
|
||||
|
||||
export function prepareCodexApplyPatchBridgeRequest(input: {
|
||||
body?: Buffer;
|
||||
config: AppConfig;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export type AppInfo = {
|
|||
configFile: string;
|
||||
dataDir: string;
|
||||
gatewayConfigFile: string;
|
||||
launchAtLoginSupported: boolean;
|
||||
requestLogsDbFile: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
|
|
@ -1352,6 +1353,7 @@ export type AppConfig = {
|
|||
botConfigs: BotGatewaySavedConfig[];
|
||||
botGateway: BotGatewayRuntimeConfig;
|
||||
gateway: GatewayRuntimeConfig;
|
||||
launchAtLogin: boolean;
|
||||
observability: ObservabilityConfig;
|
||||
preferredProvider: string;
|
||||
plugins: GatewayPluginConfig[];
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon
|
|||
host: "127.0.0.1",
|
||||
port: 3456
|
||||
},
|
||||
launchAtLogin: false,
|
||||
observability: {
|
||||
agentAnalysis: false,
|
||||
requestLogs: false
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { ClaudeCodeRouterPlugin } from "../../src/server/gateway/claude-code-router-plugin.ts";
|
||||
import { prepareGatewayUpstreamAttemptForTest } from "../../src/server/gateway/service.ts";
|
||||
|
||||
function createRouterPlugin(options = {}) {
|
||||
const agent = options.agent ?? "claude-code";
|
||||
|
|
@ -21,7 +22,7 @@ function createRouterPlugin(options = {}) {
|
|||
codex: { enabled: options.codexRuleEnabled ?? true }
|
||||
},
|
||||
fallback: { mode: "off", models: [], retryCount: 1 },
|
||||
rules: []
|
||||
rules: options.routerRules ?? []
|
||||
},
|
||||
profile: {
|
||||
enabled: options.profileRuntimeEnabled ?? true,
|
||||
|
|
@ -40,6 +41,115 @@ function createRouterPlugin(options = {}) {
|
|||
});
|
||||
}
|
||||
|
||||
function createIssue1480RouterConfig() {
|
||||
return {
|
||||
CUSTOM_ROUTER_PATH: "",
|
||||
Providers: [
|
||||
{
|
||||
api_base_url: "https://nebulacoder.example/v1/chat/completions",
|
||||
credentials: [{ apiKey: "nebulacoder-key", id: "nebulacoder-main" }],
|
||||
modelDescriptions: {
|
||||
"nebulacoder-v8.0": "Code reading model.",
|
||||
"nebulacoder-cot-v8.0": "Code reading reasoning model."
|
||||
},
|
||||
models: ["nebulacoder-v8.0", "nebulacoder-cot-v8.0"],
|
||||
name: "NebulaCoder"
|
||||
},
|
||||
{
|
||||
api_base_url: "https://coclaw.example/v1/chat/completions",
|
||||
credentials: [{ apiKey: "coclaw-key", id: "coclaw-main" }],
|
||||
modelDescriptions: {
|
||||
"Qwen3-235B-A22B": "Background task model."
|
||||
},
|
||||
models: ["Qwen3-235B-A22B"],
|
||||
name: "CoClaw"
|
||||
},
|
||||
{
|
||||
api_base_url: "https://opencode.ai/zen/go/v1/chat/completions",
|
||||
capabilities: [
|
||||
{ baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", type: "openai_chat_completions" }
|
||||
],
|
||||
credentials: [{ apiKey: "opencode-key", id: "opencode-main" }],
|
||||
modelDescriptions: {
|
||||
"deepseek-v4-flash": "Default route model.",
|
||||
"deepseek-v4-pro": "Thinking route model.",
|
||||
"kimi-k2.6": "Claude Code profile model."
|
||||
},
|
||||
models: ["kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash"],
|
||||
name: "OpenCode"
|
||||
}
|
||||
],
|
||||
Router: {
|
||||
builtInRules: {
|
||||
"claude-code": { enabled: true },
|
||||
codex: { enabled: true }
|
||||
},
|
||||
fallback: { mode: "retry", models: [], retryCount: 1 },
|
||||
rules: [
|
||||
{
|
||||
condition: {
|
||||
left: "request.url",
|
||||
operator: "contains",
|
||||
right: "/v1"
|
||||
},
|
||||
enabled: true,
|
||||
id: "rule-default",
|
||||
name: "Default route",
|
||||
rewrites: [
|
||||
{ key: "request.header.x-target-provider", operation: "set", value: "OpenCode" },
|
||||
{ key: "request.body.model", operation: "set", value: "deepseek-v4-flash" }
|
||||
],
|
||||
type: "condition"
|
||||
},
|
||||
{
|
||||
condition: {
|
||||
left: "request.header.anthropic-background",
|
||||
operator: "==",
|
||||
right: "true"
|
||||
},
|
||||
enabled: true,
|
||||
id: "rule-background",
|
||||
name: "Background tasks",
|
||||
rewrites: [
|
||||
{ key: "request.header.x-target-provider", operation: "set", value: "CoClaw" },
|
||||
{ key: "request.body.model", operation: "set", value: "Qwen3-235B-A22B" }
|
||||
],
|
||||
type: "condition"
|
||||
},
|
||||
{
|
||||
condition: {
|
||||
left: "request.header.anthropic-thinking",
|
||||
operator: "==",
|
||||
right: "true"
|
||||
},
|
||||
enabled: true,
|
||||
id: "rule-thinking",
|
||||
name: "Thinking/reasoning tasks",
|
||||
rewrites: [
|
||||
{ key: "request.header.x-target-provider", operation: "set", value: "OpenCode" },
|
||||
{ key: "request.body.model", operation: "set", value: "deepseek-v4-pro" }
|
||||
],
|
||||
type: "condition"
|
||||
}
|
||||
]
|
||||
},
|
||||
profile: {
|
||||
enabled: true,
|
||||
profiles: [
|
||||
{
|
||||
agent: "claude-code",
|
||||
enabled: true,
|
||||
id: "claude-code-main",
|
||||
model: "kimi-k2.6",
|
||||
name: "Claude Code",
|
||||
scope: "global"
|
||||
}
|
||||
]
|
||||
},
|
||||
virtualModelProfiles: []
|
||||
};
|
||||
}
|
||||
|
||||
test("built-in Claude Code route matches user-agent case-insensitively", async () => {
|
||||
const plugin = createRouterPlugin({ profileModel: "Provider/claude-sonnet" });
|
||||
const result = await plugin.routeRequest({
|
||||
|
|
@ -59,6 +169,124 @@ test("built-in Claude Code route matches user-agent case-insensitively", async (
|
|||
assert.equal(result.decision.reason, "builtin:claude-code");
|
||||
});
|
||||
|
||||
test("router rules override the built-in Claude Code profile route", async () => {
|
||||
const plugin = createRouterPlugin({
|
||||
profileModel: "Provider/claude-sonnet",
|
||||
routerRules: [
|
||||
{
|
||||
condition: {
|
||||
left: "request.url",
|
||||
operator: "contains",
|
||||
right: "/v1"
|
||||
},
|
||||
enabled: true,
|
||||
id: "default",
|
||||
name: "Default",
|
||||
rewrites: [
|
||||
{ key: "request.header.x-target-provider", operation: "set", value: "OverrideProvider" },
|
||||
{ key: "request.body.model", operation: "set", value: "Provider/gpt-5-codex" }
|
||||
],
|
||||
type: "condition"
|
||||
}
|
||||
]
|
||||
});
|
||||
const headers = {
|
||||
"user-agent": "claude-code/1.0"
|
||||
};
|
||||
const result = await plugin.routeRequest({
|
||||
body: {
|
||||
messages: [],
|
||||
model: "claude-default"
|
||||
},
|
||||
headers,
|
||||
method: "POST",
|
||||
url: "/v1/messages"
|
||||
});
|
||||
|
||||
assert.equal(headers["x-target-provider"], "OverrideProvider");
|
||||
assert.equal(result.body.model, "Provider/gpt-5-codex");
|
||||
assert.equal(result.decision.model, "Provider/gpt-5-codex");
|
||||
assert.equal(result.decision.reason, "rule:default");
|
||||
});
|
||||
|
||||
test("router rules can add headers after the built-in Claude Code profile route", async () => {
|
||||
const plugin = createRouterPlugin({
|
||||
profileModel: "Provider/claude-sonnet",
|
||||
routerRules: [
|
||||
{
|
||||
condition: {
|
||||
left: "request.url",
|
||||
operator: "contains",
|
||||
right: "/v1"
|
||||
},
|
||||
enabled: true,
|
||||
id: "target-provider",
|
||||
name: "Target provider",
|
||||
rewrites: [
|
||||
{ key: "request.header.x-target-provider", operation: "set", value: "Provider" }
|
||||
],
|
||||
type: "condition"
|
||||
}
|
||||
]
|
||||
});
|
||||
const headers = {
|
||||
"user-agent": "Claude Code"
|
||||
};
|
||||
const result = await plugin.routeRequest({
|
||||
body: {
|
||||
messages: [],
|
||||
model: "claude-default"
|
||||
},
|
||||
headers,
|
||||
method: "POST",
|
||||
url: "/v1/messages"
|
||||
});
|
||||
|
||||
assert.equal(headers["x-target-provider"], "Provider");
|
||||
assert.equal(result.body.model, "Provider/claude-sonnet");
|
||||
assert.equal(result.decision.model, "Provider/claude-sonnet");
|
||||
assert.equal(result.decision.reason, "rule:target-provider");
|
||||
});
|
||||
|
||||
test("issue 1480 config routes Claude Code profile traffic through the user default OpenCode rule", async () => {
|
||||
const config = createIssue1480RouterConfig();
|
||||
const plugin = new ClaudeCodeRouterPlugin(config);
|
||||
const headers = {
|
||||
"user-agent": "claude-cli/2.1.187 (external, cli)"
|
||||
};
|
||||
const result = await plugin.routeRequest({
|
||||
body: {
|
||||
messages: [],
|
||||
model: "claude-sonnet-4-6",
|
||||
tools: []
|
||||
},
|
||||
headers,
|
||||
method: "POST",
|
||||
url: "/v1/messages?beta=true"
|
||||
});
|
||||
|
||||
assert.equal(headers["x-target-provider"], "OpenCode");
|
||||
assert.equal(result.body.model, "deepseek-v4-flash");
|
||||
assert.equal(result.decision.model, "deepseek-v4-flash");
|
||||
assert.equal(result.decision.reason, "rule:rule-default");
|
||||
|
||||
const upstreamAttempt = prepareGatewayUpstreamAttemptForTest({
|
||||
body: result.body,
|
||||
config,
|
||||
fallback: result.decision.fallback,
|
||||
headers,
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
routedModel: result.decision.model
|
||||
});
|
||||
|
||||
assert.equal(upstreamAttempt.logicalProvider, "OpenCode");
|
||||
assert.equal(upstreamAttempt.credentialProtocol, "openai_chat_completions");
|
||||
assert.equal(upstreamAttempt.body.model, "deepseek-v4-flash");
|
||||
assert.match(upstreamAttempt.headers["x-target-providers"], /^provider-opencode-[a-f0-9]{10}::openai_chat_completions::cred:opencode-main$/);
|
||||
assert.doesNotMatch(upstreamAttempt.headers["x-target-providers"], /nebulacoder/i);
|
||||
});
|
||||
|
||||
test("built-in Claude Code route preserves explicit virtual gateway models", async () => {
|
||||
const plugin = createRouterPlugin({
|
||||
profileModel: "Provider/claude-sonnet",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue