From 89d2d2394c660ef568a60d85a77854bd7085fc7b Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 3 Jul 2026 11:12:14 +0800 Subject: [PATCH] Add launch-at-login support and route merge handling --- src/main/config.ts | 4 + src/main/ipc.ts | 14 ++ src/main/launch-at-login.ts | 28 +++ src/main/main-app.ts | 6 + src/main/web-management-server.ts | 1 + src/renderer/pages/home/App.tsx | 11 +- .../pages/home/components/settings.tsx | 28 ++- src/renderer/pages/home/shared/config.ts | 1 + src/renderer/pages/home/shared/fallbacks.ts | 1 + src/renderer/pages/home/shared/i18n.tsx | 6 + .../gateway/claude-code-router-plugin.ts | 34 ++- src/server/gateway/service.ts | 52 ++++ src/shared/app.ts | 2 + src/shared/default-config.ts | 1 + tests/main/router-builtins.test.mjs | 230 +++++++++++++++++- 15 files changed, 409 insertions(+), 10 deletions(-) create mode 100644 src/main/launch-at-login.ts diff --git a/src/main/config.ts b/src/main/config.ts index bccbc7d..c0816cb 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -579,6 +579,10 @@ function pickConfig(value: Partial): LoadedAppConfig { if (typeof value.autoStart === "boolean") { config.autoStart = value.autoStart; } + const launchAtLogin = (value as Record).launchAtLogin; + if (typeof launchAtLogin === "boolean") { + config.launchAtLogin = launchAtLogin; + } if (isObject(value.gateway)) { const gateway = value.gateway as Record; const gatewayConfig: Partial = {}; diff --git a/src/main/ipc.ts b/src/main/ipc.ts index bdf8cd2..64f01db 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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(); diff --git a/src/main/launch-at-login.ts b/src/main/launch-at-login.ts new file mode 100644 index 0000000..f79ea65 --- /dev/null +++ b/src/main/launch-at-login.ts @@ -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): 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; +} diff --git a/src/main/main-app.ts b/src/main/main-app.ts index a065668..eb7f7f0 100644 --- a/src/main/main-app.ts +++ b/src/main/main-app.ts @@ -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 { } 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}`); diff --git a/src/main/web-management-server.ts b/src/main/web-management-server.ts index 40d7f2b..6a4924d 100644 --- a/src/main/web-management-server.ts +++ b/src/main/web-management-server.ts @@ -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, diff --git a/src/renderer/pages/home/App.tsx b/src/renderer/pages/home/App.tsx index 99fdea4..be2163d 100644 --- a/src/renderer/pages/home/App.tsx +++ b/src/renderer/pages/home/App.tsx @@ -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 ? { diff --git a/src/renderer/pages/home/components/settings.tsx b/src/renderer/pages/home/components/settings.tsx index 0c9a051..4fba842 100644 --- a/src/renderer/pages/home/components/settings.tsx +++ b/src/renderer/pages/home/components/settings.tsx @@ -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) => void; onChangeTrayBalanceProgress: (config: TrayBalanceProgressConfig) => void; onChangeLanguage: (value: string) => void; @@ -77,6 +81,9 @@ export function AppSettingsDialog({ void; onChangeLanguage: (value: string) => void; onChangeTheme: (value: string) => void; systemLanguage: ResolvedLanguage; @@ -296,6 +309,15 @@ function AppearanceSettingsPage({ + {launchAtLoginSupported ? ( + + ) : null} ); @@ -314,14 +336,14 @@ function ObservabilitySettingsPage({

{copy.settings.observability}

- onChange({ requestLogs })} /> - = { 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 = { languageChinese: "中文", languageEnglish: "英文", languageSystem: "跟随系统", + launchAtLogin: "开机自启", + launchAtLoginDescription: "登录系统后自动打开 Claude Code Router。", observability: "日志与观测", requestLogs: "请求日志", requestLogsDescription: "记录网关请求并显示日志页。", diff --git a/src/server/gateway/claude-code-router-plugin.ts b/src/server/gateway/claude-code-router-plugin.ts index 89726b3..18d6d51 100644 --- a/src/server/gateway/claude-code-router-plugin.ts +++ b/src/server/gateway/claude-code-router-plugin.ts @@ -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 diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts index efc56ac..7780980 100644 --- a/src/server/gateway/service.ts +++ b/src/server/gateway/service.ts @@ -2192,6 +2192,58 @@ function applyProviderCapabilityRouting(input: { }; } +export function prepareGatewayUpstreamAttemptForTest(input: { + body: Record; + config: AppConfig; + fallback?: RouterFallbackConfig; + headers: Record; + method: string; + path: string; + routedModel?: string; +}): { + body?: Record; + credentialChain?: string[]; + credentialIds?: string[]; + credentialProtocol?: GatewayProviderProtocol; + fallback: RouterFallbackConfig; + headers?: Record; + 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; diff --git a/src/shared/app.ts b/src/shared/app.ts index 56bd581..17254ad 100644 --- a/src/shared/app.ts +++ b/src/shared/app.ts @@ -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[]; diff --git a/src/shared/default-config.ts b/src/shared/default-config.ts index 3eb66c8..c7953cd 100644 --- a/src/shared/default-config.ts +++ b/src/shared/default-config.ts @@ -90,6 +90,7 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon host: "127.0.0.1", port: 3456 }, + launchAtLogin: false, observability: { agentAnalysis: false, requestLogs: false diff --git a/tests/main/router-builtins.test.mjs b/tests/main/router-builtins.test.mjs index ccc40cc..095b842 100644 --- a/tests/main/router-builtins.test.mjs +++ b/tests/main/router-builtins.test.mjs @@ -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",