Add launch-at-login support and route merge handling

This commit is contained in:
musistudio 2026-07-03 11:12:14 +08:00
parent d45e88387a
commit 89d2d2394c
15 changed files with 409 additions and 10 deletions

View file

@ -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"]> = {};

View file

@ -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();

View 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;
}

View file

@ -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}`);

View file

@ -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,

View 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 ? {

View file

@ -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,

View file

@ -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,

View file

@ -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",

View file

@ -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: "记录网关请求并显示日志页。",

View file

@ -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

View file

@ -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;

View file

@ -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[];

View file

@ -90,6 +90,7 @@ export function createDefaultAppConfig(options: DefaultAppConfigOptions): AppCon
host: "127.0.0.1",
port: 3456
},
launchAtLogin: false,
observability: {
agentAnalysis: false,
requestLogs: false

View file

@ -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",