diff --git a/packages/ui/src/pages/home/shared/providers.ts b/packages/ui/src/pages/home/shared/providers.ts
index afa0b24..9d5cafc 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[];
@@ -878,8 +888,8 @@ export function createProviderConfigFromDeepLink(
providers: GatewayProviderConfig[],
probe: GatewayProviderProbeResult | undefined
): GatewayProviderConfig {
- const protocol = probe?.detectedProtocol ?? payload.protocol ?? "openai_chat_completions";
- const baseUrl = probe?.normalizedBaseUrl || payload.baseUrl;
+ const protocol = payload.protocol ?? probe?.detectedProtocol ?? "openai_chat_completions";
+ const baseUrl = providerGlobalBaseUrlForProbe(payload.baseUrl, probe, [protocol]);
const apiKey = payload.apiKey?.trim() || "";
const models = apiKey && probe?.models.length
? mergeProviderModelLists(probe.models)
@@ -910,14 +920,11 @@ export function createProviderConfigFromDeepLink(
throw new Error(accountKeySafetyIssue.message);
}
- const capabilities = mergeProviderCapabilities(
- probe?.capabilities ?? [],
- protocol && baseUrl ? [{ baseUrl, source: probe?.detectedProtocol ? "detected" : "preset", type: protocol }] : []
- );
+ const capabilities = providerCapabilitiesForProtocols(payload.baseUrl, [protocol], probe);
return {
account: cloneProviderAccountConfig(account),
- api_base_url: normalizeProviderBaseUrl(baseUrl, protocol),
+ api_base_url: normalizeProviderBaseUrl(baseUrl),
api_key: apiKey,
capabilities: capabilities.length > 0 ? capabilities : undefined,
icon: payload.icon?.trim() || undefined,
@@ -1489,8 +1496,13 @@ export function providerUsageFieldPatch(target: ProviderUsageFieldTarget, path:
export function createProviderInstallLinkFromDraft(draft: AddProviderDraft, probe: GatewayProviderProbeResult | undefined): string {
const providerName = draft.name.trim();
- const baseUrl = (probe?.normalizedBaseUrl || draft.baseUrl).trim();
- const protocol = probe?.detectedProtocol ?? draft.protocol;
+ const selectedProtocols = uniqueProviderProtocols(draft.selectedProtocols);
+ const protocol = selectedProtocols.includes(draft.protocol)
+ ? draft.protocol
+ : selectedProtocols.length === 1
+ ? selectedProtocols[0]
+ : probe?.detectedProtocol ?? draft.protocol;
+ const baseUrl = providerGlobalBaseUrlForProbe(draft.baseUrl, probe, selectedProtocols.length > 0 ? selectedProtocols : [protocol]);
const models = mergeProviderModelLists(draft.selectedModels, splitLines(draft.modelsText));
if (!providerName || !baseUrl) {
return "Provider name and Base URL are required.";
@@ -1697,6 +1709,7 @@ export function providerProbeCandidates(draft: AddProviderDraft): ProviderProbeC
if (preset) {
return preset.endpoints.map((endpoint) => ({
...endpoint,
+ declaredProtocols: endpoint.protocols,
protocols,
source: "preset"
}));
@@ -1786,7 +1799,10 @@ export function providerSelectableProtocolsFromProbe(probe: GatewayProviderProbe
return [];
}
- return uniqueProviderProtocols(probe.protocols.filter((item) => item.supported).map((item) => item.protocol));
+ return uniqueProviderProtocols([
+ ...probe.protocols.filter((item) => item.supported).map((item) => item.protocol),
+ ...(probe.capabilities ?? []).map((capability) => capability.type)
+ ]);
}
export function selectedProviderProtocolsFromCapabilities(
@@ -1877,17 +1893,80 @@ export function mergeProviderCapabilities(...groups: GatewayProviderCapability[]
return capabilities;
}
+export function providerGlobalBaseUrlForProbe(
+ inputBaseUrl: string,
+ _probe: GatewayProviderProbeResult | undefined,
+ _protocols: GatewayProviderProtocol[] = []
+): string {
+ return normalizeProviderBaseUrl(inputBaseUrl) || inputBaseUrl.trim();
+}
+
+export function providerCapabilityBaseUrlForProtocol(
+ inputBaseUrl: string,
+ protocol: GatewayProviderProtocol,
+ probe: GatewayProviderProbeResult | undefined
+): string {
+ const detectedCapability = (probe?.capabilities ?? []).find((capability) =>
+ capability.type === protocol && capability.baseUrl.trim()
+ );
+ if (detectedCapability) {
+ return detectedCapability.baseUrl.trim();
+ }
+
+ if (probe?.detectedProtocol === protocol && probe.normalizedBaseUrl.trim()) {
+ return probe.normalizedBaseUrl.trim();
+ }
+
+ return normalizeProviderBaseUrl(inputBaseUrl, protocol) || normalizeProviderBaseUrl(inputBaseUrl) || inputBaseUrl.trim();
+}
+
+export function providerCapabilitiesForProtocols(
+ inputBaseUrl: string,
+ protocols: GatewayProviderProtocol[],
+ probe: GatewayProviderProbeResult | undefined,
+ presetCapabilities: GatewayProviderCapability[] = []
+): GatewayProviderCapability[] {
+ const detectedCapabilities = probe?.capabilities ?? [];
+ const selectedCapabilities = uniqueProviderProtocols(protocols)
+ .map((type) => {
+ const capability =
+ detectedCapabilities.find((item) => item.type === type && item.baseUrl.trim()) ??
+ presetCapabilities.find((item) => item.type === type && item.baseUrl.trim());
+ if (capability) {
+ return capability;
+ }
+
+ const baseUrl = providerCapabilityBaseUrlForProtocol(inputBaseUrl, type, probe);
+ return baseUrl
+ ? {
+ baseUrl,
+ source: probe?.detectedProtocol ? ("detected" as const) : ("preset" as const),
+ type
+ }
+ : undefined;
+ })
+ .filter((item): item is GatewayProviderCapability => Boolean(item));
+
+ return mergeProviderCapabilities(selectedCapabilities);
+}
+
export function applyProviderProbeResult(draft: AddProviderDraft, probe: GatewayProviderProbeResult): AddProviderDraft {
- const protocol = probe.detectedProtocol ?? draft.protocol;
- const selectedProtocols = selectedProviderProtocolsForProbe(draft.selectedProtocols, probe, protocol, draft.presetId);
+ const detectedProtocol = probe.detectedProtocol ?? draft.protocol;
+ const selectedProtocols = selectedProviderProtocolsForProbe(draft.selectedProtocols, probe, detectedProtocol, draft.presetId);
+ const protocol = selectedProtocols.includes(draft.protocol)
+ ? draft.protocol
+ : selectedProtocols.includes(detectedProtocol)
+ ? detectedProtocol
+ : selectedProtocols[0] ?? detectedProtocol;
const modelDisplayNames = mergeModelDisplayNames(draft.modelDisplayNames, probe.modelDisplayNames);
const accountDraft = providerProbeAccountDraftPatch(draft, probe.account);
+ const baseUrl = providerGlobalBaseUrlForProbe(draft.baseUrl, probe, selectedProtocols);
if (probe.models.length === 0) {
return {
...draft,
...accountDraft,
- baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
+ baseUrl,
modelDisplayNames,
protocol,
selectedModels: mergeProviderModelLists(draft.selectedModels),
@@ -1910,7 +1989,7 @@ export function applyProviderProbeResult(draft: AddProviderDraft, probe: Gateway
return {
...draft,
...accountDraft,
- baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
+ baseUrl,
modelDisplayNames,
protocol,
modelsText: customModels.join("\n"),
diff --git a/packages/ui/src/pages/home/shared/usage.ts b/packages/ui/src/pages/home/shared/usage.ts
index 040988d..ed29c9b 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 1976d25..ef2cac4 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/main/gateway-client-disconnect.test.mjs b/tests/main/gateway-client-disconnect.test.mjs
new file mode 100644
index 0000000..bdad5b9
--- /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);
+}
diff --git a/tests/main/plugin-service.test.mjs b/tests/main/plugin-service.test.mjs
new file mode 100644
index 0000000..04a4978
--- /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 });
+ }
+});
diff --git a/tests/main/provider-preset-utils.test.mjs b/tests/main/provider-preset-utils.test.mjs
index a131d2e..db62f23 100644
--- a/tests/main/provider-preset-utils.test.mjs
+++ b/tests/main/provider-preset-utils.test.mjs
@@ -8,6 +8,10 @@ import {
providerIdentitySafetyIssueInList,
providerPresetMatchesBaseUrl
} from "../../packages/core/src/providers/presets/utils.ts";
+import {
+ moonshotChinaProviderPreset,
+ moonshotGlobalProviderPreset
+} from "../../packages/core/src/providers/presets/moonshot/index.ts";
const openAiPreset = {
aliases: ["OpenAI", "ChatGPT"],
@@ -26,6 +30,7 @@ const anthropicPreset = {
};
const presets = [openAiPreset, anthropicPreset];
+const moonshotPresets = [moonshotChinaProviderPreset, moonshotGlobalProviderPreset];
test("provider preset matching accepts endpoint subpaths but rejects different hosts", () => {
const openRouterPreset = {
@@ -47,6 +52,11 @@ test("provider identity lookup normalizes aliases and punctuation", () => {
assert.equal(findProviderPresetByIdentityInList(presets, "Claude Provider")?.id, "anthropic");
});
+test("provider identity lookup prefers exact Kimi regional names over shared aliases", () => {
+ assert.equal(findProviderPresetByIdentityInList(moonshotPresets, "Kimi API (Global)")?.id, "moonshot-global");
+ assert.equal(findProviderPresetByIdentityInList(moonshotPresets, "Kimi API (China)")?.id, "moonshot");
+});
+
test("provider identity safety allows loopback but warns on branded third-party endpoints", () => {
assert.equal(
providerIdentitySafetyIssueInList(presets, {
@@ -64,6 +74,38 @@ test("provider identity safety allows loopback but warns on branded third-party
);
});
+test("provider identity safety accepts shared Kimi aliases on official regional hosts", () => {
+ assert.equal(
+ providerIdentitySafetyIssueInList(moonshotPresets, {
+ baseUrl: "https://api.moonshot.ai/anthropic",
+ name: "Kimi API (Global)"
+ }),
+ undefined
+ );
+ assert.equal(
+ providerIdentitySafetyIssueInList(moonshotPresets, {
+ baseUrl: "https://api.moonshot.ai/v1",
+ name: "Kimi API (China)"
+ }),
+ undefined
+ );
+ assert.equal(
+ providerEndpointCanReceiveProviderApiKeyInList(moonshotPresets, {
+ apiKey: "manifest-provider-api-key",
+ endpoint: "https://api.moonshot.ai/v1/users/me/balance",
+ providerName: "Kimi API (China)"
+ }),
+ undefined
+ );
+ assert.match(
+ providerIdentitySafetyIssueInList(moonshotPresets, {
+ baseUrl: "https://proxy.example.com/v1",
+ name: "Kimi API (Global)"
+ })?.message ?? "",
+ /Kimi API \(Global\)/
+ );
+});
+
test("provider API key safety blocks official-looking keys on untrusted endpoints", () => {
assert.match(
providerApiKeySafetyIssueInList(presets, {
diff --git a/tests/main/provider-probe.test.mjs b/tests/main/provider-probe.test.mjs
index 9a650ee..4505486 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
);
});
diff --git a/tests/main/router-builtins.test.mjs b/tests/main/router-builtins.test.mjs
index 1928fc5..b461f52 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",
diff --git a/tests/renderer/providers.test.ts b/tests/renderer/providers.test.ts
index 8931dcf..324e27f 100644
--- a/tests/renderer/providers.test.ts
+++ b/tests/renderer/providers.test.ts
@@ -1,15 +1,30 @@
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 { moonshotGlobalProviderPreset } from "../../packages/core/src/providers/presets/moonshot/index.ts";
+import { ProvidersView } from "../../packages/ui/src/pages/home/components/providers.tsx";
import {
applyProviderProbeResult,
+ createProviderConfigFromDeepLink,
createProviderDraft,
+ createProviderInstallLinkFromDraft,
+ providerCapabilitiesForProtocols,
+ providerCapabilityBaseUrlForProtocol,
+ providerDisplayIcon,
providerAccountConnectorsTextWithNewApiUserBalanceTemplate,
+ providerGlobalBaseUrlForProbe,
+ providerPresetIconUrls,
providerProtocolOptions,
providerProbeCandidates,
+ providerSelectableProtocolsFromProbe,
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]);
@@ -22,6 +37,7 @@ test("Gemini preset keeps full protocol probing candidates", () => {
assert.equal(candidates.length, 1);
assert.deepEqual(candidates[0].protocols, providerProtocolOptions.map((option) => option.value));
+ assert.deepEqual(candidates[0].declaredProtocols, ["gemini_generate_content", "gemini_interactions"]);
});
test("provider probe result drops unavailable selected protocols", () => {
@@ -112,6 +128,301 @@ 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 probe keeps anthropic prefix on the protocol capability", () => {
+ const draft = {
+ ...createProviderDraft([]),
+ baseUrl: "https://gateway.example",
+ protocol: "anthropic_messages",
+ selectedProtocols: ["anthropic_messages"]
+ };
+ const anthropicCapability = {
+ baseUrl: "https://gateway.example/anthropic",
+ endpoint: "https://gateway.example/anthropic/v1/messages",
+ source: "detected" as const,
+ type: "anthropic_messages" as const
+ };
+ const probe = {
+ capabilities: [anthropicCapability],
+ detectedProtocol: "anthropic_messages" as const,
+ models: [],
+ normalizedBaseUrl: "https://gateway.example/anthropic",
+ protocols: [
+ {
+ ...anthropicCapability,
+ message: "HTTP 400: model is required",
+ status: 400,
+ supported: true
+ }
+ ]
+ };
+
+ const next = applyProviderProbeResult(draft, probe);
+
+ assert.equal(next.baseUrl, "https://gateway.example");
+ assert.equal(providerGlobalBaseUrlForProbe(draft.baseUrl, probe, ["anthropic_messages"]), "https://gateway.example");
+ assert.equal(
+ providerCapabilityBaseUrlForProtocol(draft.baseUrl, "anthropic_messages", probe),
+ "https://gateway.example/anthropic"
+ );
+ assert.deepEqual(providerCapabilitiesForProtocols(draft.baseUrl, ["anthropic_messages"], probe), [anthropicCapability]);
+});
+
+test("provider capabilities prefer detected anthropic endpoint over preset root", () => {
+ const probe = {
+ capabilities: [
+ {
+ baseUrl: "https://gateway.example/anthropic",
+ endpoint: "https://gateway.example/anthropic/v1/messages",
+ source: "detected" as const,
+ type: "anthropic_messages" as const
+ }
+ ],
+ detectedProtocol: "anthropic_messages" as const,
+ models: [],
+ normalizedBaseUrl: "https://gateway.example/anthropic",
+ protocols: []
+ };
+
+ const capabilities = providerCapabilitiesForProtocols(
+ "https://gateway.example",
+ ["anthropic_messages", "openai_chat_completions"],
+ probe,
+ [
+ { baseUrl: "https://gateway.example", source: "preset" as const, type: "anthropic_messages" as const },
+ { baseUrl: "https://gateway.example/v1", source: "preset" as const, type: "openai_chat_completions" as const }
+ ]
+ );
+
+ assert.deepEqual(capabilities.map((capability) => [capability.type, capability.baseUrl]), [
+ ["openai_chat_completions", "https://gateway.example/v1"],
+ ["anthropic_messages", "https://gateway.example/anthropic"]
+ ]);
+ assert.equal(
+ providerGlobalBaseUrlForProbe("https://gateway.example", probe, ["anthropic_messages", "openai_chat_completions"]),
+ "https://gateway.example"
+ );
+});
+
+test("provider global URL keeps Kimi Global OpenAI v1 when anthropic probe is detected", () => {
+ const probe = {
+ capabilities: [
+ {
+ baseUrl: "https://api.moonshot.ai/anthropic",
+ endpoint: "https://api.moonshot.ai/anthropic/v1/messages",
+ source: "detected" as const,
+ type: "anthropic_messages" as const
+ }
+ ],
+ detectedProtocol: "anthropic_messages" as const,
+ models: [],
+ normalizedBaseUrl: "https://api.moonshot.ai/anthropic",
+ protocols: []
+ };
+
+ assert.equal(
+ providerGlobalBaseUrlForProbe("https://api.moonshot.ai/v1", probe, ["openai_chat_completions"]),
+ "https://api.moonshot.ai/v1"
+ );
+ assert.deepEqual(
+ providerCapabilitiesForProtocols("https://api.moonshot.ai/v1", ["openai_chat_completions"], probe)
+ .map((capability) => [capability.type, capability.baseUrl]),
+ [["openai_chat_completions", "https://api.moonshot.ai/v1"]]
+ );
+});
+
+test("provider global URL always follows the typed endpoint instead of probe endpoint", () => {
+ const probe = {
+ capabilities: [
+ {
+ baseUrl: "https://gateway.example/v1",
+ endpoint: "https://gateway.example/v1/chat/completions",
+ source: "detected" as const,
+ type: "openai_chat_completions" as const
+ },
+ {
+ baseUrl: "https://gateway.example/anthropic",
+ endpoint: "https://gateway.example/anthropic/v1/messages",
+ source: "detected" as const,
+ type: "anthropic_messages" as const
+ }
+ ],
+ detectedProtocol: "openai_chat_completions" as const,
+ models: [],
+ normalizedBaseUrl: "https://gateway.example/v1",
+ protocols: []
+ };
+
+ assert.equal(
+ providerGlobalBaseUrlForProbe("https://gateway.example", probe, ["openai_chat_completions", "anthropic_messages"]),
+ "https://gateway.example"
+ );
+});
+
+test("Kimi Global preset keeps OpenAI Chat when probe detects anthropic fallback", () => {
+ setProviderPresets([moonshotGlobalProviderPreset]);
+ const draft = {
+ ...createProviderDraft([]),
+ baseUrl: "https://api.moonshot.ai/v1",
+ presetId: "moonshot-global",
+ protocol: "openai_chat_completions",
+ selectedProtocols: ["openai_chat_completions"]
+ };
+ const probe = {
+ capabilities: [
+ {
+ baseUrl: "https://api.moonshot.ai/anthropic",
+ endpoint: "https://api.moonshot.ai/anthropic/v1/messages",
+ source: "detected" as const,
+ type: "anthropic_messages" as const
+ },
+ {
+ baseUrl: "https://api.moonshot.ai/v1",
+ source: "preset" as const,
+ type: "openai_chat_completions" as const
+ }
+ ],
+ detectedProtocol: "anthropic_messages" as const,
+ models: [],
+ normalizedBaseUrl: "https://api.moonshot.ai/anthropic",
+ protocols: [
+ {
+ endpoint: "https://api.moonshot.ai/anthropic/v1/messages",
+ message: "HTTP 400: model is required",
+ protocol: "anthropic_messages" as const,
+ status: 400,
+ supported: true
+ }
+ ]
+ };
+
+ const next = applyProviderProbeResult(draft, probe);
+
+ assert.equal(next.baseUrl, "https://api.moonshot.ai/v1");
+ assert.equal(next.protocol, "openai_chat_completions");
+ assert.deepEqual(next.selectedProtocols, ["openai_chat_completions", "anthropic_messages"]);
+ assert.deepEqual(providerSelectableProtocolsFromProbe(probe), ["openai_chat_completions", "anthropic_messages"]);
+
+ const installLink = createProviderInstallLinkFromDraft({
+ ...next,
+ modelsText: "kimi-k2.7-code",
+ name: "Kimi API (Global)"
+ }, probe);
+ const payload = providerInstallLinkPayload(installLink);
+ assert.equal(payload.protocol, "openai_chat_completions");
+ assert.equal(payload.baseUrl, "https://api.moonshot.ai/v1");
+});
+
+test("Kimi Global deep link explicit OpenAI protocol wins over anthropic probe", () => {
+ const probe = {
+ capabilities: [
+ {
+ baseUrl: "https://api.moonshot.ai/anthropic",
+ endpoint: "https://api.moonshot.ai/anthropic/v1/messages",
+ source: "detected" as const,
+ type: "anthropic_messages" as const
+ }
+ ],
+ detectedProtocol: "anthropic_messages" as const,
+ models: [],
+ normalizedBaseUrl: "https://api.moonshot.ai/anthropic",
+ protocols: []
+ };
+
+ const provider = createProviderConfigFromDeepLink({
+ baseUrl: "https://api.moonshot.ai/v1",
+ models: ["kimi-k2.7-code"],
+ name: "Kimi API (Global)",
+ protocol: "openai_chat_completions"
+ }, [], probe);
+
+ assert.equal(provider.type, "openai_chat_completions");
+ assert.equal(provider.api_base_url, "https://api.moonshot.ai/v1");
+ assert.deepEqual(provider.capabilities?.map((capability) => [capability.type, capability.baseUrl]), [
+ ["openai_chat_completions", "https://api.moonshot.ai/v1"]
+ ]);
+});
+
+test("provider deep link config saves anthropic probe prefix as capability URL", () => {
+ const probe = {
+ capabilities: [
+ {
+ baseUrl: "https://gateway.example/anthropic",
+ endpoint: "https://gateway.example/anthropic/v1/messages",
+ source: "detected" as const,
+ type: "anthropic_messages" as const
+ }
+ ],
+ detectedProtocol: "anthropic_messages" as const,
+ models: [],
+ normalizedBaseUrl: "https://gateway.example/anthropic",
+ protocols: []
+ };
+
+ const provider = createProviderConfigFromDeepLink({
+ baseUrl: "https://gateway.example",
+ models: ["claude-test"],
+ name: "Gateway",
+ protocol: "anthropic_messages"
+ }, [], probe);
+
+ assert.equal(provider.api_base_url, "https://gateway.example");
+ assert.deepEqual(provider.capabilities?.map((capability) => [capability.type, capability.baseUrl]), [
+ ["anthropic_messages", "https://gateway.example/anthropic"]
+ ]);
+});
+
+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(
@@ -129,3 +440,12 @@ test("New API user balance template adds configurable user self connector", () =
assert.equal(connectors[1].headers["New-Api-User"], "42");
assert.equal(connectors[1].mapping.meters[0].id, "new_api_user_balance");
});
+
+function providerInstallLinkPayload(link) {
+ const url = new URL(link);
+ const payload = url.searchParams.get("payload");
+ assert.ok(payload);
+ const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, "=").replace(/-/g, "+").replace(/_/g, "/");
+ const bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
+ return JSON.parse(new TextDecoder().decode(bytes));
+}
diff --git a/tests/renderer/usage-format.test.ts b/tests/renderer/usage-format.test.ts
index 43eca8f..a6df6da 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,