From 499d25d200607940509fe0cccb2ed430cd22a64e Mon Sep 17 00:00:00 2001 From: musi Date: Tue, 7 Jul 2026 21:56:20 +0800 Subject: [PATCH 01/21] chore: prepare cli and docker 3.0.1 --- package-lock.json | 4 ++-- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 84d877d2..4dae0cda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9542,7 +9542,7 @@ }, "packages/cli": { "name": "@musistudio/claude-code-router", - "version": "3.0.9", + "version": "3.0.1", "license": "MIT", "dependencies": { "@the-next-ai/ai-gateway": "^1.0.4", @@ -9560,7 +9560,7 @@ }, "packages/core": { "name": "@claude-code-router/core", - "version": "3.0.9", + "version": "3.0.1", "dependencies": { "@the-next-ai/ai-gateway": "^1.0.4", "@the-next-ai/bot-gateway-sdk": "^0.1.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index 8e040f78..83627b1a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@musistudio/claude-code-router", - "version": "3.0.9", + "version": "3.0.1", "license": "MIT", "description": "Local Claude Code Router gateway with CLI and web management UI.", "repository": { diff --git a/packages/core/package.json b/packages/core/package.json index d9eeb5a9..3a935cc1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@claude-code-router/core", - "version": "3.0.9", + "version": "3.0.1", "private": true, "description": "Claude Code Router core gateway, routing, provider, and storage services.", "main": "dist/main/server.js", From a8ccdf0403f90f482426595972b8d52f6ae30c6f Mon Sep 17 00:00:00 2001 From: musistudio Date: Wed, 8 Jul 2026 16:03:24 +0800 Subject: [PATCH 02/21] Handle update states and rollback failed plugin loads --- packages/core/src/gateway/service.ts | 8 +- packages/core/src/plugins/service.ts | 61 +++++++++++++- packages/ui/src/pages/home/App.tsx | 15 +++- .../ui/src/pages/home/components/layout.tsx | 47 +++++++---- tests/main/plugin-service.test.mjs | 84 +++++++++++++++++++ 5 files changed, 195 insertions(+), 20 deletions(-) create mode 100644 tests/main/plugin-service.test.mjs diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index 5936e7aa..13f78a6e 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -1157,8 +1157,8 @@ async function writeCoreGatewayConfig( mkdirSync(dirname(config.gateway.generatedConfigFile), { mode: privateDirMode, recursive: true }); const pluginCoreGatewayConfig = pluginService.getCoreGatewayConfig(); const providerPlugins = withCodexOauthRuntimeDefaults([ - ...(config.providerPlugins ?? []), - ...pluginService.getCoreProviderPlugins() + ...(config.providerPlugins ?? []).filter(providerPluginEnabled), + ...pluginService.getCoreProviderPlugins().filter(providerPluginEnabled) ]); const codexOauthProviderNames = codexOauthLocalProviderNames(providerPlugins); const virtualModelProfiles = normalizeCoreGatewayVirtualModelProfiles(withCodexCompatibleVirtualModelProfiles(withFusionVirtualModelAliases([ @@ -1244,6 +1244,10 @@ function writePrivateTextFile(file: string, content: string): void { } } +function providerPluginEnabled(plugin: unknown): boolean { + return !isRecord(plugin) || plugin.enabled !== false; +} + export function normalizeCoreGatewayVirtualModelProfiles(profiles: unknown[], config: AppConfig): unknown[] { return profiles.map((profile) => normalizeCoreGatewayVirtualModelProfile(profile, config)); } diff --git a/packages/core/src/plugins/service.ts b/packages/core/src/plugins/service.ts index ae9ebd95..05fc11cc 100644 --- a/packages/core/src/plugins/service.ts +++ b/packages/core/src/plugins/service.ts @@ -144,6 +144,18 @@ type LoadedPlugin = { stop?: () => MaybePromise; }; +type PluginServiceStateSnapshot = { + apps: InstalledBrowserApp[]; + coreGatewayConfig: Record; + coreProviderPlugins: unknown[]; + gatewayRoutes: RegisteredGatewayRoute[]; + providerAccountConnectors: Map; + proxyRoutes: RegisteredProxyRoute[]; + resourceOwnerIds: Set; + stopHooks: Array<() => MaybePromise>; + virtualModelProfiles: unknown[]; +}; + const requireFromHere = createRequire(__filename); const builtInMarketplacePluginModules = new Map([ ["claude-design", path.join(__dirname, "..", "marketplace", "plugins", "claude-design-plugin.cjs")], @@ -172,8 +184,14 @@ class GatewayPluginService { if (pluginConfig.enabled === false) { continue; } + const snapshot = this.createStateSnapshot(); this.resourceOwnerIds.add(pluginConfig.id); - await this.loadConfiguredPlugin(pluginConfig); + try { + await this.loadConfiguredPlugin(pluginConfig); + } catch (error) { + await this.rollbackConfiguredPluginLoad(pluginConfig.id, snapshot); + console.warn(`[plugin:${pluginConfig.id}] Disabled after startup failure: ${formatError(error)}`); + } } } @@ -513,6 +531,47 @@ class GatewayPluginService { ): Promise { return backendService.openSqliteStore(pluginId, pluginDataDir, options); } + + private createStateSnapshot(): PluginServiceStateSnapshot { + return { + apps: [...this.apps], + coreGatewayConfig: { ...this.coreGatewayConfig }, + coreProviderPlugins: [...this.coreProviderPlugins], + gatewayRoutes: [...this.gatewayRoutes], + providerAccountConnectors: new Map(this.providerAccountConnectors), + proxyRoutes: [...this.proxyRoutes], + resourceOwnerIds: new Set(this.resourceOwnerIds), + stopHooks: [...this.stopHooks], + virtualModelProfiles: [...this.virtualModelProfiles] + }; + } + + private async rollbackConfiguredPluginLoad(pluginId: string, snapshot: PluginServiceStateSnapshot): Promise { + const newStopHooks = this.stopHooks.slice(snapshot.stopHooks.length).reverse(); + this.apps = snapshot.apps; + this.coreGatewayConfig = snapshot.coreGatewayConfig; + this.coreProviderPlugins = snapshot.coreProviderPlugins; + this.gatewayRoutes = snapshot.gatewayRoutes; + this.providerAccountConnectors = snapshot.providerAccountConnectors; + this.proxyRoutes = snapshot.proxyRoutes; + this.resourceOwnerIds = snapshot.resourceOwnerIds; + this.stopHooks = snapshot.stopHooks; + this.virtualModelProfiles = snapshot.virtualModelProfiles; + + for (const stopHook of newStopHooks) { + try { + await stopHook(); + } catch (error) { + console.warn(`[plugin:${pluginId}] Rollback stop hook failed: ${formatError(error)}`); + } + } + + try { + await backendService.stopOwner(pluginId); + } catch (error) { + console.warn(`[plugin:${pluginId}] Rollback resource cleanup failed: ${formatError(error)}`); + } + } } export const pluginService = new GatewayPluginService(); diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index 5bd3b5e5..69db5c79 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -827,12 +827,23 @@ function App() { void checkForAppUpdate(); } - function openUpdateDownloadDialog() { + function openSidebarUpdateDialog() { setUpdateDialogOpen(true); setUpdateActionError(""); if (updateDialogStatus.canDownload || updateDialogStatus.state === "available") { void downloadAppUpdate(); + return; } + if ( + updateDialogStatus.canInstall || + updateDialogStatus.state === "checking" || + updateDialogStatus.state === "downloading" || + updateDialogStatus.state === "downloaded" || + updateDialogStatus.state === "installing" + ) { + return; + } + void checkForAppUpdate(); } async function checkForAppUpdate() { @@ -2816,7 +2827,7 @@ function App() { isMac={isMac} needsTrafficLightSafeArea={needsTrafficLightSafeArea} networkCaptureEnabled={networkCaptureEnabled} - onDownloadUpdate={openUpdateDownloadDialog} + onOpenUpdate={openSidebarUpdateDialog} onOpenSettings={openSettingsDialog} onSelectNavigationItem={selectNavigationItem} onToggleSidebar={() => setSidebarOpen((current) => !current)} diff --git a/packages/ui/src/pages/home/components/layout.tsx b/packages/ui/src/pages/home/components/layout.tsx index 663d7ac2..468afc2b 100644 --- a/packages/ui/src/pages/home/components/layout.tsx +++ b/packages/ui/src/pages/home/components/layout.tsx @@ -1,8 +1,8 @@ import type { ComponentProps } from "react"; import { - AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, cn, EndpointTitleBar, + AnimatedIconSwap, AnimatePresence, AppConfig, AppCopy, Button, Check, cn, EndpointTitleBar, AppUpdateStatus, Download, GatewayStatus, listSpringTransition, LucideIcon, motion, motionEase, - LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, + LoaderCircle, NavigationId, PanelLeftClose, PanelLeftOpen, RefreshCw, reducedMotionTransition, ServiceControlButton, Settings, ViewId, ViewMotionShell, viewUsesInternalScroll } from "../shared/index"; @@ -63,7 +63,7 @@ export function MainLayout({ needsTrafficLightSafeArea, agentAnalysisEnabled, networkCaptureEnabled, - onDownloadUpdate, + onOpenUpdate, onOpenSettings, onSelectNavigationItem, onToggleSidebar, @@ -86,7 +86,7 @@ export function MainLayout({ isMac: boolean; needsTrafficLightSafeArea: boolean; networkCaptureEnabled: boolean; - onDownloadUpdate: () => void; + onOpenUpdate: () => void; onOpenSettings: () => void; onSelectNavigationItem: (id: NavigationId) => void; onToggleSidebar: () => void; @@ -99,15 +99,24 @@ export function MainLayout({ requestLogsEnabled: boolean; visibleNavigation: MainNavigationItem[]; }) { - const windowControlSafeAreaWidth = isMac ? 152 : 88; - const showUpdateDownloadButton = - updateStatus.supported && - (updateStatus.state === "available" || updateStatus.state === "downloading" || updateStatus.state === "downloaded"); - const updateDownloadLabel = updateStatus.state === "downloaded" + const showUpdateButton = updateStatus.supported; + const windowControlSafeAreaWidth = showUpdateButton + ? (isMac ? 188 : 124) + : (isMac ? 152 : 88); + const updateBusy = + updateActionBusy || + updateStatus.state === "checking" || + updateStatus.state === "downloading" || + updateStatus.state === "installing"; + const updateLabel = updateStatus.state === "downloaded" ? copy.text["Update ready to install"] ?? "Update ready to install" : updateStatus.state === "downloading" ? copy.text["Downloading update"] ?? "Downloading update" - : copy.text["Download update"] ?? "Download update"; + : updateStatus.state === "checking" + ? copy.text["Checking for updates"] ?? "Checking for updates" + : updateStatus.state === "available" + ? copy.text["Download update"] ?? "Download update" + : copy.text["Check for updates"] ?? "Check for updates"; return ( <> @@ -132,17 +141,25 @@ export function MainLayout({ onClick={toggleGatewayService} state={gatewayStatus.state} /> - {showUpdateDownloadButton ? ( + {showUpdateButton ? ( ) : null} diff --git a/tests/main/plugin-service.test.mjs b/tests/main/plugin-service.test.mjs new file mode 100644 index 00000000..04a4978f --- /dev/null +++ b/tests/main/plugin-service.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts"; +import { pluginService } from "../../packages/core/src/plugins/service.ts"; + +test("plugin service skips failed plugins and rolls back their registrations", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-plugin-service-test-")); + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.map(String).join(" ")); + + try { + const brokenPlugin = path.join(dir, "broken-plugin.cjs"); + const goodPlugin = path.join(dir, "good-plugin.cjs"); + writeFileSync(brokenPlugin, ` +module.exports = async function brokenPlugin(context) { + context.registerApp({ id: "broken-context-app", name: "Broken context", url: "http://broken-context.local" }); + context.registerGatewayRoute({ id: "broken-context-route", path: "/broken-context", handler(_request, response) { response.end("broken"); } }); + context.registerCoreGatewayProviderPlugin({ key: "broken-context-provider" }); + context.registerCoreGatewayVirtualModelProfile({ id: "broken-context-vm" }); + context.registerProviderAccountConnector({ id: "broken-account", resolve() { return []; } }); + throw new Error("broken plugin setup failed"); +}; +`); + writeFileSync(goodPlugin, ` +module.exports = { + setup(context) { + context.registerCoreGatewayProviderPlugin({ key: "good-context-provider" }); + context.registerCoreGatewayVirtualModelProfile({ id: "good-context-vm" }); + return { + apps: [{ id: "good-app", name: "Good app", url: "http://good.local" }], + coreGateway: { + config: { agent: { mcpServers: [{ name: "good-mcp", command: "good" }] } }, + providerPlugins: [{ key: "good-provider" }], + virtualModelProfiles: [{ id: "good-vm" }] + }, + gatewayRoutes: [{ id: "good-route", path: "/good", handler(_request, response) { response.end("good"); } }], + providerAccountConnectors: [{ id: "good-account", resolve() { return []; } }], + proxyRoutes: [{ host: "good.local", upstream: "http://127.0.0.1" }] + }; + } +}; +`); + + const config = createDefaultAppConfig({ generatedConfigFile: path.join(dir, "gateway.json") }); + config.plugins = [ + { + apps: [{ id: "broken-config-app", name: "Broken config", url: "http://broken-config.local" }], + coreGateway: { + config: { agent: { mcpServers: [{ name: "broken-config-mcp", command: "broken" }] } }, + providerPlugins: [{ key: "broken-config-provider" }], + virtualModelProfiles: [{ id: "broken-config-vm" }] + }, + id: "broken", + module: brokenPlugin, + proxy: { routes: [{ host: "broken.local", upstream: "http://127.0.0.1" }] } + }, + { + id: "good", + module: goodPlugin + } + ]; + + await pluginService.start(config); + + assert.match(warnings.join("\n"), /plugin:broken.*Disabled after startup failure.*broken plugin setup failed/); + assert.deepEqual(pluginService.getApps().map((app) => app.id), ["good-app"]); + assert.equal(pluginService.matchGatewayRoute("GET", "/broken-context"), undefined); + assert.equal(pluginService.matchGatewayRoute("GET", "/good")?.id, "good-route"); + assert.deepEqual(pluginService.getProxyRouteTargets(), [{ host: "good.local", paths: undefined }]); + assert.deepEqual(pluginService.getCoreProviderPlugins().map((plugin) => plugin.key), ["good-context-provider", "good-provider"]); + assert.deepEqual(pluginService.getVirtualModelProfiles().map((profile) => profile.id), ["good-context-vm", "good-vm"]); + assert.deepEqual(pluginService.getCoreGatewayConfig(), { agent: { mcpServers: [{ name: "good-mcp", command: "good" }] } }); + assert.equal(pluginService.getProviderAccountConnector("broken", "broken-account"), undefined); + assert.equal(typeof pluginService.getProviderAccountConnector("good", "good-account")?.resolve, "function"); + } finally { + console.warn = originalWarn; + await pluginService.stop(); + rmSync(dir, { force: true, recursive: true }); + } +}); From 980a9bae63c417a94d4093e4366befcfd35bb6ec Mon Sep 17 00:00:00 2001 From: musistudio Date: Wed, 8 Jul 2026 17:02:36 +0800 Subject: [PATCH 03/21] Add paginated browser snapshots and raise dialog z-index --- .../src/main/browser-automation-mcp.ts | 45 +++++++++++++++---- packages/ui/src/components/ui/dialog.tsx | 2 +- .../src/pages/home/components/dashboard.tsx | 2 +- .../src/pages/home/components/providers.tsx | 2 +- .../ui/src/pages/home/components/settings.tsx | 4 +- .../pages/home/components/virtual-models.tsx | 2 +- 6 files changed, 42 insertions(+), 15 deletions(-) diff --git a/packages/electron/src/main/browser-automation-mcp.ts b/packages/electron/src/main/browser-automation-mcp.ts index 884905f3..10f85582 100644 --- a/packages/electron/src/main/browser-automation-mcp.ts +++ b/packages/electron/src/main/browser-automation-mcp.ts @@ -70,6 +70,13 @@ type BrowserSessionRef = { userId?: string; }; +type SnapshotOptions = { + limit?: number; + maxElements: number; + maxText?: number; + offset?: number; +}; + type AttachedSession = { attachedAt: number; leaseId: string; @@ -140,6 +147,8 @@ const defaultSnapshotMaxElements = 80; const defaultSnapshotMaxText = 3_000; const defaultEventAwaitTimeoutMs = 10_000; const defaultJavascriptTimeoutMs = 8_000; +const maxSnapshotTextLimit = 20_000; +const maxSnapshotTextOffset = 1_000_000_000; const maxMcpRequestBytes = 2 * 1024 * 1024; const maxSubscriptionEvents = 512; const maxToolResultChars = 60_000; @@ -148,7 +157,7 @@ const maxToolResultStringChars = 2_000; const maxToolResultObjectKeys = 120; const maxSnapshotResultElements = 80; const maxAxSnapshotResultNodes = 80; -const maxBrowserResultTextChars = 3_000; +const maxBrowserResultTextChars = maxSnapshotTextLimit; const defaultWaitTimeoutMs = 10_000; const sessionSchema = objectSchema({ @@ -552,8 +561,10 @@ function browserLegacyAliasTools(): McpTool[] { { description: "Capture page text plus interactable element refs for browser automation. Use returned refs for browser_click, browser_type, browser_select, browser_press_key, or browser_scroll.", inputSchema: objectSchema({ + limit: { description: "Maximum page text characters to include. Overrides maxText when both are provided.", maximum: maxSnapshotTextLimit, minimum: 0, type: "number" }, maxElements: { description: "Maximum interactable elements to include.", maximum: 300, minimum: 1, type: "number" }, - maxText: { description: "Maximum page text characters to include.", maximum: 20000, minimum: 0, type: "number" }, + maxText: { description: "Legacy alias for limit.", maximum: maxSnapshotTextLimit, minimum: 0, type: "number" }, + offset: { description: "Starting character offset into the normalized page text.", maximum: maxSnapshotTextOffset, minimum: 0, type: "number" }, tabId: { description: "Optional tab id. Defaults to the active tab.", type: "string" } }), name: "browser_snapshot", @@ -1014,8 +1025,9 @@ class BrowserAutomationMcpService implements BrowserAutomationMcpIntegration { builtInBrowserService.getAutomationWebContents(readString(args.tabId)), readString(args.tabId), { + limit: clampInteger(readNumber(args.limit) ?? readNumber(args.maxText) ?? defaultSnapshotMaxText, 0, maxSnapshotTextLimit), maxElements: clampInteger(readNumber(args.maxElements) ?? defaultSnapshotMaxElements, 1, 300), - maxText: clampInteger(readNumber(args.maxText) ?? defaultSnapshotMaxText, 0, 20000) + offset: clampInteger(readNumber(args.offset) ?? 0, 0, maxSnapshotTextOffset) } ); case "browser_click": @@ -2339,7 +2351,7 @@ async function withTimeout(promise: Promise, timeoutMs: number, message: s } } -async function captureSnapshot(webContents: WebContents, options: { maxElements: number; maxText: number }): Promise { +async function captureSnapshot(webContents: WebContents, options: SnapshotOptions): Promise { return await executeJavaScriptWithTimeout( webContents, `(${snapshotScript})(${JSON.stringify(options)})`, @@ -2351,7 +2363,7 @@ async function captureSnapshot(webContents: WebContents, options: { maxElements: async function captureSnapshotWithHandoff( webContents: WebContents, tabId: string | undefined, - options: { maxElements: number; maxText: number } + options: SnapshotOptions ): Promise { const snapshot = await captureSnapshot(webContents, options); const handoff = await maybeRequestPageHandoff(webContents, { @@ -2670,9 +2682,11 @@ async function waitForPageCondition( }; } -const snapshotScript = function(options: { maxElements: number; maxText: number }) { +const snapshotScript = function(options: { limit?: number; maxElements: number; maxText?: number; offset?: number }) { const maxElements = Math.max(1, Math.min(300, Math.floor(options.maxElements || 80))); - const maxText = Math.max(0, Math.min(20000, Math.floor(options.maxText || 3000))); + const textLimitInput = options.limit ?? options.maxText ?? 3000; + const textLimit = Math.max(0, Math.min(20000, Math.floor(textLimitInput))); + const requestedTextOffset = Math.max(0, Math.floor(options.offset || 0)); const refAttribute = "data-ccr-browser-ref"; const elementSelector = [ "a[href]", @@ -2860,6 +2874,11 @@ const snapshotScript = function(options: { maxElements: number; maxText: number }; }); + const pageText = (document.body?.innerText || "").replace(/\s+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); + const textOffset = Math.min(requestedTextOffset, pageText.length); + const textWindow = pageText.slice(textOffset, textOffset + textLimit); + const textNextOffset = textOffset + textWindow.length; + return { activeElement: document.activeElement ? { name: nameOf(document.activeElement).slice(0, 240), @@ -2868,7 +2887,15 @@ const snapshotScript = function(options: { maxElements: number; maxText: number tag: document.activeElement.tagName.toLowerCase() } : undefined, elements, - text: (document.body?.innerText || "").replace(/\s+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim().slice(0, maxText), + text: textWindow, + textHasMore: textNextOffset < pageText.length, + textLength: pageText.length, + textLimit, + textNextOffset, + textOffset, + textRemaining: Math.max(0, pageText.length - textNextOffset), + textReturned: textWindow.length, + textRequestedOffset: requestedTextOffset !== textOffset ? requestedTextOffset : undefined, title: document.title, url: location.href }; @@ -3437,7 +3464,7 @@ function truncateString(value: string, maxChars: number): string { function largeResultGuidance(toolName: string): string { if (toolName === "browser_snapshot") { - return "Result was compacted. Re-run browser_snapshot with lower maxElements/maxText, or use browser_ax_query to fetch targeted elements."; + return "Result was compacted. Re-run browser_snapshot with lower maxElements/limit, page text with offset/limit, or use browser_ax_query to fetch targeted elements."; } if (toolName === "browser_ax_snapshot") { return "Result was compacted. Re-run browser_ax_snapshot with lower limit or scope=outline, or use browser_ax_query with role/name/text filters."; diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx index 3f28c946..1d971dd5 100644 --- a/packages/ui/src/components/ui/dialog.tsx +++ b/packages/ui/src/components/ui/dialog.tsx @@ -46,7 +46,7 @@ function Dialog({ return ( { diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index 1f7f5179..310d1111 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -3705,7 +3705,7 @@ function ToolPayloadDialog({ : undefined; return ( - !open && onClose()} open> + !open && onClose()} open>
diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index 5f1753ab..3d62fa83 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -2516,7 +2516,7 @@ export function AddProviderDialog({
{checkConfirmOpen ? ( - !open && !checkConfirmBusy && setCheckConfirmOpen(false)}> + !open && !checkConfirmBusy && setCheckConfirmOpen(false)}>
diff --git a/packages/ui/src/pages/home/components/settings.tsx b/packages/ui/src/pages/home/components/settings.tsx index 2b6657fe..4ef57a51 100644 --- a/packages/ui/src/pages/home/components/settings.tsx +++ b/packages/ui/src/pages/home/components/settings.tsx @@ -636,7 +636,7 @@ function ToolHubMcpServerDialog({ const stdioMessageModeOptions = translateOptions(mcpStdioMessageModeOptions, t); return ( - !nextOpen && onClose()} open={open}> + !nextOpen && onClose()} open={open}>
@@ -755,7 +755,7 @@ function ToolHubMcpJsonDialog({ const t = useAppText(); return ( - !nextOpen && onClose()} open={open}> + !nextOpen && onClose()} open={open}>
diff --git a/packages/ui/src/pages/home/components/virtual-models.tsx b/packages/ui/src/pages/home/components/virtual-models.tsx index 6e7c2c46..bbfcc357 100644 --- a/packages/ui/src/pages/home/components/virtual-models.tsx +++ b/packages/ui/src/pages/home/components/virtual-models.tsx @@ -605,7 +605,7 @@ function CustomMcpToolDialog({ } return ( - !nextOpen && onClose()} open={open}> + !nextOpen && onClose()} open={open}>
From cce72928775e56bc191944c723fb82fee0bf4710 Mon Sep 17 00:00:00 2001 From: "Kuranasaki@M3Max" Date: Wed, 8 Jul 2026 16:55:55 +0700 Subject: [PATCH 04/21] Fix broken auto import --- packages/ui/src/pages/home/App.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index 5bd3b5e5..c6b61da6 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -60,12 +60,17 @@ const localAgentProviderApiKey = "ccr-local-agent-login"; function materializeProviderPluginTemplates( templates: unknown[], providerName: string, - protocol: GatewayProviderConfig["type"] + protocol: GatewayProviderConfig["type"], + providerId: string ): unknown[] { if (templates.length === 0) { return []; } - const internalName = protocol ? `${providerName}::${protocol}` : providerName; + // The core gateway matches provider plugins against the provider's runtime + // identifier (provider.id, or its slug), not the human-readable display name + // — the internal name here must mirror providerCapabilityInternalName() in + // gateway/service.ts or the plugin's auth-header override silently never applies. + const internalName = protocol ? `${providerId}::${protocol}` : providerId; const replacements: Record = { [providerInternalNamePlaceholder]: internalName, [providerNamePlaceholder]: providerName, @@ -1431,6 +1436,8 @@ function App() { return false; } + const existingProvider = providerEditIndex !== undefined ? draftConfig.Providers[providerEditIndex] : undefined; + const providerId = existingProvider?.id ?? providerNameSlug(providerName); const provider: GatewayProviderConfig = { api_base_url: normalizeProviderBaseUrl(baseUrl, protocol), api_key: providerDraft.apiKey.trim(), @@ -1438,13 +1445,14 @@ function App() { account: accountConfig, credentials: credentials.length > 0 ? credentials : undefined, icon: providerDraft.icon.trim() || undefined, + id: providerId, modelDescriptions, modelDisplayNames, models, name: providerName, type: protocol }; - const importedProviderPlugins = materializeProviderPluginTemplates(providerDraft.providerPlugins, providerName, protocol); + const importedProviderPlugins = materializeProviderPluginTemplates(providerDraft.providerPlugins, providerName, protocol, providerId); const wasImport = providerImportOpen; const next = buildConfigUpdate((config) => { From d8154e5357f2773eae267c8eb755fbf741cf4483 Mon Sep 17 00:00:00 2001 From: "Kuranasaki@M3Max" Date: Wed, 8 Jul 2026 17:21:35 +0700 Subject: [PATCH 05/21] Read claude credential from KeyChain --- issue.md | 109 ++++++++++++++++++ issue2.md | 39 +++++++ .../src/agents/local-providers/claude-code.ts | 38 ++++++ 3 files changed, 186 insertions(+) create mode 100644 issue.md create mode 100644 issue2.md diff --git a/issue.md b/issue.md new file mode 100644 index 00000000..f4b970b7 --- /dev/null +++ b/issue.md @@ -0,0 +1,109 @@ +# Local-agent OAuth provider plugin auth override never applies + +## Summary + +Providers imported from a local CLI login (Claude Code OAuth, Codex OAuth) send +requests upstream with the wrong auth header. Instead of +`Authorization: Bearer ` (plus `anthropic-beta: oauth-2025-04-20`), +the request goes out with `x-api-key: ccr-local-agent-login` (the internal +placeholder credential) or, after a partial fix, `x-api-key: ` +— still the wrong header, since Anthropic's OAuth flow requires `Authorization`, +not `x-api-key`. + +## Root cause + +Importing a local-agent provider (`packages/core/src/agents/local-providers/claude-code.ts`, +`codex.ts`) creates two things: + +1. A `GatewayProviderConfig` entry in `config.Providers`, with `api_key` set to + the sentinel placeholder `ccr-local-agent-login` + (`packages/core/src/agents/local-providers/shared.ts:26`). +2. One or more `providerPlugins` entries (`bearerAuthPlugin()` / + `apiKeyAuthPlugin()` in `shared.ts:78-114`) carrying the real captured OAuth + token in `auth.headers.authorization`, plus `removeHeaders: ["x-api-key"]`. + +The plugin is supposed to be matched to its provider by the internal gateway +process (`@the-next-ai/ai-gateway`, config written by `writeCoreGatewayConfig()` +in `packages/core/src/gateway/service.ts:1151`) via an **exact string match** +between the plugin's `providerName` field and the provider's *runtime* name. + +The runtime name is computed by `providerRuntimeId()` +(`gateway/service.ts:6671`): + +```ts +function providerRuntimeId(provider) { + const explicit = sanitizeProviderHeaderId(provider.id); + if (explicit) return explicit; + // ...falls back to `provider--` +} +``` + +`provider.id` was **never set** during import +(`packages/ui/src/pages/home/App.tsx`, provider-save handler around line 1434 +— no `id` field on the constructed `GatewayProviderConfig`). So the backend +always fell into the hash branch, producing an opaque name like +`provider-claude-code-api-884b99c439::anthropic_messages`. + +Meanwhile, the plugin's `providerName` was set (in +`materializeProviderPluginTemplates()`, `App.tsx:60-75`) to the **human-readable +label**: `"Claude Code API::anthropic_messages"`. + +These two strings never match. The gateway's plugin-resolution step +(`resolve(n, t)` in the vendor bundle) silently no-ops — no error, no log — +and the provider's raw `api_key` (the sentinel, or later the swapped-in real +token) goes out using the protocol's default header convention. For Anthropic +(`type: "anthropic"`), that default is always `x-api-key`, never `Authorization`, +regardless of `extraHeaders`. + +## Fix applied + +Two changes, both to make the plugin's `providerName` deterministically equal +to whatever runtime name the provider will actually get — rather than trying +to intercept/patch headers after the fact. + +**1. `packages/ui/src/pages/home/App.tsx` (generator/import-time fix, kept):** + +- Provider save now sets an explicit `id` on the saved `GatewayProviderConfig`: + a slug of the provider name (`providerNameSlug(providerName)`), reusing the + existing edit's `id` if editing rather than importing fresh. +- `materializeProviderPluginTemplates()` now builds the plugin's internal name + from that same `id` (`${providerId}::${protocol}`) instead of the raw human + name, so it exactly matches `providerCapabilityInternalName()` on the + backend (which now takes the explicit `id` path in `providerRuntimeId()`, + skipping the hash entirely — fully deterministic). + +Verified: after wiping `~/.claude-code-router` and re-importing, generated +`gateway.config.json` shows the provider and its paired plugin sharing the +same computed name (`claude-code-api::anthropic_messages`), where previously +they diverged (`Claude Code API` vs `provider-claude-code-api-::anthropic_messages`). + +**2. `packages/core/src/gateway/service.ts` (write-time defense-in-depth, +currently stashed / not applied):** + +- `toCoreGatewayProvider()` swaps the sentinel `api_key` for the real resolved + credential (via `localAgentProviderAccountCredential()`, exported from + `packages/core/src/providers/account-service.ts`) before handing the + provider to the gateway. +- `writeCoreGatewayConfig()` also expands each `providerPlugins` entry's + `providerName` into every actual runtime alias (bare name, `name::protocol`) + via a new `withProviderPluginRuntimeNames()` alias-map, so plugins still + match even if a provider was imported before the fix in (1). +- Held back at the user's request in favor of the cleaner root-cause fix in + (1); re-apply from `git stash@{0}` ("Fixed claude oat") if existing + pre-fix-1 imports need to keep working without re-import. + +## Known follow-up: newer macOS Claude Code CLI stores credentials in Keychain + +`~/.claude/.credentials.json` is Claude Code CLI's older on-disk credential +store. Recent macOS builds instead store the OAuth token in the macOS +Keychain under generic-password service name `"Claude Code-credentials"`. +CCR's importer (`claude-code.ts`) only reads the JSON file today — if it's +absent, import silently finds nothing to import. + +Fix not yet implemented: in the importer, fall back to +`security find-generic-password -s "Claude Code-credentials" -w` (triggers a +normal macOS keychain-access permission prompt) when the credentials file is +missing, parse stdout as JSON, and feed it through the same +`findOauthTokenSet()` path already used for the file-based case. Needs a +try/catch since `security` exits non-zero if the user denies the prompt or no +such keychain item exists. diff --git a/issue2.md b/issue2.md new file mode 100644 index 00000000..3ba74260 --- /dev/null +++ b/issue2.md @@ -0,0 +1,39 @@ +# Claude Code CLI credentials moved to macOS Keychain — local-agent import fails + +## Problem + +Newer macOS builds of the Claude Code CLI store OAuth credentials in the macOS +Keychain (service name `"Claude Code-credentials"`) instead of +`~/.claude/.credentials.json`. CCR's local-agent provider importer only reads +the file path, so on these installs it finds no credentials and the +"Claude Code API" local login provider cannot be detected/imported. + +## Where it's read today + +`packages/core/src/agents/local-providers/claude-code.ts` uses +`readJsonRecord()` (from `packages/core/src/agents/local-providers/shared.ts`) +to read `~/.claude/.credentials.json` directly off disk. There is no +Keychain fallback anywhere in `packages/core/src/agents/local-providers/`. + +## Fix direction + +Add a Keychain fallback when the credentials file is missing/empty: + +```bash +security find-generic-password -s "Claude Code-credentials" -w +``` + +- Triggers a standard macOS permission prompt (Allow / Always Allow) on + first access — no bypass, same consent flow any app goes through. +- Exits non-zero if the item doesn't exist or the user denies access — + must be wrapped in try/catch, falling back to "no local credentials + found" (existing `missingCandidate()` path) rather than throwing. +- Output is JSON on stdout, same shape expected by + `findOauthTokenSet()` in `shared.ts` — parse and feed through the same + path used for the file-based case. +- macOS-only path (`process.platform === "darwin"`); other platforms keep + using the file-based read only. + +## Not yet implemented + +This is a plan, not a diff — nothing has been changed for this issue yet. diff --git a/packages/core/src/agents/local-providers/claude-code.ts b/packages/core/src/agents/local-providers/claude-code.ts index 37c05ff7..00e2032a 100644 --- a/packages/core/src/agents/local-providers/claude-code.ts +++ b/packages/core/src/agents/local-providers/claude-code.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import type { @@ -9,6 +10,7 @@ import type { import { bearerAuthPlugin, findOauthTokenSet, + isRecord, missingCandidate, providerInternalNamePlaceholder, providerPayload, @@ -18,6 +20,8 @@ import { type OAuthTokenSet } from "@ccr/core/agents/local-providers/shared"; +const claudeCodeKeychainService = "Claude Code-credentials"; + const claudeDefaultModels = ["claude-sonnet-4-20250514"]; const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({ @@ -148,6 +152,19 @@ function readClaudeCodeOauth(): OAuthTokenSet | undefined { sourceFile }; } + + const keychainRecord = readClaudeCodeKeychainRecord(); + if (keychainRecord) { + const credential = findOauthTokenSet(keychainRecord); + if (credential) { + return { + accessToken: credential.accessToken, + refreshToken: credential.refreshToken, + sourceFile: `keychain:${claudeCodeKeychainService}` + }; + } + } + return undefined; } @@ -158,3 +175,24 @@ function claudeCredentialFiles(): string[] { path.join(os.homedir(), ".config", "claude", "credentials.json") ]); } + +// Newer macOS builds of the Claude Code CLI store credentials in the +// Keychain instead of ~/.claude/.credentials.json. Reading it triggers the +// standard macOS keychain access prompt (Allow / Always Allow); the user +// declining or the item not existing both surface as a non-zero exit here. +function readClaudeCodeKeychainRecord(): Record | undefined { + if (process.platform !== "darwin") { + return undefined; + } + try { + const output = execFileSync( + "security", + ["find-generic-password", "-s", claudeCodeKeychainService, "-w"], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] } + ); + const parsed = JSON.parse(output.trim()) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} From a65b8bb6f960bfe22f7ac92df99945e7ef6597d6 Mon Sep 17 00:00:00 2001 From: "Kuranasaki@M3Max" Date: Wed, 8 Jul 2026 17:23:13 +0700 Subject: [PATCH 06/21] Read claude credential from KeyChain --- .../src/agents/local-providers/claude-code.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/core/src/agents/local-providers/claude-code.ts b/packages/core/src/agents/local-providers/claude-code.ts index 37c05ff7..00e2032a 100644 --- a/packages/core/src/agents/local-providers/claude-code.ts +++ b/packages/core/src/agents/local-providers/claude-code.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import type { @@ -9,6 +10,7 @@ import type { import { bearerAuthPlugin, findOauthTokenSet, + isRecord, missingCandidate, providerInternalNamePlaceholder, providerPayload, @@ -18,6 +20,8 @@ import { type OAuthTokenSet } from "@ccr/core/agents/local-providers/shared"; +const claudeCodeKeychainService = "Claude Code-credentials"; + const claudeDefaultModels = ["claude-sonnet-4-20250514"]; const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({ @@ -148,6 +152,19 @@ function readClaudeCodeOauth(): OAuthTokenSet | undefined { sourceFile }; } + + const keychainRecord = readClaudeCodeKeychainRecord(); + if (keychainRecord) { + const credential = findOauthTokenSet(keychainRecord); + if (credential) { + return { + accessToken: credential.accessToken, + refreshToken: credential.refreshToken, + sourceFile: `keychain:${claudeCodeKeychainService}` + }; + } + } + return undefined; } @@ -158,3 +175,24 @@ function claudeCredentialFiles(): string[] { path.join(os.homedir(), ".config", "claude", "credentials.json") ]); } + +// Newer macOS builds of the Claude Code CLI store credentials in the +// Keychain instead of ~/.claude/.credentials.json. Reading it triggers the +// standard macOS keychain access prompt (Allow / Always Allow); the user +// declining or the item not existing both surface as a non-zero exit here. +function readClaudeCodeKeychainRecord(): Record | undefined { + if (process.platform !== "darwin") { + return undefined; + } + try { + const output = execFileSync( + "security", + ["find-generic-password", "-s", claudeCodeKeychainService, "-w"], + { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] } + ); + const parsed = JSON.parse(output.trim()) as unknown; + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} From 421cbd05b2354c232c2fbb45c238632f304229aa Mon Sep 17 00:00:00 2001 From: "Kuranasaki@M3Max" Date: Wed, 8 Jul 2026 17:26:51 +0700 Subject: [PATCH 07/21] Update default claude model to claude-sonnet-4-5-20250929 --- packages/core/src/agents/local-providers/claude-code.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agents/local-providers/claude-code.ts b/packages/core/src/agents/local-providers/claude-code.ts index 00e2032a..f712fcd1 100644 --- a/packages/core/src/agents/local-providers/claude-code.ts +++ b/packages/core/src/agents/local-providers/claude-code.ts @@ -22,7 +22,7 @@ import { const claudeCodeKeychainService = "Claude Code-credentials"; -const claudeDefaultModels = ["claude-sonnet-4-20250514"]; +const claudeDefaultModels = ["claude-sonnet-4-5-20250929"]; const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({ id, From f56896867381f9947fbec1d04fe2642d22a65d88 Mon Sep 17 00:00:00 2001 From: "Kuranasaki@M3Max" Date: Wed, 8 Jul 2026 17:29:59 +0700 Subject: [PATCH 08/21] Revert model change --- packages/core/src/agents/local-providers/claude-code.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agents/local-providers/claude-code.ts b/packages/core/src/agents/local-providers/claude-code.ts index f712fcd1..00e2032a 100644 --- a/packages/core/src/agents/local-providers/claude-code.ts +++ b/packages/core/src/agents/local-providers/claude-code.ts @@ -22,7 +22,7 @@ import { const claudeCodeKeychainService = "Claude Code-credentials"; -const claudeDefaultModels = ["claude-sonnet-4-5-20250929"]; +const claudeDefaultModels = ["claude-sonnet-4-20250514"]; const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({ id, From 8361805bdfcc85ac7f49d70fec5f4fd2596be91c Mon Sep 17 00:00:00 2001 From: "Kuranasaki@M3Max" Date: Wed, 8 Jul 2026 17:38:24 +0700 Subject: [PATCH 09/21] Update default sonnet model as old one is no longer existed (404) --- packages/core/src/agents/local-providers/claude-code.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agents/local-providers/claude-code.ts b/packages/core/src/agents/local-providers/claude-code.ts index 37c05ff7..d85d9e12 100644 --- a/packages/core/src/agents/local-providers/claude-code.ts +++ b/packages/core/src/agents/local-providers/claude-code.ts @@ -18,7 +18,7 @@ import { type OAuthTokenSet } from "@ccr/core/agents/local-providers/shared"; -const claudeDefaultModels = ["claude-sonnet-4-20250514"]; +const claudeDefaultModels = ["claude-sonnet-4-5-20250929"]; const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({ id, From 70f85864a62ea4018c598520d56313ecf42b67bf Mon Sep 17 00:00:00 2001 From: "Kuranasaki@M3Max" Date: Wed, 8 Jul 2026 17:46:24 +0700 Subject: [PATCH 10/21] Revert "Read claude credential from KeyChain" This reverts commit d8154e5357f2773eae267c8eb755fbf741cf4483. --- issue.md | 109 ------------------ issue2.md | 39 ------- .../src/agents/local-providers/claude-code.ts | 38 ------ 3 files changed, 186 deletions(-) delete mode 100644 issue.md delete mode 100644 issue2.md diff --git a/issue.md b/issue.md deleted file mode 100644 index f4b970b7..00000000 --- a/issue.md +++ /dev/null @@ -1,109 +0,0 @@ -# Local-agent OAuth provider plugin auth override never applies - -## Summary - -Providers imported from a local CLI login (Claude Code OAuth, Codex OAuth) send -requests upstream with the wrong auth header. Instead of -`Authorization: Bearer ` (plus `anthropic-beta: oauth-2025-04-20`), -the request goes out with `x-api-key: ccr-local-agent-login` (the internal -placeholder credential) or, after a partial fix, `x-api-key: ` -— still the wrong header, since Anthropic's OAuth flow requires `Authorization`, -not `x-api-key`. - -## Root cause - -Importing a local-agent provider (`packages/core/src/agents/local-providers/claude-code.ts`, -`codex.ts`) creates two things: - -1. A `GatewayProviderConfig` entry in `config.Providers`, with `api_key` set to - the sentinel placeholder `ccr-local-agent-login` - (`packages/core/src/agents/local-providers/shared.ts:26`). -2. One or more `providerPlugins` entries (`bearerAuthPlugin()` / - `apiKeyAuthPlugin()` in `shared.ts:78-114`) carrying the real captured OAuth - token in `auth.headers.authorization`, plus `removeHeaders: ["x-api-key"]`. - -The plugin is supposed to be matched to its provider by the internal gateway -process (`@the-next-ai/ai-gateway`, config written by `writeCoreGatewayConfig()` -in `packages/core/src/gateway/service.ts:1151`) via an **exact string match** -between the plugin's `providerName` field and the provider's *runtime* name. - -The runtime name is computed by `providerRuntimeId()` -(`gateway/service.ts:6671`): - -```ts -function providerRuntimeId(provider) { - const explicit = sanitizeProviderHeaderId(provider.id); - if (explicit) return explicit; - // ...falls back to `provider--` -} -``` - -`provider.id` was **never set** during import -(`packages/ui/src/pages/home/App.tsx`, provider-save handler around line 1434 -— no `id` field on the constructed `GatewayProviderConfig`). So the backend -always fell into the hash branch, producing an opaque name like -`provider-claude-code-api-884b99c439::anthropic_messages`. - -Meanwhile, the plugin's `providerName` was set (in -`materializeProviderPluginTemplates()`, `App.tsx:60-75`) to the **human-readable -label**: `"Claude Code API::anthropic_messages"`. - -These two strings never match. The gateway's plugin-resolution step -(`resolve(n, t)` in the vendor bundle) silently no-ops — no error, no log — -and the provider's raw `api_key` (the sentinel, or later the swapped-in real -token) goes out using the protocol's default header convention. For Anthropic -(`type: "anthropic"`), that default is always `x-api-key`, never `Authorization`, -regardless of `extraHeaders`. - -## Fix applied - -Two changes, both to make the plugin's `providerName` deterministically equal -to whatever runtime name the provider will actually get — rather than trying -to intercept/patch headers after the fact. - -**1. `packages/ui/src/pages/home/App.tsx` (generator/import-time fix, kept):** - -- Provider save now sets an explicit `id` on the saved `GatewayProviderConfig`: - a slug of the provider name (`providerNameSlug(providerName)`), reusing the - existing edit's `id` if editing rather than importing fresh. -- `materializeProviderPluginTemplates()` now builds the plugin's internal name - from that same `id` (`${providerId}::${protocol}`) instead of the raw human - name, so it exactly matches `providerCapabilityInternalName()` on the - backend (which now takes the explicit `id` path in `providerRuntimeId()`, - skipping the hash entirely — fully deterministic). - -Verified: after wiping `~/.claude-code-router` and re-importing, generated -`gateway.config.json` shows the provider and its paired plugin sharing the -same computed name (`claude-code-api::anthropic_messages`), where previously -they diverged (`Claude Code API` vs `provider-claude-code-api-::anthropic_messages`). - -**2. `packages/core/src/gateway/service.ts` (write-time defense-in-depth, -currently stashed / not applied):** - -- `toCoreGatewayProvider()` swaps the sentinel `api_key` for the real resolved - credential (via `localAgentProviderAccountCredential()`, exported from - `packages/core/src/providers/account-service.ts`) before handing the - provider to the gateway. -- `writeCoreGatewayConfig()` also expands each `providerPlugins` entry's - `providerName` into every actual runtime alias (bare name, `name::protocol`) - via a new `withProviderPluginRuntimeNames()` alias-map, so plugins still - match even if a provider was imported before the fix in (1). -- Held back at the user's request in favor of the cleaner root-cause fix in - (1); re-apply from `git stash@{0}` ("Fixed claude oat") if existing - pre-fix-1 imports need to keep working without re-import. - -## Known follow-up: newer macOS Claude Code CLI stores credentials in Keychain - -`~/.claude/.credentials.json` is Claude Code CLI's older on-disk credential -store. Recent macOS builds instead store the OAuth token in the macOS -Keychain under generic-password service name `"Claude Code-credentials"`. -CCR's importer (`claude-code.ts`) only reads the JSON file today — if it's -absent, import silently finds nothing to import. - -Fix not yet implemented: in the importer, fall back to -`security find-generic-password -s "Claude Code-credentials" -w` (triggers a -normal macOS keychain-access permission prompt) when the credentials file is -missing, parse stdout as JSON, and feed it through the same -`findOauthTokenSet()` path already used for the file-based case. Needs a -try/catch since `security` exits non-zero if the user denies the prompt or no -such keychain item exists. diff --git a/issue2.md b/issue2.md deleted file mode 100644 index 3ba74260..00000000 --- a/issue2.md +++ /dev/null @@ -1,39 +0,0 @@ -# Claude Code CLI credentials moved to macOS Keychain — local-agent import fails - -## Problem - -Newer macOS builds of the Claude Code CLI store OAuth credentials in the macOS -Keychain (service name `"Claude Code-credentials"`) instead of -`~/.claude/.credentials.json`. CCR's local-agent provider importer only reads -the file path, so on these installs it finds no credentials and the -"Claude Code API" local login provider cannot be detected/imported. - -## Where it's read today - -`packages/core/src/agents/local-providers/claude-code.ts` uses -`readJsonRecord()` (from `packages/core/src/agents/local-providers/shared.ts`) -to read `~/.claude/.credentials.json` directly off disk. There is no -Keychain fallback anywhere in `packages/core/src/agents/local-providers/`. - -## Fix direction - -Add a Keychain fallback when the credentials file is missing/empty: - -```bash -security find-generic-password -s "Claude Code-credentials" -w -``` - -- Triggers a standard macOS permission prompt (Allow / Always Allow) on - first access — no bypass, same consent flow any app goes through. -- Exits non-zero if the item doesn't exist or the user denies access — - must be wrapped in try/catch, falling back to "no local credentials - found" (existing `missingCandidate()` path) rather than throwing. -- Output is JSON on stdout, same shape expected by - `findOauthTokenSet()` in `shared.ts` — parse and feed through the same - path used for the file-based case. -- macOS-only path (`process.platform === "darwin"`); other platforms keep - using the file-based read only. - -## Not yet implemented - -This is a plan, not a diff — nothing has been changed for this issue yet. diff --git a/packages/core/src/agents/local-providers/claude-code.ts b/packages/core/src/agents/local-providers/claude-code.ts index 00e2032a..37c05ff7 100644 --- a/packages/core/src/agents/local-providers/claude-code.ts +++ b/packages/core/src/agents/local-providers/claude-code.ts @@ -1,4 +1,3 @@ -import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; import type { @@ -10,7 +9,6 @@ import type { import { bearerAuthPlugin, findOauthTokenSet, - isRecord, missingCandidate, providerInternalNamePlaceholder, providerPayload, @@ -20,8 +18,6 @@ import { type OAuthTokenSet } from "@ccr/core/agents/local-providers/shared"; -const claudeCodeKeychainService = "Claude Code-credentials"; - const claudeDefaultModels = ["claude-sonnet-4-20250514"]; const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({ @@ -152,19 +148,6 @@ function readClaudeCodeOauth(): OAuthTokenSet | undefined { sourceFile }; } - - const keychainRecord = readClaudeCodeKeychainRecord(); - if (keychainRecord) { - const credential = findOauthTokenSet(keychainRecord); - if (credential) { - return { - accessToken: credential.accessToken, - refreshToken: credential.refreshToken, - sourceFile: `keychain:${claudeCodeKeychainService}` - }; - } - } - return undefined; } @@ -175,24 +158,3 @@ function claudeCredentialFiles(): string[] { path.join(os.homedir(), ".config", "claude", "credentials.json") ]); } - -// Newer macOS builds of the Claude Code CLI store credentials in the -// Keychain instead of ~/.claude/.credentials.json. Reading it triggers the -// standard macOS keychain access prompt (Allow / Always Allow); the user -// declining or the item not existing both surface as a non-zero exit here. -function readClaudeCodeKeychainRecord(): Record | undefined { - if (process.platform !== "darwin") { - return undefined; - } - try { - const output = execFileSync( - "security", - ["find-generic-password", "-s", claudeCodeKeychainService, "-w"], - { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] } - ); - const parsed = JSON.parse(output.trim()) as unknown; - return isRecord(parsed) ? parsed : undefined; - } catch { - return undefined; - } -} From 1aaee2ef601e7c3b0b108ebbe8c84e85022978f6 Mon Sep 17 00:00:00 2001 From: "Kuranasaki@M3Max" Date: Wed, 8 Jul 2026 18:24:27 +0700 Subject: [PATCH 11/21] Update to sonnet 5 due legacy model not support effort and adative thinking --- packages/core/src/agents/local-providers/claude-code.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agents/local-providers/claude-code.ts b/packages/core/src/agents/local-providers/claude-code.ts index d85d9e12..bb98b856 100644 --- a/packages/core/src/agents/local-providers/claude-code.ts +++ b/packages/core/src/agents/local-providers/claude-code.ts @@ -18,7 +18,7 @@ import { type OAuthTokenSet } from "@ccr/core/agents/local-providers/shared"; -const claudeDefaultModels = ["claude-sonnet-4-5-20250929"]; +const claudeDefaultModels = ["claude-sonnet-5"]; const percentLimitMapping = (id: string, label: string, path: string, window: string) => ({ id, From ab6a2c1c501627ff23e1052a46c636b3aa8fd285 Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 9 Jul 2026 15:59:08 +0800 Subject: [PATCH 12/21] Fix USD cost formatting for large values --- packages/ui/src/pages/home/shared/usage.ts | 2 +- packages/ui/src/pages/tray/shared.tsx | 2 +- tests/renderer/usage-format.test.ts | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/pages/home/shared/usage.ts b/packages/ui/src/pages/home/shared/usage.ts index 040988d4..ed29c9b8 100644 --- a/packages/ui/src/pages/home/shared/usage.ts +++ b/packages/ui/src/pages/home/shared/usage.ts @@ -155,7 +155,7 @@ export function formatUsdCost(value: number | undefined): string { return new Intl.NumberFormat(undefined, { currency: "USD", maximumFractionDigits: normalized >= 100 ? 0 : 2, - minimumFractionDigits: 2, + minimumFractionDigits: normalized >= 100 ? 0 : 2, style: "currency" }).format(normalized); } diff --git a/packages/ui/src/pages/tray/shared.tsx b/packages/ui/src/pages/tray/shared.tsx index 1976d250..ef2cac4a 100644 --- a/packages/ui/src/pages/tray/shared.tsx +++ b/packages/ui/src/pages/tray/shared.tsx @@ -609,7 +609,7 @@ export function formatUsdCost(value: number | undefined): string { return new Intl.NumberFormat(undefined, { currency: "USD", maximumFractionDigits: normalized >= 100 ? 0 : 2, - minimumFractionDigits: 2, + minimumFractionDigits: normalized >= 100 ? 0 : 2, style: "currency" }).format(normalized); } diff --git a/tests/renderer/usage-format.test.ts b/tests/renderer/usage-format.test.ts index 43eca8f4..a6df6da5 100644 --- a/tests/renderer/usage-format.test.ts +++ b/tests/renderer/usage-format.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { formatLogTokenSummary } from "../../packages/ui/src/pages/home/shared/logs.ts"; -import { formatCompactNumber } from "../../packages/ui/src/pages/home/shared/usage.ts"; +import { formatCompactNumber, formatUsdCost as formatHomeUsdCost } from "../../packages/ui/src/pages/home/shared/usage.ts"; +import { formatUsdCost as formatTrayUsdCost } from "../../packages/ui/src/pages/tray/shared.tsx"; import type { RequestLogEntry } from "../../packages/core/src/contracts/app.ts"; test("formatCompactNumber can be bound to the UI language locale", () => { @@ -9,6 +10,13 @@ test("formatCompactNumber can be bound to the UI language locale", () => { assert.equal(formatCompactNumber(123456, "zh-CN"), "12.3万"); }); +test("formatUsdCost formats large values without conflicting fraction digits", () => { + assert.doesNotThrow(() => formatHomeUsdCost(100)); + assert.doesNotThrow(() => formatTrayUsdCost(100)); + assert.doesNotMatch(formatHomeUsdCost(123.45), /[.,]45/); + assert.doesNotMatch(formatTrayUsdCost(123.45), /[.,]45/); +}); + test("formatLogTokenSummary uses the provided locale for token counts", () => { const entry: RequestLogEntry = { cacheReadTokens: 0, From 2cc208d857551d6276f7807c6ec3e28a47a46f1b Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:33:39 +0000 Subject: [PATCH 13/21] Add MiniMax provider presets Co-Authored-By: Claude Opus 4.7 --- packages/core/src/providers/presets/index.ts | 3 ++ .../src/providers/presets/minimax/index.ts | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 packages/core/src/providers/presets/minimax/index.ts diff --git a/packages/core/src/providers/presets/index.ts b/packages/core/src/providers/presets/index.ts index 85c1c86c..7ea43061 100644 --- a/packages/core/src/providers/presets/index.ts +++ b/packages/core/src/providers/presets/index.ts @@ -5,6 +5,7 @@ import { code0ProviderPreset } from "@ccr/core/providers/presets/code0/index"; import { deepSeekProviderPreset } from "@ccr/core/providers/presets/deepseek/index"; import { geminiProviderPreset } from "@ccr/core/providers/presets/gemini/index"; import { kimiCodingProviderPreset } from "@ccr/core/providers/presets/kimi-coding/index"; +import { minimaxChinaProviderPreset, minimaxGlobalProviderPreset } from "@ccr/core/providers/presets/minimax/index"; import { mistralProviderPreset } from "@ccr/core/providers/presets/mistral/index"; import { moonshotChinaProviderPreset, moonshotGlobalProviderPreset } from "@ccr/core/providers/presets/moonshot/index"; import { openaiProviderPreset } from "@ccr/core/providers/presets/openai/index"; @@ -38,6 +39,8 @@ export const providerPresets: ProviderPreset[] = [ zhipuCnGeneralProviderPreset, zaiGlobalCodingProviderPreset, zaiGlobalGeneralProviderPreset, + minimaxGlobalProviderPreset, + minimaxChinaProviderPreset, mistralProviderPreset, moonshotChinaProviderPreset, moonshotGlobalProviderPreset, diff --git a/packages/core/src/providers/presets/minimax/index.ts b/packages/core/src/providers/presets/minimax/index.ts new file mode 100644 index 00000000..f01fee00 --- /dev/null +++ b/packages/core/src/providers/presets/minimax/index.ts @@ -0,0 +1,44 @@ +import type { ProviderPreset } from "@ccr/core/providers/presets/types"; +import { standardProviderAccountConfig } from "@ccr/core/providers/presets/types"; + +export const minimaxGlobalProviderPreset: ProviderPreset = { + account: standardProviderAccountConfig, + aliases: ["minimax", "minimax global"], + defaultModels: ["MiniMax-M3"], + endpoints: [ + { + baseUrl: "https://api.minimax.io/v1", + protocols: ["openai_chat_completions"], + websiteUrl: "https://platform.minimax.io/docs" + }, + { + baseUrl: "https://api.minimax.io/anthropic/v1", + protocols: ["anthropic_messages"], + websiteUrl: "https://platform.minimax.io/docs" + } + ], + id: "minimax-global", + name: "MiniMax (Global)", + websiteUrl: "https://platform.minimax.io/docs" +}; + +export const minimaxChinaProviderPreset: ProviderPreset = { + account: standardProviderAccountConfig, + aliases: ["minimax", "minimaxi", "minimax china"], + defaultModels: ["MiniMax-M3"], + endpoints: [ + { + baseUrl: "https://api.minimaxi.com/v1", + protocols: ["openai_chat_completions"], + websiteUrl: "https://platform.minimaxi.com/docs" + }, + { + baseUrl: "https://api.minimaxi.com/anthropic/v1", + protocols: ["anthropic_messages"], + websiteUrl: "https://platform.minimaxi.com/docs" + } + ], + id: "minimax-cn", + name: "MiniMax (China)", + websiteUrl: "https://platform.minimaxi.com/docs" +}; From 768baa8575a2af8f5e9588a62aa8062ad768d476 Mon Sep 17 00:00:00 2001 From: "Lim, Un-tiong" Date: Thu, 9 Jul 2026 18:15:22 +0800 Subject: [PATCH 14/21] fix: handle gateway client aborts during streaming --- packages/core/src/gateway/service.ts | 80 ++++++--- tests/main/gateway-client-disconnect.test.mjs | 155 ++++++++++++++++++ 2 files changed, 216 insertions(+), 19 deletions(-) create mode 100644 tests/main/gateway-client-disconnect.test.mjs diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index 5936e7aa..c2b37751 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -706,28 +706,28 @@ class GatewayService { let responseCompleted = false; let onClientDisconnect: (() => void) | undefined; let onResponseFinish: (() => void) | undefined; + const handleClientDisconnect = () => { + if (responseCompleted || response.writableEnded) { + return; + } + if (!clientDisconnected) { + clientDisconnected = true; + upstreamAbortController.abort(new Error(clientDisconnectMessage)); + } + onClientDisconnect?.(); + }; response.once("finish", () => { responseCompleted = true; onResponseFinish?.(); }); - response.once("close", () => { - if (responseCompleted || response.writableEnded) { - return; - } - clientDisconnected = true; - upstreamAbortController.abort(new Error(clientDisconnectMessage)); - onClientDisconnect?.(); - }); - response.on("error", (error) => { + response.once("close", handleClientDisconnect); + response.on("error", () => { // Client-side write failures (EPIPE / ECONNRESET when the client closes // mid-stream, common during tool execution) must not crash the main // process as an Uncaught Exception. Swallow them here; the close handler // above already records the disconnect via writeStreamLog. - if (!clientDisconnected) { - clientDisconnected = true; - upstreamAbortController.abort(new Error(clientDisconnectMessage)); - } + handleClientDisconnect(); }); const writeRequestLog = ( @@ -976,6 +976,11 @@ class GatewayService { this.config ); const upstreamResponse = upstreamResult.response; + if (clientDisconnected || upstreamAbortController.signal.aborted) { + await cancelResponseBody(upstreamResponse); + writeRequestLog(clientClosedRequestStatusCode, responseHeaders, "", false, clientDisconnectMessage); + return; + } if (codexApplyPatchBridgeActive) { responseHeaders.delete("content-length"); } @@ -989,6 +994,11 @@ class GatewayService { responseHeaders.delete("content-length"); } recordProviderCredentialOutcome(this.config, method, upstreamResult.attempt, upstreamResponse.status, responseHeaders); + if (clientDisconnected || response.destroyed) { + await cancelResponseBody(upstreamResponse); + writeRequestLog(clientClosedRequestStatusCode, responseHeaders, "", false, clientDisconnectMessage); + return; + } response.writeHead(upstreamResponse.status, Object.fromEntries(filteredResponseHeaders(responseHeaders))); if (!upstreamResponse.body) { if (shouldCaptureUsage) { @@ -1023,6 +1033,7 @@ class GatewayService { this.browserWebSearchMcpIntegration ) : patchedResponseBody; + const responseStreams = uniqueStreams([upstreamBody, patchedResponseBody, responseBody]); const sampler = createBodySampler(); const sseErrorDetector = createSseErrorDetector(responseHeaders.get("content-type") ?? undefined); let streamDetectedError: string | undefined; @@ -1034,7 +1045,7 @@ class GatewayService { } logRecorded = true; writeRequestLog( - upstreamResponse.status, + clientDisconnected ? clientClosedRequestStatusCode : upstreamResponse.status, responseHeaders, sampler.read(), sampler.isTruncated(), @@ -1043,13 +1054,21 @@ class GatewayService { }; onClientDisconnect = () => { writeStreamLog(clientDisconnectMessage); - responseBody.destroy(new Error(clientDisconnectMessage)); + responseBody.unpipe(response); + destroyResponseStreams(responseStreams); }; onResponseFinish = () => { if (upstreamStreamEnded) { writeStreamLog(); } }; + const onResponseStreamError = (error: Error) => { + streamDetectedError ??= sseErrorDetector.finish(); + writeStreamLog(clientDisconnected ? clientDisconnectMessage : formatError(error)); + }; + for (const stream of responseStreams) { + stream.on("error", onResponseStreamError); + } responseBody.on("data", (chunk) => { sampler.append(chunk); streamDetectedError ??= sseErrorDetector.append(chunk); @@ -1061,10 +1080,6 @@ class GatewayService { writeStreamLog(); } }); - responseBody.on("error", (error) => { - streamDetectedError ??= sseErrorDetector.finish(); - writeStreamLog(clientDisconnected ? clientDisconnectMessage : formatError(error)); - }); if (shouldCaptureUsage) { responseBody.once("end", () => { void recordGatewayUsageCapture({ @@ -1082,6 +1097,10 @@ class GatewayService { }); }); } + if (clientDisconnected || response.destroyed) { + onClientDisconnect(); + return; + } responseBody.pipe(response); } @@ -6205,6 +6224,29 @@ async function drainResponseBody(response: Response): Promise { } } +async function cancelResponseBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // The client already disconnected; best-effort upstream cleanup must not mask that expected path. + } +} + +function uniqueStreams(streams: Readable[]): Readable[] { + return [...new Set(streams)]; +} + +function destroyResponseStreams(streams: Readable[]): void { + for (const stream of streams) { + if (!stream.destroyed) { + // A downstream client close is an expected abort path. Destroying with + // an Error would emit another error event on Readable/Transform stages, + // and intermediate stages may not be the final responseBody listener. + stream.destroy(); + } + } +} + function parseJsonObjectSafe(buffer: Buffer | undefined): Record | undefined { if (!buffer || buffer.byteLength === 0) { return undefined; diff --git a/tests/main/gateway-client-disconnect.test.mjs b/tests/main/gateway-client-disconnect.test.mjs new file mode 100644 index 00000000..bdad5b92 --- /dev/null +++ b/tests/main/gateway-client-disconnect.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createDefaultAppConfig } from "../../packages/core/src/config/default-config.ts"; +import { gatewayService } from "../../packages/core/src/gateway/service.ts"; + +test("gateway treats downstream client aborts as expected stream cleanup", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-gateway-client-abort-test-")); + const uncaughtErrors = []; + const unhandledRejections = []; + const onUncaughtException = (error) => uncaughtErrors.push(error); + const onUnhandledRejection = (error) => unhandledRejections.push(error); + process.prependListener("uncaughtException", onUncaughtException); + process.prependListener("unhandledRejection", onUnhandledRejection); + let upstreamResponseClosed = false; + const patch = "*** Begin Patch\n*** Add File: foo.txt\n+hi\n*** End Patch\n"; + + const upstream = createServer((request, response) => { + request.resume(); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.write("event: response.output_text.delta\n"); + response.write(`data: ${JSON.stringify({ + item: { + arguments: JSON.stringify({ patch }), + call_id: "call_patch", + name: "virtual_apply_patch", + type: "function_call" + }, + type: "response.output_item.done" + })}\n\n`); + const interval = setInterval(() => { + response.write("event: response.output_text.delta\n"); + response.write(`data: ${JSON.stringify({ delta: "tick", type: "response.output_text.delta" })}\n\n`); + }, 20); + response.on("close", () => { + upstreamResponseClosed = true; + clearInterval(interval); + }); + }); + const gateway = createServer((request, response) => { + const requestPath = new URL(request.url ?? "/", "http://127.0.0.1").pathname; + void gatewayService.proxyRequest(request, response, requestPath).catch((error) => { + if (!response.headersSent) { + response.writeHead(502, { "content-type": "application/json" }); + } + if (!response.writableEnded) { + response.end(`${JSON.stringify({ error: { message: String(error?.message ?? error) } })}\n`); + } + }); + }); + + try { + await listen(upstream); + const upstreamPort = serverPort(upstream); + const config = createDefaultAppConfig({ generatedConfigFile: path.join(dir, "gateway.config.json") }); + config.APIKEY = "test-api-key"; + config.gateway.coreHost = "127.0.0.1"; + config.gateway.corePort = upstreamPort; + config.gateway.host = "127.0.0.1"; + config.gateway.port = 0; + gatewayService.updateConfig(config); + gatewayService.coreAuthToken = "test-core-auth-token"; + + await listen(gateway); + const gatewayUrl = `http://127.0.0.1:${serverPort(gateway)}/v1/responses`; + const controller = new AbortController(); + const response = await fetch(gatewayUrl, { + body: JSON.stringify({ + input: "hello", + model: "provider-deepseek::openai_chat_completions/deepseek-v4-flash", + stream: true, + tools: [{ type: "custom", name: "apply_patch", format: { type: "grammar", syntax: "lark", definition: "start: begin_patch" } }] + }), + headers: { + authorization: "Bearer test-api-key", + "content-type": "application/json", + "user-agent": "codex-test" + }, + method: "POST", + signal: controller.signal + }); + + const errorText = response.status === 200 ? "" : await response.text(); + assert.equal(response.status, 200, errorText); + const reader = response.body.getReader(); + const firstChunk = await reader.read(); + assert.equal(firstChunk.done, false); + const firstChunkText = new TextDecoder().decode(firstChunk.value); + assert.match(firstChunkText, /"type":"custom_tool_call"/, "expected the response to use the Codex apply_patch response transform"); + assert.match(firstChunkText, /"name":"apply_patch"/, "expected virtual_apply_patch to be rewritten back to apply_patch"); + + await reader.cancel("test downstream disconnect"); + controller.abort(); + await waitFor(() => upstreamResponseClosed, 1000); + + assert.deepEqual(uncaughtErrors.map((error) => error?.message ?? String(error)), []); + assert.deepEqual(unhandledRejections.map((error) => error?.message ?? String(error)), []); + } finally { + process.off("uncaughtException", onUncaughtException); + process.off("unhandledRejection", onUnhandledRejection); + await closeServer(gateway); + await closeServer(upstream); + await gatewayService.stop(); + rmSync(dir, { force: true, recursive: true }); + } +}); + +function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function serverPort(server) { + const address = server.address(); + assert.equal(typeof address, "object"); + assert.ok(address); + return address.port; +} + +function closeServer(server) { + return new Promise((resolve, reject) => { + if (!server.listening) { + resolve(); + return; + } + const timeout = setTimeout(() => server.closeAllConnections?.(), 1000); + server.close((error) => { + clearTimeout(timeout); + error ? reject(error) : resolve(); + }); + }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitFor(predicate, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) { + return; + } + await sleep(10); + } + assert.equal(predicate(), true); +} From 088ec97e7b24a5657bfc5990a4eb0e446ac89bf0 Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 9 Jul 2026 18:17:48 +0800 Subject: [PATCH 15/21] Relax protocol probe 400 validation handling --- packages/core/src/providers/probe.ts | 13 +------------ tests/main/provider-probe.test.mjs | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/core/src/providers/probe.ts b/packages/core/src/providers/probe.ts index 0a823467..1cf93873 100644 --- a/packages/core/src/providers/probe.ts +++ b/packages/core/src/providers/probe.ts @@ -1076,18 +1076,7 @@ function isProtocolSupported( if (/not found|unknown endpoint|unknown route|no route/.test(normalized)) { return false; } - if (protocol === "openai_responses") { - return /model|max_output|max output|input|required/.test(normalized); - } - if (protocol === "openai_chat_completions" || protocol === "anthropic_messages") { - return /model|max_tokens|messages|required/.test(normalized); - } - if (protocol === "gemini_generate_content") { - return /contents|generatecontentrequest|generationconfig/.test(normalized); - } - if (protocol === "gemini_interactions") { - return /model|input|required|interaction|generation_config|generationconfig|system_instruction/.test(normalized); - } + return true; } return false; diff --git a/tests/main/provider-probe.test.mjs b/tests/main/provider-probe.test.mjs index 9a650ee7..45054864 100644 --- a/tests/main/provider-probe.test.mjs +++ b/tests/main/provider-probe.test.mjs @@ -43,7 +43,7 @@ test("protocol support probe keeps auth-only fallback for unhinted endpoints", ( ); }); -test("protocol support probe treats Gemini contents validation as Gemini support only", () => { +test("protocol support probe treats HTTP 400 validation as protocol support", () => { const message = "HTTP 400: * GenerateContentRequest.contents: contents is not specified"; assert.equal( @@ -52,11 +52,11 @@ test("protocol support probe treats Gemini contents validation as Gemini support ); assert.equal( isProviderProtocolEndpointSupportedForProbe(400, message, "openai_chat_completions", ["openai_chat_completions"]), - false + true ); }); -test("protocol support probe treats Gemini Interactions input validation as Interactions support", () => { +test("protocol support probe treats HTTP 400 input validation as protocol support", () => { const message = "HTTP 400: Gemini Interactions request requires input."; assert.equal( @@ -65,6 +65,15 @@ test("protocol support probe treats Gemini Interactions input validation as Inte ); assert.equal( isProviderProtocolEndpointSupportedForProbe(400, message, "gemini_generate_content", ["gemini_generate_content"]), + true + ); +}); + +test("protocol support probe still rejects HTTP 400 route misses", () => { + const message = "HTTP 400: unknown route"; + + assert.equal( + isProviderProtocolEndpointSupportedForProbe(400, message, "openai_chat_completions", ["openai_chat_completions"]), false ); }); From 9163e306c07fd168b8f581782ed4f6f00d89ab07 Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 9 Jul 2026 18:23:09 +0800 Subject: [PATCH 16/21] Show provider icons in the provider list --- .../src/pages/home/components/providers.tsx | 38 ++++++------ .../ui/src/pages/home/shared/providers.ts | 10 ++++ tests/renderer/providers.test.ts | 60 +++++++++++++++++++ 3 files changed, 90 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index 3d62fa83..ec6a6c5e 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -12,7 +12,7 @@ import { providerAccountConnectorsTextWithNewApiUserBalanceTemplate, providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath, ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft, ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol, - providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl, + providerDisplayIcon, providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl, resolveProviderDeepLinkPreset, ShieldCheck, splitLines, splitModelTagInput, Switch, Textarea, translatedProviderProtocolLabel, translateOptions, translateProbeProtocolMessage, Trash2, uniqueProviderName, uniqueProviderProtocols, useAppErrorText, useAppText, useEffect, useMemo, useRef, useState, X, isPlainRecord @@ -111,24 +111,25 @@ export function ProvidersView({ accountSnapshots, addProvider, editProvider, not
- {visibleProviders.map(({ provider, index }) => { - const itemKey = providerListItemKey(provider, index); - const expanded = expandedProviders.has(itemKey); + {visibleProviders.map(({ provider, index }) => { + const itemKey = providerListItemKey(provider, index); + const expanded = expandedProviders.has(itemKey); const providerAccountSnapshots = accountSnapshotsByProvider.get(provider.name) ?? []; - return ( - -
toggleProvider(provider, index)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - toggleProvider(provider, index); - } - }} - role="button" - tabIndex={0} - > + const providerIconUrl = providerDisplayIcon(provider); + return ( + +
toggleProvider(provider, index)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggleProvider(provider, index); + } + }} + role="button" + tabIndex={0} + >
+
{provider.name || t("Unnamed")}
diff --git a/packages/ui/src/pages/home/shared/providers.ts b/packages/ui/src/pages/home/shared/providers.ts index afa0b24b..d528ab71 100644 --- a/packages/ui/src/pages/home/shared/providers.ts +++ b/packages/ui/src/pages/home/shared/providers.ts @@ -772,6 +772,16 @@ export function providerDeepLinkDisplayIcon(payload: ProviderDeepLinkPayload): s return presetIcon || payload.icon?.trim() || ""; } +export function providerDisplayIcon(provider: GatewayProviderConfig): string { + const icon = provider.icon?.trim(); + if (icon) { + return icon; + } + + const preset = findProviderPresetByBaseUrl(providerBaseUrl(provider)); + return preset ? providerPresetIconUrls[preset.id] ?? "" : ""; +} + export type ProviderDeepLinkCatalogModelsResolution = { modelDisplayNames?: Record; models: string[]; diff --git a/tests/renderer/providers.test.ts b/tests/renderer/providers.test.ts index 8931dcf2..78e944ce 100644 --- a/tests/renderer/providers.test.ts +++ b/tests/renderer/providers.test.ts @@ -1,15 +1,23 @@ import assert from "node:assert/strict"; import test from "node:test"; +import * as React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; import { newApiKeyUsageAccountConfig } from "../../packages/core/src/providers/new-api.ts"; import { geminiProviderPreset } from "../../packages/core/src/providers/presets/gemini/index.ts"; +import { ProvidersView } from "../../packages/ui/src/pages/home/components/providers.tsx"; import { applyProviderProbeResult, createProviderDraft, + providerDisplayIcon, providerAccountConnectorsTextWithNewApiUserBalanceTemplate, + providerPresetIconUrls, providerProtocolOptions, providerProbeCandidates, setProviderPresets } from "../../packages/ui/src/pages/home/shared/index.tsx"; +import { installBrowserGlobals } from "./fixtures.ts"; + +installBrowserGlobals(); test("Gemini preset keeps full protocol probing candidates", () => { setProviderPresets([geminiProviderPreset]); @@ -112,6 +120,58 @@ test("provider probe result applies detected New API key quota account connector assert.equal(connectors[0].mapping.meters[0].remaining, "$.data.total_available"); }); +test("provider display icon prefers custom icons and falls back to preset icons", () => { + setProviderPresets([geminiProviderPreset]); + + assert.equal( + providerDisplayIcon({ + api_base_url: "https://custom.example/v1", + icon: "https://custom.example/icon.png", + models: [], + name: "Custom Provider", + type: "openai_chat_completions" + }), + "https://custom.example/icon.png" + ); + assert.equal( + providerDisplayIcon({ + api_base_url: "https://generativelanguage.googleapis.com", + models: [], + name: "Google Gemini", + type: "gemini_generate_content" + }), + providerPresetIconUrls.gemini + ); +}); + +test("ProvidersView renders configured provider icons in the list", () => { + const iconUrl = "https://custom.example/icon.png"; + const html = renderToStaticMarkup( + React.createElement(ProvidersView, { + accountSnapshots: [], + addProvider: () => undefined, + editProvider: () => undefined, + notify: () => undefined, + providers: [ + { + index: 0, + provider: { + api_base_url: "https://custom.example/v1", + icon: iconUrl, + models: ["custom-model"], + name: "Custom Provider", + type: "openai_chat_completions" + } + } + ], + removeProvider: () => undefined + }) + ); + + assert.match(html, /Custom Provider/); + assert.match(html, /src="https:\/\/custom\.example\/icon\.png"/); +}); + test("New API user balance template adds configurable user self connector", () => { const account = newApiKeyUsageAccountConfig("https://gateway.example/v1"); const text = providerAccountConnectorsTextWithNewApiUserBalanceTemplate( From 15f67ab8d8bc5ae81218cbfaf37a25d2734126db Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 9 Jul 2026 19:26:17 +0800 Subject: [PATCH 17/21] Add Kimi sponsorship banners to README docs --- README.md | 36 ++++++++++++++++++++++++++++++++++-- README_zh.md | 36 ++++++++++++++++++++++++++++++++++-- packages/cli/README.md | 36 ++++++++++++++++++++++++++++++++++-- packages/cli/README_zh.md | 36 ++++++++++++++++++++++++++++++++++-- 4 files changed, 136 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 70f73bb6..10f4c32d 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,44 @@ Documentation

+
+ + + + + + + + +
+ + Kimi K2.7 Code sponsor banner + +
+ + Kimi Code Subscription +  ·  + API Global +  ·  + API China + +
+

+ Thanks to Kimi for sponsoring this project! Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard. +

+

+ CCR already supports Kimi. Visit the Kimi Open Platform (中文站 | Global) to try the API, or explore the cost-effective Coding Plan. +

+
+ +
+ +Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use. +

Claude Code Router Desktop screenshot

-Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use. - ## Why Use CCR - Use one local endpoint for multiple agent tools instead of configuring every client separately. diff --git a/README_zh.md b/README_zh.md index 710c17a8..183df2d9 100644 --- a/README_zh.md +++ b/README_zh.md @@ -9,12 +9,44 @@ 文档

+
+ + + + + + + + +
+ + Kimi K2.7 Code 赞助横幅 + +
+ + Kimi Code 订阅 +  ·  + API 中文站 +  ·  + API Global + +
+

+ 感谢 Kimi 赞助本项目!Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。 +

+

+ CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(中文站Global)体验 API,或了解高性价比 Coding Plan 套餐。 +

+
+ +
+ +Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 +

Claude Code Router Desktop 项目截图

-Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 - ## 为什么使用 CCR - 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。 diff --git a/packages/cli/README.md b/packages/cli/README.md index 3f5ce1e7..1862494b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -8,12 +8,44 @@ Documentation

+
+ + + + + + + + +
+ + Kimi K2.7 Code sponsor banner + +
+ + Kimi Code Subscription +  ·  + API Global +  ·  + API China + +
+

+ Thanks to Kimi for sponsoring this project! Kimi K2.7 Code is an open-source, coding-focused agentic model developed by Moonshot AI, with substantial gains on real-world long-horizon coding tasks and higher end-to-end success across complex software engineering workflows. It also cuts thinking-token usage by approximately 30% compared with K2.6. Inside CCR, Kimi ships as built-in provider presets: import the pay-as-you-go API or the Kimi Code subscription in one click and route your coding agent's requests to Kimi, the subscription endpoint passes straight through natively with no protocol conversion, API endpoints are adapted automatically, and your balance and subscription usage show up right in the CCR dashboard. +

+

+ CCR already supports Kimi. Visit the Kimi Open Platform (中文站 | Global) to try the API, or explore the cost-effective Coding Plan. +

+
+ +
+ +Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use. +

Claude Code Router Desktop screenshot

-Claude Code Router Desktop is a local gateway and desktop control panel for routing agent requests from Claude Code, Codex, ZCode, and compatible clients to the model provider you actually want to use. - ## Why Use CCR - Use one local endpoint for multiple agent tools instead of configuring every client separately. diff --git a/packages/cli/README_zh.md b/packages/cli/README_zh.md index cd791618..959c2393 100644 --- a/packages/cli/README_zh.md +++ b/packages/cli/README_zh.md @@ -8,12 +8,44 @@ 文档

+
+ + + + + + + + +
+ + Kimi K2.7 Code 赞助横幅 + +
+ + Kimi Code 订阅 +  ·  + API 中文站 +  ·  + API Global + +
+

+ 感谢 Kimi 赞助本项目!Kimi K2.7 Code 是 Moonshot AI 推出的编程专用开源智能体模型,在真实长程编程与复杂软件工程工作流中显著提升端到端任务成功率,同时优化推理效率,相比 K2.6 平均减少约 30% 的推理 token 消耗。在 CCR 中,Kimi 已作为内置供应商预设开箱即用:无论按量付费 API 还是 Kimi Code 订阅,一键导入即可把你的编程 Agent 请求路由到 Kimi,订阅端点原生直通、无需协议转换,API 端点自动适配,账户余额与订阅用量也能直接在 CCR 面板中查看。 +

+

+ CCR 已内置 Kimi 供应商预设。前往 Kimi 开放平台(中文站Global)体验 API,或了解高性价比 Coding Plan 套餐。 +

+
+ +
+ +Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 +

Claude Code Router Desktop 项目截图

-Claude Code Router Desktop 是一个本地网关和桌面控制台,用来把 Claude Code、Codex、ZCode 以及兼容客户端的 Agent 请求路由到你真正想使用的模型服务。 - ## 为什么使用 CCR - 用一个本地入口连接多个 Agent 工具,不需要在每个客户端里重复配置 Provider。 From 4cf0c2468c21b6727b1eaa5a8e7daed3abec96c1 Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 9 Jul 2026 19:30:31 +0800 Subject: [PATCH 18/21] Retry fallback after network errors and HTTP failures --- .../content/docs/en/configuration/routing.md | 2 +- .../content/docs/zh/configuration/routing.md | 2 +- packages/core/src/gateway/service.ts | 37 ++++++++++++++----- tests/main/router-builtins.test.mjs | 18 ++++++++- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/docs/src/content/docs/en/configuration/routing.md b/docs/src/content/docs/en/configuration/routing.md index a378c56f..f21a5219 100644 --- a/docs/src/content/docs/en/configuration/routing.md +++ b/docs/src/content/docs/en/configuration/routing.md @@ -162,7 +162,7 @@ Network errors move to the next attempt. Status-code fallback depends on the mod | Retry | `408`, `409`, `429`, `5xx` | | Fallback targets | Any `4xx` or `5xx` | -For `429` rate-limit responses, CCR waits before the next attempt. It honors `Retry-After` when the upstream provides it; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt. +Before moving to the next attempt, CCR waits for every fallback-triggering failure, including network errors. It honors a positive `Retry-After` header when the upstream provides one; otherwise it uses exponential backoff starting at 1 second and capped at 30 seconds per attempt. **Fallback targets** also switches on `4xx` because model-not-found, auth, or provider-side rejection errors may only affect the current target. If the fallback model works, the request can still succeed. diff --git a/docs/src/content/docs/zh/configuration/routing.md b/docs/src/content/docs/zh/configuration/routing.md index c00f069d..332eda91 100644 --- a/docs/src/content/docs/zh/configuration/routing.md +++ b/docs/src/content/docs/zh/configuration/routing.md @@ -162,7 +162,7 @@ Fallback 处理请求失败后的降级。第一次选模型由路由完成; | 继续重试 | `408`、`409`、`429`、`5xx` | | 失败降级目标 | 任意 `4xx` 或 `5xx` | -对于 `429` 限流响应,CCR 会在下一次尝试前等待。上游提供 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。 +进入下一次尝试前,CCR 会对每个触发 Fallback 的失败进行等待,包括网络错误。上游提供正数 `Retry-After` 时会优先遵守;否则使用从 1 秒开始、单次最多 30 秒的指数退避。 **失败降级目标** 对 `4xx` 也会切换,是因为模型不存在、鉴权或供应商侧拒绝等错误可能只影响当前目标。切换后如果备用模型可用,请求仍然可以成功。 diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index 13f78a6e..99dcaafa 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -5681,7 +5681,7 @@ async function fetchUpstreamWithFallback(input: { }); if (hasNextAttempt && shouldFallbackAfterStatus(response.status, fallbackMode)) { - const delayMs = retryDelayAfterStatus(response.status, response, failedAttempts.length); + const delayMs = retryDelayAfterStatus(response.status, response.headers, failedAttempts.length); failedAttempts.push({ credentialChain: attempt.credentialChain, credentialIds: attempt.credentialIds, @@ -5704,10 +5704,13 @@ async function fetchUpstreamWithFallback(input: { }; } catch (error) { const message = formatError(error); + const delayMs = hasNextAttempt && !input.signal?.aborted + ? retryDelayAfterNetworkError(failedAttempts.length) + : 0; failedAttempts.push({ credentialChain: attempt.credentialChain, credentialIds: attempt.credentialIds, - delayMs: 0, + delayMs, error: message, model: attempt.model }); @@ -5719,6 +5722,9 @@ async function fetchUpstreamWithFallback(input: { }); } if (hasNextAttempt) { + if (delayMs > 0) { + await delay(delayMs); + } continue; } throw new UpstreamRequestError(message, { @@ -6170,17 +6176,30 @@ function shouldFallbackAfterStatus(statusCode: number, mode: RouterFallbackMode) return false; } -function retryDelayAfterStatus(statusCode: number, response: Response, failedAttemptIndex: number): number { - if (statusCode !== 429) { - return 0; - } - const retryAfterMs = parseRetryAfterHeaderMs(response.headers.get("retry-after")); - if (retryAfterMs !== undefined) { - return clampNumber(retryAfterMs, 0, upstreamRetryAfterMaxMs); +function retryDelayAfterStatus(_statusCode: number, headers: Headers, failedAttemptIndex: number): number { + const retryAfterMs = parseRetryAfterHeaderMs(headers.get("retry-after")); + if (retryAfterMs !== undefined && retryAfterMs > 0) { + return clampNumber(retryAfterMs, 1, upstreamRetryAfterMaxMs); } return exponentialRetryBackoffMs(failedAttemptIndex); } +function retryDelayAfterNetworkError(failedAttemptIndex: number): number { + return exponentialRetryBackoffMs(failedAttemptIndex); +} + +export function fallbackRetryDelayAfterStatusForTest(input: { failedAttemptIndex?: number; retryAfter?: string | null; statusCode: number }): number { + const headers = new Headers(); + if (input.retryAfter !== undefined && input.retryAfter !== null) { + headers.set("retry-after", input.retryAfter); + } + return retryDelayAfterStatus(input.statusCode, headers, input.failedAttemptIndex ?? 0); +} + +export function fallbackRetryDelayAfterNetworkErrorForTest(failedAttemptIndex = 0): number { + return retryDelayAfterNetworkError(failedAttemptIndex); +} + function parseRetryAfterHeaderMs(value: string | null): number | undefined { const trimmed = value?.trim(); if (!trimmed) { diff --git a/tests/main/router-builtins.test.mjs b/tests/main/router-builtins.test.mjs index 1928fc5b..b461f525 100644 --- a/tests/main/router-builtins.test.mjs +++ b/tests/main/router-builtins.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; import { ClaudeCodeRouterPlugin } from "../../packages/core/src/gateway/claude-code-router-plugin.ts"; -import { prepareGatewayUpstreamAttemptForTest } from "../../packages/core/src/gateway/service.ts"; +import { + fallbackRetryDelayAfterNetworkErrorForTest, + fallbackRetryDelayAfterStatusForTest, + prepareGatewayUpstreamAttemptForTest +} from "../../packages/core/src/gateway/service.ts"; function createRouterPlugin(options = {}) { const agent = options.agent ?? "claude-code"; @@ -42,6 +46,18 @@ function createRouterPlugin(options = {}) { }); } +test("fallback retry delay backs off retryable HTTP statuses", () => { + assert.equal(fallbackRetryDelayAfterStatusForTest({ statusCode: 503 }), 1000); + assert.equal(fallbackRetryDelayAfterStatusForTest({ failedAttemptIndex: 1, statusCode: 408 }), 2000); + assert.equal(fallbackRetryDelayAfterStatusForTest({ retryAfter: "3", statusCode: 429 }), 3000); + assert.equal(fallbackRetryDelayAfterStatusForTest({ retryAfter: "0", statusCode: 429 }), 1000); +}); + +test("fallback retry delay backs off network errors", () => { + assert.equal(fallbackRetryDelayAfterNetworkErrorForTest(), 1000); + assert.equal(fallbackRetryDelayAfterNetworkErrorForTest(2), 4000); +}); + function createIssue1480UserConfig() { return { APIKEY: "gateway-key", From 81fbae63a60f43e67953006240d9350bc96dc613 Mon Sep 17 00:00:00 2001 From: musistudio Date: Thu, 9 Jul 2026 20:57:35 +0800 Subject: [PATCH 19/21] Refine provider protocol and base URL handling --- packages/core/src/contracts/app.ts | 1 + packages/core/src/providers/presets/utils.ts | 37 ++- packages/core/src/providers/probe.ts | 19 +- packages/ui/src/pages/home/App.tsx | 28 +- .../src/pages/home/components/providers.tsx | 34 +-- .../ui/src/pages/home/shared/providers.ts | 97 ++++++- tests/main/provider-preset-utils.test.mjs | 42 +++ tests/renderer/providers.test.ts | 260 ++++++++++++++++++ 8 files changed, 450 insertions(+), 68 deletions(-) diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 0dbfa566..60e389e7 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -420,6 +420,7 @@ export type GatewayProviderProbeRequest = { export type GatewayProviderProbeCandidate = { baseUrl: string; + declaredProtocols?: GatewayProviderProtocol[]; label?: string; protocols: GatewayProviderProtocol[]; source: "custom" | "preset"; diff --git a/packages/core/src/providers/presets/utils.ts b/packages/core/src/providers/presets/utils.ts index 362e19d3..9a46da5c 100644 --- a/packages/core/src/providers/presets/utils.ts +++ b/packages/core/src/providers/presets/utils.ts @@ -49,14 +49,14 @@ export function providerIdentitySafetyIssueInList( } const selectedPreset = findProviderPresetInList(presets, input.presetId); - if (selectedPreset && !providerPresetMatchesBaseUrl(selectedPreset, input.baseUrl)) { + if (selectedPreset && !providerBaseUrlCanReceiveOfficialKey(selectedPreset, input.baseUrl)) { return createProviderIdentitySafetyIssue(selectedPreset); } const namedPresets = findProviderPresetsByIdentity(presets, input.name); if ( namedPresets.length > 0 && - !namedPresets.some((preset) => providerPresetMatchesBaseUrl(preset, input.baseUrl)) + !namedPresets.some((preset) => providerBaseUrlCanReceiveOfficialKey(preset, input.baseUrl)) ) { return createProviderIdentitySafetyIssue(namedPresets[0]); } @@ -128,15 +128,30 @@ function findProviderPresetsByIdentity(presets: ProviderPreset[], name: string | return []; } - return presets.filter((preset) => { - const identities = [preset.id, preset.name, ...preset.aliases] - .map(normalizeProviderIdentityText) - .filter(Boolean); - return identities.some((identity) => - normalizedName === identity || - (identity.length >= 4 && normalizedName.includes(identity)) - ); - }); + return presets + .map((preset) => ({ + preset, + score: providerPresetIdentityMatchScore(preset, normalizedName) + })) + .filter((item) => item.score > 0) + .sort((left, right) => right.score - left.score) + .map((item) => item.preset); +} + +function providerPresetIdentityMatchScore(preset: ProviderPreset, normalizedName: string): number { + const identities = [preset.id, preset.name, ...preset.aliases] + .map(normalizeProviderIdentityText) + .filter(Boolean); + + return Math.max(0, ...identities.map((identity) => { + if (normalizedName === identity) { + return 10_000 + identity.length; + } + if (identity.length >= 4 && normalizedName.includes(identity)) { + return identity.length; + } + return 0; + })); } function createProviderIdentitySafetyIssue(preset: ProviderPreset): ProviderIdentitySafetyIssue { diff --git a/packages/core/src/providers/probe.ts b/packages/core/src/providers/probe.ts index 1cf93873..94b4f5a4 100644 --- a/packages/core/src/providers/probe.ts +++ b/packages/core/src/providers/probe.ts @@ -339,21 +339,30 @@ function providerProbeCapabilities( probe: GatewayProviderProbeResult ): GatewayProviderCapability[] { const detectedCapabilities = mergeProviderCapabilities(probe.capabilities ?? []); - if (detectedCapabilities.length > 0) { - return detectedCapabilities; - } + const presetCapabilities = providerProbePresetCapabilities(candidate); + return mergeProviderCapabilities(detectedCapabilities, presetCapabilities); +} +function providerProbePresetCapabilities(candidate: GatewayProviderProbeCandidate): GatewayProviderCapability[] { if (candidate.source !== "preset") { return []; } - return candidate.protocols.map((type) => ({ - baseUrl: probe.normalizedBaseUrl || candidate.baseUrl, + return uniqueProtocols(candidate.declaredProtocols ?? []).map((type) => ({ + baseUrl: providerProbeCandidateBaseUrlForProtocol(candidate.baseUrl, type), source: "preset" as const, type })); } +function providerProbeCandidateBaseUrlForProtocol(baseUrl: string, protocol: GatewayProviderProtocol): string { + try { + return providerBaseUrlForProtocol(parseProviderBaseUrl(baseUrl), protocol); + } catch { + return baseUrl.trim(); + } +} + function mergeProviderCapabilities(...groups: GatewayProviderCapability[][]): GatewayProviderCapability[] { const seen = new Set(); const capabilities: GatewayProviderCapability[] = []; diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index 69db5c79..f6319309 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -17,7 +17,7 @@ import { isCursorProxyPluginConfig, isMacPlatform, isPlainRecord, isProfileDraftSubmittable, isProviderNameDuplicate, isProviderProbeCandidateReady, isTraySupportedPlatform, isRoutingRewriteDraftRowValid, - LayoutGroup, mergeModelDisplayNames, mergeProviderCapabilities, mergeProviderModelLists, modelDescriptionsForModels, modelDisplayNamesForModels, + LayoutGroup, mergeModelDisplayNames, mergeProviderModelLists, modelDescriptionsForModels, modelDisplayNamesForModels, navigation, NavigationId, normalizeApiKeys, normalizeBotGatewaySavedConfigs, normalizeConfig, normalizeLanguagePreference, normalizeObservabilityConfig, normalizeOverviewWidgets, normalizeProfileItem, normalizeProfileScope, normalizeProviderBaseUrl, normalizeRouterBuiltInRules, normalizeRouterFallbackConfig, normalizeThemePreference, normalizeToolHubConfig, normalizeTrayBalanceProgressConfig, normalizeTrayIconPreference, normalizeTrayWidgets, normalizeTrayWindowModules, normalizeVirtualModelDraftPatch, numberValue, OnboardingReadinessOptions, OnboardingStepId, onboardingStepOrder, @@ -26,7 +26,7 @@ import { persistLanguagePreference, PluginMarketplaceEntry, PluginRoutingConfigTarget, pluginSettingsConfigFromDraft, PluginSettingsDraft, presetCapabilitiesFromDraft, probeProviderCandidates, probeProviderDeepLinkPayload, profileAgentLabel, profileEnvRowsForAgent, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, profileConfigFromDraft, providerAccountApiKeySafetyIssue, profileOpenCommandFallback, profileOpenSurfaces, ProviderAccountSnapshot, providerApiKeySafetyIssue, ProviderConnectivityCheckReport, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerIdentitySafetyIssue, providerProbeCandidates, - providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage, + providerCapabilitiesForProtocols, providerGlobalBaseUrlForProbe, providerProbeCandidatesApiKeySafetyIssue, providerProbeHasSupportedProtocol, providerProbeInputKey, providerSelectableProtocolsFromProbe, ProxyCertificateStatus, ProxyNetworkSnapshot, proxyRestartMessage, ProxyStatus, readLanguagePreference, RequestLogListFilter, RequestLogPage, ResolvedLanguage, ResolvedTheme, resolvePluginInstallPlan, resolveProviderDeepLinkCatalogModels, RouterRule, ServerActionBusy, SettingsPageId, routingRewriteFromDraftRow, setProviderPresets, splitLines, translateAppErrorMessage, translateProxyCertificateMessage, translateText, TrayBalanceProgressConfig, TrayWidgetConfig, @@ -1368,8 +1368,6 @@ function App() { setProviderProbeError(translateAppErrorMessage(copy, credentials)); return false; } - const fallbackProtocol = probe?.detectedProtocol ?? providerDraft.protocol; - const fallbackBaseUrl = probe?.normalizedBaseUrl || providerDraft.baseUrl; const selectableProtocols = providerSelectableProtocolsFromProbe(probe); const selectedProtocols = providerDraft.selectedProtocols.length > 0 ? providerDraft.selectedProtocols.filter((protocol) => !probe || selectableProtocols.includes(protocol)) @@ -1379,25 +1377,19 @@ function App() { return false; } - const protocolsToSave = selectedProtocols.length > 0 ? selectedProtocols : [fallbackProtocol]; + const protocolsToSave = selectedProtocols.length > 0 ? selectedProtocols : [probe?.detectedProtocol ?? providerDraft.protocol]; + const fallbackProtocol = protocolsToSave.includes(providerDraft.protocol) + ? providerDraft.protocol + : protocolsToSave[0] ?? probe?.detectedProtocol ?? providerDraft.protocol; + const fallbackBaseUrl = providerGlobalBaseUrlForProbe(providerDraft.baseUrl, probe, protocolsToSave); const modelDescriptions = modelDescriptionsForModels(providerDraft.modelDescriptions, models); const modelDisplayNames = modelDisplayNamesForModels(providerDraft.modelDisplayNames, models); - const selectedProtocolSet = new Set(protocolsToSave); - const capabilityCandidates = mergeProviderCapabilities( - presetCapabilitiesFromDraft(providerDraft), - probe?.capabilities ?? [], - protocolsToSave.map((type) => ({ - baseUrl: fallbackBaseUrl, - source: probe?.detectedProtocol ? ("detected" as const) : ("preset" as const), - type - })) - ); - const capabilities = capabilityCandidates.filter((capability) => selectedProtocolSet.has(capability.type)); + const capabilities = providerCapabilitiesForProtocols(providerDraft.baseUrl, protocolsToSave, probe, presetCapabilitiesFromDraft(providerDraft)); const primaryCapability = capabilities.find((capability) => capability.type === fallbackProtocol) ?? capabilities[0]; const protocol = primaryCapability?.type ?? fallbackProtocol; - const baseUrl = primaryCapability?.baseUrl ?? fallbackBaseUrl; + const baseUrl = fallbackBaseUrl; const keySafetyIssue = providerApiKeySafetyIssue({ apiKey: providerDraft.apiKey, @@ -1443,7 +1435,7 @@ function App() { } const provider: GatewayProviderConfig = { - api_base_url: normalizeProviderBaseUrl(baseUrl, protocol), + api_base_url: normalizeProviderBaseUrl(baseUrl), api_key: providerDraft.apiKey.trim(), capabilities: capabilities.length > 0 ? capabilities : undefined, account: accountConfig, diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index ec6a6c5e..2b7765b3 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -11,8 +11,8 @@ import { providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountSnapshot, providerAccountConnectorsTextWithNewApiUserBalanceTemplate, providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath, ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft, - ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol, - providerDisplayIcon, providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl, + ProviderConnectivityCheckReport, providerCapabilityBaseUrlForProtocol, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol, + providerDisplayIcon, providerGlobalBaseUrlForProbe, providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl, resolveProviderDeepLinkPreset, ShieldCheck, splitLines, splitModelTagInput, Switch, Textarea, translatedProviderProtocolLabel, translateOptions, translateProbeProtocolMessage, Trash2, uniqueProviderName, uniqueProviderProtocols, useAppErrorText, useAppText, useEffect, useMemo, useRef, useState, X, isPlainRecord @@ -1366,8 +1366,11 @@ export function AddProviderForm({ const customEndpoint = draft.presetId === customProviderPresetId; const importMode = Boolean(importProvider); const showBaseUrl = customEndpoint || mode === "edit"; - const detectedProtocol = probe?.detectedProtocol ?? draft.protocol; - const detectedBaseUrl = probe?.normalizedBaseUrl || draft.baseUrl; + const selectedDisplayProtocols = uniqueProviderProtocols(draft.selectedProtocols); + const detectedProtocol = selectedDisplayProtocols.length === 1 + ? selectedDisplayProtocols[0] + : probe?.detectedProtocol ?? draft.protocol; + const detectedBaseUrl = providerCapabilityBaseUrlForProtocol(draft.baseUrl, detectedProtocol, probe); const safetyIssue = providerDraftSafetyIssue(draft, detectedBaseUrl); const localAgentImport = draft.providerPlugins.length > 0; const providerPresetOptions = [ @@ -1697,17 +1700,6 @@ export function AddProviderForm({ {advancedOpen ? (
- {selectedPreset && !customEndpoint && mode === "add" ? ( - - onChange({ baseUrl: event.target.value }, true)} /> - - ) : null} - - - - - - {protocolProbeRows.map((item) => { - const selectable = item.supported && selectableProtocols.includes(item.protocol); + const available = item.supported || selectableProtocols.includes(item.protocol); + const selectable = available && selectableProtocols.includes(item.protocol); const checked = selectable && draft.selectedProtocols.includes(item.protocol); const itemKey = `${item.protocol}-${item.endpoint}`; return ( @@ -1744,8 +1737,8 @@ export function AddProviderForm({ }} /> {translatedProviderProtocolLabel(item.protocol, t)} - - {item.supported ? t("Available") : t("Unavailable")} + + {available ? t("Available") : t("Unavailable")}