diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 542f44b973..2439c63a94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1213,8 +1213,10 @@ jobs: cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - # (tsx/esm = QW-b; o alinhamento de ESCOPO do integration com o npm script fica p/ follow-up) - - run: node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts + - name: Integration tests (shard ${{ matrix.shard }}/2) + env: + TEST_SHARD: ${{ matrix.shard }}/2 + run: npm run test:integration:ci test-security: name: Security Tests diff --git a/changelog.d/fixes/8946-no-tool-output.md b/changelog.d/fixes/8946-no-tool-output.md new file mode 100644 index 0000000000..9dd6ed3369 --- /dev/null +++ b/changelog.d/fixes/8946-no-tool-output.md @@ -0,0 +1 @@ +- fix(compression): drop orphan custom_tool_call/local_shell_call/apply_patch_call on compaction restore (#8946) diff --git a/changelog.d/fixes/9435-kiro-import-overwrite.md b/changelog.d/fixes/9435-kiro-import-overwrite.md new file mode 100644 index 0000000000..9081e7537b --- /dev/null +++ b/changelog.d/fixes/9435-kiro-import-overwrite.md @@ -0,0 +1 @@ +- fix(oauth): Kiro import token endpoint no longer overwrites existing connection when using shared cached OIDC clientId (#9435) diff --git a/changelog.d/fixes/9531-ci-combo-matrix.md b/changelog.d/fixes/9531-ci-combo-matrix.md new file mode 100644 index 0000000000..c5ff766166 --- /dev/null +++ b/changelog.d/fixes/9531-ci-combo-matrix.md @@ -0,0 +1 @@ +- fix(ci): include combo-matrix tests in test-integration job (#9531) diff --git a/changelog.d/fixes/9534-modelsdevsync-timers.md b/changelog.d/fixes/9534-modelsdevsync-timers.md new file mode 100644 index 0000000000..a12b4448c4 --- /dev/null +++ b/changelog.d/fixes/9534-modelsdevsync-timers.md @@ -0,0 +1 @@ +- fix(test): prevent flaky modelsDevSync timer assertions by serializing test execution within the file (#9534) diff --git a/changelog.d/fixes/9536-usage-misreporting.md b/changelog.d/fixes/9536-usage-misreporting.md new file mode 100644 index 0000000000..ad6daa28ee --- /dev/null +++ b/changelog.d/fixes/9536-usage-misreporting.md @@ -0,0 +1 @@ +- fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536) diff --git a/changelog.d/fixes/9541-fastpath-db-corruption.md b/changelog.d/fixes/9541-fastpath-db-corruption.md new file mode 100644 index 0000000000..b15f04fa65 --- /dev/null +++ b/changelog.d/fixes/9541-fastpath-db-corruption.md @@ -0,0 +1 @@ +- fix(db): add transient-error retry to corruption probe to prevent data loss under concurrent load (#9541) diff --git a/changelog.d/fixes/9543-searxng-auto-select.md b/changelog.d/fixes/9543-searxng-auto-select.md new file mode 100644 index 0000000000..2798fab967 --- /dev/null +++ b/changelog.d/fixes/9543-searxng-auto-select.md @@ -0,0 +1 @@ +- fix(search): mark searxng-search as fallbackOnly to prevent auto-select without instance (#9543) diff --git a/changelog.d/fixes/9545-gpt56-effort-tools.md b/changelog.d/fixes/9545-gpt56-effort-tools.md new file mode 100644 index 0000000000..7cc1a77bb3 --- /dev/null +++ b/changelog.d/fixes/9545-gpt56-effort-tools.md @@ -0,0 +1 @@ +- fix(providers): strip provider prefix in getModelTargetFormat to route GPT-5.6 models to /v1/responses (#9545) diff --git a/changelog.d/fixes/9550-amazon-q-alias.md b/changelog.d/fixes/9550-amazon-q-alias.md new file mode 100644 index 0000000000..b984b8bfc0 --- /dev/null +++ b/changelog.d/fixes/9550-amazon-q-alias.md @@ -0,0 +1 @@ +- fix(model): add "aq" alias for amazon-q provider so parseModel resolves it instead of falling back to OpenAI (#9550) diff --git a/changelog.d/fixes/9551-proxyfetch-context-bypass.md b/changelog.d/fixes/9551-proxyfetch-context-bypass.md new file mode 100644 index 0000000000..eeefa9d7e6 --- /dev/null +++ b/changelog.d/fixes/9551-proxyfetch-context-bypass.md @@ -0,0 +1 @@ +- fix(proxy): NO_PROXY now bypasses context-level proxy in resolveProxyForRequest (#9551) diff --git a/changelog.d/fixes/9560-turbopack-nft-guard.md b/changelog.d/fixes/9560-turbopack-nft-guard.md new file mode 100644 index 0000000000..f6ec73d296 --- /dev/null +++ b/changelog.d/fixes/9560-turbopack-nft-guard.md @@ -0,0 +1 @@ +- fix(build): lazy-resolve module-level fs paths to avoid Turbopack NFT whole-source trace (#9560) diff --git a/changelog.d/fixes/9567-chatcore-sse-flaky.md b/changelog.d/fixes/9567-chatcore-sse-flaky.md new file mode 100644 index 0000000000..4dfefc22a0 --- /dev/null +++ b/changelog.d/fixes/9567-chatcore-sse-flaky.md @@ -0,0 +1 @@ +- fix(sse): replace timer-based waits with polling to fix flaky chatCore/SSE tests under CI load (#9567) diff --git a/changelog.d/fixes/9568-gemini-tool-casing.md b/changelog.d/fixes/9568-gemini-tool-casing.md new file mode 100644 index 0000000000..0629e40538 --- /dev/null +++ b/changelog.d/fixes/9568-gemini-tool-casing.md @@ -0,0 +1 @@ +- **fix(translator):** restore original tool name casing in Gemini/Antigravity response translators ([#9568](https://github.com/diegosouzapw/OmniRoute/issues/9568)) diff --git a/changelog.d/fixes/9575-tool-call-case.md b/changelog.d/fixes/9575-tool-call-case.md new file mode 100644 index 0000000000..dd3e888151 --- /dev/null +++ b/changelog.d/fixes/9575-tool-call-case.md @@ -0,0 +1 @@ +- fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575) diff --git a/open-sse/config/providerModels.ts b/open-sse/config/providerModels.ts index 4051e00e93..75099bad83 100644 --- a/open-sse/config/providerModels.ts +++ b/open-sse/config/providerModels.ts @@ -12,27 +12,27 @@ export const PROVIDER_MODELS: Record = new Proxy( {} as Record, { get(_, prop) { - if (typeof prop === 'symbol') return undefined; + if (typeof prop === "symbol") return undefined; return Reflect.get(initModels(), prop, _models); }, has(_, prop) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; return Reflect.has(initModels(), prop); }, ownKeys() { return Reflect.ownKeys(initModels()); }, getOwnPropertyDescriptor(_, prop) { - if (typeof prop === 'symbol') return undefined; + if (typeof prop === "symbol") return undefined; return Object.getOwnPropertyDescriptor(initModels(), prop); }, set(_, prop, value) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; (initModels() as Record)[prop] = value; return true; }, deleteProperty(_, prop) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; return Reflect.deleteProperty(initModels(), prop); }, } @@ -41,27 +41,27 @@ export const PROVIDER_ID_TO_ALIAS: Record = new Proxy( {} as Record, { get(_, prop) { - if (typeof prop === 'symbol') return undefined; + if (typeof prop === "symbol") return undefined; return Reflect.get(initAliases(), prop, _aliases); }, has(_, prop) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; return Reflect.has(initAliases(), prop); }, ownKeys() { return Reflect.ownKeys(initAliases()); }, getOwnPropertyDescriptor(_, prop) { - if (typeof prop === 'symbol') return undefined; + if (typeof prop === "symbol") return undefined; return Object.getOwnPropertyDescriptor(initAliases(), prop); }, set(_, prop, value) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; (initAliases() as Record)[prop] = value; return true; }, deleteProperty(_, prop) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; return Reflect.deleteProperty(initAliases(), prop); }, } @@ -116,7 +116,13 @@ export function findModelName(aliasOrId: string, modelId: string): string { export function getModelTargetFormat(aliasOrId: string, modelId: string): string | null { const models = PROVIDER_MODELS[aliasOrId]; - const found = models?.find((m) => m.id === modelId); + // Strip provider prefix if present: "openai/gpt-5.6-luna" → "gpt-5.6-luna" + const prefix = aliasOrId + "/"; + const bareModelId = + typeof modelId === "string" && modelId.startsWith(prefix) + ? modelId.slice(prefix.length) + : modelId; + const found = models?.find((m) => m.id === bareModelId); if (found?.targetFormat) return found.targetFormat; // #5842: OpenAI "*-pro" reasoning models (o1-pro, gpt-5.x-pro) are only served by // the native /v1/responses endpoint — /v1/chat/completions 404s ("only supported @@ -124,7 +130,7 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string // covers dynamically-synced ids that post-date the catalog (same spirit as the gh // executor's /codex/i routing, 9router#102). Scoped to the openai alias so other // providers shipping *-pro ids keep their own endpoint semantics. - if (aliasOrId === "openai" && /-pro$/i.test(modelId)) return "openai-responses"; + if (aliasOrId === "openai" && /-pro$/i.test(bareModelId)) return "openai-responses"; return null; } diff --git a/open-sse/config/searchRegistry.ts b/open-sse/config/searchRegistry.ts index 2b366547d3..b742ad4914 100644 --- a/open-sse/config/searchRegistry.ts +++ b/open-sse/config/searchRegistry.ts @@ -207,6 +207,7 @@ export const SEARCH_PROVIDERS: Record = { maxMaxResults: 50, timeoutMs: 10_000, cacheTTLMs: 3 * 60 * 1000, + fallbackOnly: true, }, "ollama-search": { diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 26120abce3..3a0baf33f5 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2306,10 +2306,24 @@ export async function handleChatCore({ const nativeClaudeToolNameMap = isClaudePassthrough ? buildClaudePassthroughToolNameMap(body) : null; - const toolNameMap = + let toolNameMap: Map | null = translatedToolNameMap instanceof Map && translatedToolNameMap.size > 0 ? translatedToolNameMap : nativeClaudeToolNameMap; + + // For providers whose _toolNameMap was extracted as requestToolIdentityMap + // before the Kiro merge block (Gemini/Antigravity), merge it into the + // response toolNameMap so the response translator can restore tool names + // from their lowercased form (#9568). Only merge string-valued entries + // (tool name aliases), not object-valued namespace identities (#7936). + if (!toolNameMap && requestToolIdentityMap instanceof Map && requestToolIdentityMap.size > 0) { + const hasStringValues = [...requestToolIdentityMap.values()].every( + (v: unknown) => typeof v === "string" + ); + if (hasStringValues) { + toolNameMap = requestToolIdentityMap; + } + } delete translatedBody._toolNameMap; delete translatedBody._disableToolPrefix; diff --git a/open-sse/handlers/responseTranslator.ts b/open-sse/handlers/responseTranslator.ts index 35f66e1087..165e292dc0 100644 --- a/open-sse/handlers/responseTranslator.ts +++ b/open-sse/handlers/responseTranslator.ts @@ -6,7 +6,10 @@ import { import { normalizeOpenAICompatibleFinishReasonString } from "../utils/finishReason.ts"; import { containsTextualToolCallMarker } from "../utils/textualToolCall.ts"; import { getAnyReasoningValue } from "../utils/reasoningFields.ts"; -import { restoreOpenAIToolNames } from "../translator/helpers/toolCallHelper.ts"; +import { + caseInsensitiveToolNameLookup, + restoreOpenAIToolNames, +} from "../translator/helpers/toolCallHelper.ts"; type JsonRecord = Record; @@ -206,7 +209,7 @@ export function translateNonStreamingResponse( typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit || {}); const rawName = toString(itemObj.name); // Strip Claude OAuth proxy_ prefix using toolNameMap - const resolvedName = toolNameMap?.get(rawName) ?? rawName; + const resolvedName = caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName; toolCalls.push({ id: callId, type: "function", @@ -388,7 +391,8 @@ export function translateNonStreamingResponse( if (partObj.functionCall) { const fn = toRecord(partObj.functionCall); const rawName = toString(fn.name); - const restoredName = toolNameMap?.get(rawName) ?? rawName; + const restoredName = + caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName; const nativeId = toString(fn.id); const toolCallId = nativeId.length > 0 @@ -507,7 +511,7 @@ export function translateNonStreamingResponse( thinkingContent += toString(blockObj.thinking); } else if (blockObj.type === "tool_use") { const rawName = toString(blockObj.name); - const strippedName = toolNameMap?.get(rawName) ?? rawName; + const strippedName = caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName; toolCalls.push({ id: toString(blockObj.id, `call_${Date.now()}_${toolCalls.length}`), type: "function", @@ -687,6 +691,35 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco if (stopReason === "tool_calls") stopReason = "tool_use"; const usageSrc = toRecord(openaiResponse.usage); + const promptTokens = toNumber(usageSrc.prompt_tokens, 0); + const outputTokens = toNumber(usageSrc.completion_tokens, 0); + + // Extract cache tokens from prompt_tokens_details (mirrors the streaming + // translator in open-sse/translator/response/openai-to-claude.ts lines 119-148). + const promptDetails = toRecord(usageSrc.prompt_tokens_details); + const cachedTokens = toNumber(promptDetails.cached_tokens, 0); + const cacheCreationTokens = toNumber(promptDetails.cache_creation_tokens, 0); + + // OpenAI's prompt_tokens includes all prompt-side tokens (cached + non-cached). + // Claude expects input_tokens to be only non-cached tokens, with cached tokens + // exposed separately as cache_read_input_tokens. + const inputTokens = promptTokens - cachedTokens - cacheCreationTokens; + + const usage: JsonRecord = { + input_tokens: inputTokens, + output_tokens: outputTokens, + }; + + // Add cache_read_input_tokens if present + if (cachedTokens > 0) { + usage.cache_read_input_tokens = cachedTokens; + } + + // Add cache_creation_input_tokens if present + if (cacheCreationTokens > 0) { + usage.cache_creation_input_tokens = cacheCreationTokens; + } + const claudeResponse: JsonRecord = { id: toString(openaiResponse.id, `msg_${Date.now()}`), type: "message", @@ -695,10 +728,7 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco content, stop_reason: stopReason, stop_sequence: null, - usage: { - input_tokens: toNumber(usageSrc.prompt_tokens, 0), - output_tokens: toNumber(usageSrc.completion_tokens, 0), - }, + usage, }; return claudeResponse; diff --git a/open-sse/mcp-server/__tests__/createComboTool.test.ts b/open-sse/mcp-server/__tests__/createComboTool.test.ts new file mode 100644 index 0000000000..a0c4ce0ee0 --- /dev/null +++ b/open-sse/mcp-server/__tests__/createComboTool.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { MCP_TOOLS, MCP_TOOL_MAP, createComboInput, createComboTool } from "../schemas/tools.ts"; +import { createMcpServer } from "../server.ts"; + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +const mockLogToolCall = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); +vi.mock("../audit.ts", () => ({ + logToolCall: mockLogToolCall, +})); + +describe("omniroute_create_combo MCP tool schema", () => { + it("should be registered in MCP_TOOLS and MCP_TOOL_MAP", () => { + const tool = MCP_TOOLS.find((t) => t.name === "omniroute_create_combo"); + expect(tool).toBeDefined(); + expect(MCP_TOOL_MAP["omniroute_create_combo"]).toBeDefined(); + }); + + it("should require write:combos scope", () => { + expect(createComboTool.scopes).toContain("write:combos"); + }); + + it("should validate a minimal payload (name + models)", () => { + const result = createComboInput.safeParse({ + name: "My Combo", + models: [{ provider: "anthropic", model: "claude-sonnet" }], + }); + expect(result.success).toBe(true); + }); + + it("should validate a full payload with description and strategy", () => { + const result = createComboInput.safeParse({ + name: "My Combo", + description: "A test combo", + strategy: "priority", + models: [ + { provider: "anthropic", model: "claude-sonnet" }, + { provider: "google", model: "gemini-pro" }, + ], + }); + expect(result.success).toBe(true); + }); + + it("should reject a payload missing name", () => { + const result = createComboInput.safeParse({ + models: [{ provider: "anthropic", model: "claude-sonnet" }], + }); + expect(result.success).toBe(false); + }); + + it("should reject a payload with an empty models array", () => { + const result = createComboInput.safeParse({ name: "My Combo", models: [] }); + expect(result.success).toBe(false); + }); + + it("should reject an unknown strategy value", () => { + const result = createComboInput.safeParse({ + name: "My Combo", + strategy: "not-a-real-strategy", + models: [{ provider: "anthropic", model: "claude-sonnet" }], + }); + expect(result.success).toBe(false); + }); +}); + +describe("omniroute_create_combo handler (via MCP dispatch)", () => { + let client: Client; + + beforeEach(async () => { + mockFetch.mockReset(); + mockLogToolCall.mockClear(); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = createMcpServer(); + await server.connect(serverTransport); + client = new Client({ name: "create-combo-test", version: "1.0.0" }); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + }); + + it("should appear in tools/list after registration", async () => { + const { tools } = await client.listTools(); + const tool = tools.find((t) => t.name === "omniroute_create_combo"); + expect(tool).toBeDefined(); + expect(tool?.description).toContain("Registers new combo"); + }); + + it("should POST to /api/combos and return the created combo on success", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + combo: { id: "combo-123", name: "My Combo", strategy: "priority", enabled: true }, + }), + }); + + const args = { + name: "My Combo", + models: [{ provider: "anthropic", model: "claude-sonnet" }], + }; + + const result = await client.callTool({ name: "omniroute_create_combo", arguments: args }); + + expect(result.isError).toBeFalsy(); + const content = result.content[0] as { type: string; text: string }; + const data = JSON.parse(content.text); + expect(data.success).toBe(true); + expect(data.combo.id).toBe("combo-123"); + expect(data.combo.name).toBe("My Combo"); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/api/combos"), + expect.objectContaining({ method: "POST" }) + ); + const [, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body as string); + expect(body.name).toBe("My Combo"); + expect(body.models).toHaveLength(1); + + // Audit: the invocation must be logged to mcp_audit (via logToolCall). + expect(mockLogToolCall).toHaveBeenCalledWith( + "omniroute_create_combo", + expect.objectContaining({ name: "My Combo" }), + expect.objectContaining({ success: true }), + expect.any(Number), + true + ); + }); + + it("should pass through optional description and strategy fields", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + combo: { id: "combo-456", name: "Cost Saver", strategy: "cost-optimized", enabled: true }, + }), + }); + + await client.callTool({ + name: "omniroute_create_combo", + arguments: { + name: "Cost Saver", + description: "Prefers cheaper models", + strategy: "cost-optimized", + models: [ + { provider: "anthropic", model: "claude-haiku" }, + { provider: "google", model: "gemini-flash" }, + ], + }, + }); + + const [, options] = mockFetch.mock.calls[0]; + const body = JSON.parse(options.body as string); + expect(body.description).toBe("Prefers cheaper models"); + expect(body.strategy).toBe("cost-optimized"); + expect(body.models).toHaveLength(2); + }); + + it("should return isError and log the failure when the backend rejects the combo (e.g. name collision)", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 409, + text: async () => "Combo name already exists", + }); + + const result = await client.callTool({ + name: "omniroute_create_combo", + arguments: { + name: "Duplicate Combo", + models: [{ provider: "anthropic", model: "claude-sonnet" }], + }, + }); + + expect(result.isError).toBe(true); + const content = result.content[0] as { type: string; text: string }; + expect(content.text).toContain("Error"); + + expect(mockLogToolCall).toHaveBeenCalledWith( + "omniroute_create_combo", + expect.objectContaining({ name: "Duplicate Combo" }), + null, + expect.any(Number), + false, + expect.stringContaining("Combo name already exists") + ); + }); +}); diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 012198baf8..4db6a850dc 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -192,6 +192,53 @@ export const switchComboTool: McpToolDefinition = + { + name: "omniroute_create_combo", + description: + "Registers a new combo (model chain) with a name, ordered model list, and optional routing strategy. Full validation (name collisions, nested-combo DAG, composite tiers) is enforced by the combos API.", + inputSchema: createComboInput, + outputSchema: createComboOutput, + scopes: ["write:combos"], + auditLevel: "full", + phase: 1, + sourceEndpoints: ["/api/combos"], + }; + // --- Tool 5: omniroute_check_quota --- export const checkQuotaInput = z.object({ provider: z @@ -1460,6 +1507,7 @@ export const MCP_TOOLS = [ listCombosTool, getComboMetricsTool, switchComboTool, + createComboTool, checkQuotaTool, routeRequestTool, costReportTool, diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index 9f8a536744..d37a157f71 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -12,6 +12,7 @@ import { listCombosInput, getComboMetricsInput, switchComboInput, + createComboInput, checkQuotaInput, routeRequestInput, costReportInput, @@ -393,6 +394,27 @@ async function handleSwitchCombo(args: { comboId: string; active: boolean }) { } } +async function handleCreateCombo(args: { + name: string; + description?: string; + strategy?: string; + models: { provider: string; model: string }[]; +}) { + const start = Date.now(); + try { + const result = await omniRouteFetch("/api/combos", { + method: "POST", + body: JSON.stringify(args), + }); + await logToolCall("omniroute_create_combo", args, result, Date.now() - start, true); + return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await logToolCall("omniroute_create_combo", args, null, Date.now() - start, false, msg); + return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true }; + } +} + async function handleCheckQuota(args: { provider?: string; connectionId?: string }) { const start = Date.now(); try { @@ -738,6 +760,17 @@ export function createMcpServer(): McpServer { ) ); + server.registerTool( + "omniroute_create_combo", + { + description: "Registers a new combo (model chain) with name, models, and strategy", + inputSchema: createComboInput, + }, + withScopeEnforcement("omniroute_create_combo", (args) => + handleCreateCombo(createComboInput.parse(args)) + ) + ); + server.registerTool( "omniroute_check_quota", { diff --git a/open-sse/services/adobeFireflyBrowserLogin.ts b/open-sse/services/adobeFireflyBrowserLogin.ts index 9df37023d3..1482971c3e 100644 --- a/open-sse/services/adobeFireflyBrowserLogin.ts +++ b/open-sse/services/adobeFireflyBrowserLogin.ts @@ -259,7 +259,14 @@ async function captureViaCdp(opts: { if (capturedAccessToken) return; const request = params.request as { url?: string; headers?: Record } | undefined; - if (!request?.url || !request.url.includes(FIREFLY_3P_HOST_SUFFIX)) return; + if (!request?.url) return; + let host: string; + try { + host = new URL(request.url).hostname.toLowerCase(); + } catch { + return; + } + if (host !== FIREFLY_3P_HOST_SUFFIX && !host.endsWith(`.${FIREFLY_3P_HOST_SUFFIX}`)) return; const headers = request.headers || {}; const auth = headers.Authorization || headers.authorization || headers.AUTHORIZATION || ""; const token = extractAdobeBearerTokenFromAuthorization(auth); diff --git a/open-sse/services/combo/comboStructure.ts b/open-sse/services/combo/comboStructure.ts index 58a8b99538..dd121d7e0d 100644 --- a/open-sse/services/combo/comboStructure.ts +++ b/open-sse/services/combo/comboStructure.ts @@ -618,12 +618,6 @@ export type CompatFilterOptions = { failOpen?: boolean; }; -const HARD_COMPAT_REASONS = new Set(["tools", "vision", "structured_output"]); - -function hasHardCapabilityFailure(reasons: string[]): boolean { - return reasons.some((reason) => HARD_COMPAT_REASONS.has(reason)); -} - /** * Summarize a capability-filter exhaustion for a 400-class combo error (#8488). * Returns null when the empty pool is not attributable to hard requirements. @@ -727,7 +721,9 @@ export function filterTargetsByRequestCompatibility( if (compatible.length === targets.length) return targets; if (compatible.length === 0) { - const hardRejected = rejected.some((entry) => hasHardCapabilityFailure(entry.reasons)); + const hardRejected = rejected.some((entry) => + entry.reasons.some((r) => HARD_COMPAT_REASONS.has(r)) + ); const failOpen = options?.failOpen === true; log.debug?.( diff --git a/open-sse/services/compression/bodyAdapter.ts b/open-sse/services/compression/bodyAdapter.ts index 64edaca84e..467575349f 100644 --- a/open-sse/services/compression/bodyAdapter.ts +++ b/open-sse/services/compression/bodyAdapter.ts @@ -400,13 +400,24 @@ export function adaptBodyForCompression( }); const cleanedInput = nextInput.filter((item) => { - if (!isRecord(item) || item.type !== "function_call") return true; + if (!isRecord(item)) return true; + const t = item.type; + if ( + t !== "function_call" && + t !== "custom_tool_call" && + t !== "local_shell_call" && + t !== "apply_patch_call" + ) { + return true; + } if (typeof item.call_id !== "string" || item.call_id.length === 0) return true; const hadMappedOutput = mappings.some((mapping) => { const original = mapping.item; return ( (original.type === "function_call_output" || - original.type === "custom_tool_call_output") && + original.type === "custom_tool_call_output" || + original.type === "local_shell_call_output" || + original.type === "apply_patch_call_output") && original.call_id === item.call_id ); }); diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 640fb10802..9465529a27 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -54,6 +54,10 @@ ALIAS_TO_PROVIDER_ID["xiaomi"] = "xiaomi-mimo"; ALIAS_TO_PROVIDER_ID["llamacpp"] = "llama-cpp"; // agy/ is the short alias for antigravity provider. ALIAS_TO_PROVIDER_ID["agy"] = "antigravity"; +// aq/ is the user-visible prefix for the Amazon Q (AWS Builder ID) provider. +// The canonical provider ID is "amazon-q". Register it so parseModel("aq/") +// resolves provider = "amazon-q" instead of falling through to the identity fallback. +ALIAS_TO_PROVIDER_ID["aq"] = "amazon-q"; // Provider-scoped legacy model aliases. Used to normalize provider/model inputs // and keep backward compatibility when upstream IDs change. diff --git a/open-sse/translator/helpers/toolCallHelper.ts b/open-sse/translator/helpers/toolCallHelper.ts index f0869951c7..817aabbf57 100644 --- a/open-sse/translator/helpers/toolCallHelper.ts +++ b/open-sse/translator/helpers/toolCallHelper.ts @@ -96,6 +96,37 @@ export function normalizeOpenAIToolNames(body: unknown, maxLength: number): Tool return aliases; } +/** + * Case-insensitive fallback for tool name lookups from upstream responses. + * + * Many upstream providers/models return tool call names in lowercase (e.g., "bash") + * even when the tool definition used PascalCase ("Bash"). This helper tries an exact + * match first (fast path for well-behaved providers), then falls back to a + * case-insensitive scan over the map entries. + * + * Returns the mapped value on match, or `undefined` when no entry matches. + */ +export function caseInsensitiveToolNameLookup( + name: string, + map: Map | null | undefined +): string | undefined { + if (!map || !name) return undefined; + + // Fast path: exact match (PascalCase-preserving providers) + const exact = map.get(name); + if (exact !== undefined) return exact; + + // Fallback: case-insensitive scan + const lowerName = name.toLowerCase(); + for (const [key, value] of map) { + if (key.toLowerCase() === lowerName) { + return value; + } + } + + return undefined; +} + /** Restore normalized function names in OpenAI Chat Completions responses. */ export function restoreOpenAIToolNames(body: unknown, aliases: unknown): boolean { if (!(aliases instanceof Map) || aliases.size === 0) return false; @@ -108,7 +139,7 @@ export function restoreOpenAIToolNames(body: unknown, aliases: unknown): boolean for (const toolCall of calls) { const fn = toRecord(toRecord(toolCall)?.function); if (!fn || typeof fn.name !== "string") continue; - const original = aliases.get(fn.name); + const original = caseInsensitiveToolNameLookup(fn.name, aliases); if (typeof original !== "string" || original === fn.name) continue; fn.name = original; changed = true; diff --git a/open-sse/translator/request/openai-to-gemini/helpers.ts b/open-sse/translator/request/openai-to-gemini/helpers.ts index 3dfd35cef1..810620d8f9 100644 --- a/open-sse/translator/request/openai-to-gemini/helpers.ts +++ b/open-sse/translator/request/openai-to-gemini/helpers.ts @@ -37,10 +37,22 @@ type OpenAIToolCallLike = { export function buildChangedToolNameMap( toolNameMap: Map ): Map | null { - const changedEntries = [...toolNameMap.entries()].filter( - ([sanitizedName, originalName]) => sanitizedName !== originalName - ); - return changedEntries.length > 0 ? new Map(changedEntries) : null; + if (toolNameMap.size === 0) return null; + + const result = new Map(); + for (const [sanitizedName, originalName] of toolNameMap.entries()) { + result.set(sanitizedName, originalName); + // Add lowercase-keyed alias so Gemini's lowercased tool names find the original. + // Gemini always lowercases tool names in functionCall responses, so even identity + // entries (Bash → Bash) need a lowercase key ("bash" → "Bash") for the response + // translator to look them up (#9568). + const lower = sanitizedName.toLowerCase(); + if (lower !== sanitizedName && !result.has(lower)) { + result.set(lower, originalName); + } + } + + return result; } export function extractClientThoughtSignature(toolCall: unknown): string | null { diff --git a/open-sse/translator/response/gemini-to-claude.ts b/open-sse/translator/response/gemini-to-claude.ts index 5bc9482508..3af3c48418 100644 --- a/open-sse/translator/response/gemini-to-claude.ts +++ b/open-sse/translator/response/gemini-to-claude.ts @@ -108,9 +108,11 @@ export function geminiToClaudeResponse(chunk, state) { } const fc = part.functionCall; const rawToolName = fc.name; - const restoredToolName = normalizeToolName( - state.toolNameMap?.get(rawToolName) || rawToolName - ); + const mappedName = state.toolNameMap?.get(rawToolName); + // When the toolNameMap provides a match (e.g., lowercase "bash" → "Bash"), + // use it directly without passing through normalizeToolName(), which would + // reverse TitleCase back to lowercase via REVERSE_MAP (#9568). + const restoredToolName = mappedName || normalizeToolName(rawToolName); const idx = state.contentBlockIndex++; const toolId = fc.id || `toolu_${Date.now()}_${idx}`; diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index 3f915b5ec2..2a8240e290 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -4,6 +4,7 @@ import { buildGeminiThoughtSignatureKey, storeGeminiThoughtSignature, } from "../../services/geminiThoughtSignatureStore.ts"; +import { caseInsensitiveToolNameLookup } from "../helpers/toolCallHelper.ts"; import { parseTextualToolCallCandidate, containsTextualToolCallMarker, @@ -256,7 +257,7 @@ function emitFunctionCallPart( results: Array> ) { const rawToolName = part.functionCall.name; - const fcName = state.toolNameMap?.get(rawToolName) || rawToolName; + const fcName = caseInsensitiveToolNameLookup(rawToolName, state.toolNameMap) ?? rawToolName; const fcArgs = normalizeToolCallArgs(part.functionCall.args || {}); const toolCallIndex = state.functionIndex++; const toolCall = { diff --git a/open-sse/translator/response/openai-to-claude.ts b/open-sse/translator/response/openai-to-claude.ts index 6a5d1687ad..cac48d5848 100644 --- a/open-sse/translator/response/openai-to-claude.ts +++ b/open-sse/translator/response/openai-to-claude.ts @@ -1,6 +1,7 @@ import { register } from "../registry.ts"; import { FORMATS } from "../formats.ts"; import { CLAUDE_OAUTH_TOOL_PREFIX } from "../request/openai-to-claude.ts"; +import { caseInsensitiveToolNameLookup } from "../helpers/toolCallHelper.ts"; import { hasToolCallShim, applyToolCallShimToBuffer } from "../helpers/toolCallShim.ts"; import { appendToolCallArgumentDelta } from "../../utils/toolCallArguments.ts"; import { isAbortFinishReason } from "../../utils/finishReason.ts"; @@ -284,7 +285,7 @@ export function openaiToClaudeResponse(chunk, state) { // Strip the Claude OAuth prefix from an incoming tool name (if any). const incomingName = (() => { let n = tc.function?.name || ""; - n = state.toolNameMap?.get(n) || n; + n = caseInsensitiveToolNameLookup(n, state.toolNameMap) ?? n; if (n.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) n = n.slice(CLAUDE_OAUTH_TOOL_PREFIX.length); return n; })(); diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 6fa2c8c29c..a5080c6fbb 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -382,6 +382,10 @@ export function resolveProxyForRequest(targetUrl) { const contextProxy = proxyContext.getStore(); if (contextProxy) { + // #9551: NO_PROXY must bypass context-proxy too + if (target && noProxyMatch(targetUrl)) { + return { source: "direct", proxyUrl: null }; + } return { source: "context", proxyUrl: proxyConfigToUrl(contextProxy) }; } diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 086c938fcf..7f0991a0b9 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -70,7 +70,10 @@ import { hasUnsupportedReasoningSignal, } from "./reasoningFields.ts"; import { applyThinkTag, flushThink, initThinkState } from "./thinkTagParser.ts"; -import { restoreOpenAIToolNames } from "../translator/helpers/toolCallHelper.ts"; +import { + caseInsensitiveToolNameLookup, + restoreOpenAIToolNames, +} from "../translator/helpers/toolCallHelper.ts"; import { normalizeFinalOpenAIStreamChunk } from "./openAIStreamChunk.ts"; /** @@ -578,7 +581,7 @@ function restoreClaudePassthroughToolUseName(parsed: JsonRecord, toolNameMap: un : null; if (!block || block.type !== "tool_use" || typeof block.name !== "string") return false; - const restoredName = toolNameMap.get(block.name) ?? block.name; + const restoredName = caseInsensitiveToolNameLookup(block.name, toolNameMap) ?? block.name; if (restoredName === block.name) return false; block.name = restoredName; return true; diff --git a/package.json b/package.json index b581c8fb3a..d237f9aeb9 100644 --- a/package.json +++ b/package.json @@ -210,6 +210,7 @@ "backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts", "env:sync": "node scripts/dev/sync-env.mjs", "test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"", + "test:integration:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=$TEST_SHARD tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"", "test:combo:matrix": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-matrix/*.test.ts\"", "test:combo:live": "cross-env RUN_COMBO_LIVE=1 DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 \"tests/integration/combo-live/*.live.test.ts\"", "test:combo:live:vps": "node scripts/test/combo-live-vps.mjs", diff --git a/src/app/api/oauth/kiro/import/route.ts b/src/app/api/oauth/kiro/import/route.ts index 54b63d2f3e..d4b89183ab 100755 --- a/src/app/api/oauth/kiro/import/route.ts +++ b/src/app/api/oauth/kiro/import/route.ts @@ -217,11 +217,15 @@ export async function POST(request: Request) { testStatus: "active", isActive: true, }; - const connection: any = await upsertImportedKiroConnection(targetProvider, record, { - profileArn: resolvedProfileArn, - clientId: providerSpecificData.clientId, - email, - }); + // Only include clientId in the identity for IDC imports where it is genuinely + // unique per account (#2059). For Builder ID / social imports the OIDC clientId + // comes from a machine-wide cached OIDC registration (shared across all accounts + // on the same machine), so using it for identity matching would cause different + // accounts to overwrite each other (#9435). Without clientId, the identity + // matching falls through to the email field, which correctly distinguishes imports. + const identity: Record = { profileArn: resolvedProfileArn, email }; + if (isIdc) identity.clientId = providerSpecificData.clientId; + const connection: any = await upsertImportedKiroConnection(targetProvider, record, identity); // Auto sync to Cloud if enabled await syncToCloudIfEnabled(); diff --git a/src/domain/persistence/comboRepositories.ts b/src/domain/persistence/comboRepositories.ts new file mode 100644 index 0000000000..00750a318c --- /dev/null +++ b/src/domain/persistence/comboRepositories.ts @@ -0,0 +1,61 @@ +export type ComboRecord = Record; + +export interface ComboUpdateResult { + combo: ComboRecord; + previousName: string; + currentName: string; + modelsFieldProvided: boolean; +} + +export interface ComboReorderResult { + combos: ComboRecord[]; + rowsReordered: number; +} + +export interface ComboRepository { + list(limit?: number, offset?: number): Promise; + count(): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + findByNameInsensitive(name: string): Promise; + create(data: ComboRecord): Promise; + update(id: string, data: ComboRecord): Promise; + reorder(comboIds: string[]): Promise; + deleteById(id: string): Promise; +} + +export interface ModelComboMapping { + id: string; + pattern: string; + comboId: string; + comboName?: string; + priority: number; + enabled: boolean; + description: string; + createdAt: string; + updatedAt: string; +} + +export interface CreateModelComboMappingInput { + pattern: string; + comboId: string; + priority?: number; + enabled?: boolean; + description?: string; +} + +export type UpdateModelComboMappingInput = Partial; + +export interface ModelComboMappingPage { + items: ModelComboMapping[]; + total: number; +} + +export interface ModelComboMappingRepository { + list(options?: { limit?: number; offset?: number }): Promise; + findById(id: string): Promise; + create(data: CreateModelComboMappingInput): Promise; + update(id: string, data: UpdateModelComboMappingInput): Promise; + deleteById(id: string): Promise; + resolveForModel(model: string): Promise; +} diff --git a/src/lib/db/combos.ts b/src/lib/db/combos.ts index 5e5564bb61..6cdebbd85b 100644 --- a/src/lib/db/combos.ts +++ b/src/lib/db/combos.ts @@ -1,359 +1,91 @@ /** - * db/combos.js — Combo CRUD operations. + * Compatibility facade for combo persistence. + * + * Application code keeps the existing function-level API while persistence is + * delegated through the domain repository contract. Cross-cutting write effects + * remain here instead of becoming part of the portable repository surface. */ -import { v4 as uuidv4 } from "uuid"; -import { getDbInstance } from "./core"; +import type { ComboRecord } from "@/domain/persistence/comboRepositories"; import { backupDbFile } from "./backup"; +import { clearSessionModelHistoryForCombo } from "./contextHandoffs"; +import { getDbInstance } from "./core"; import { invalidateDbCache } from "./readCache"; import { invalidateReasoningRoutingRuleCache } from "./reasoningRoutingRules"; -import { normalizeComboRecord } from "@/lib/combos/steps"; -import { clearSessionModelHistoryForCombo } from "./contextHandoffs"; -import { validateComboInvariant } from "@/lib/combos/invariants"; +import { routingConfigRepositories } from "./repositories/routingConfigRepositories"; -type JsonRecord = Record; +const repository = routingConfigRepositories.combos; -function asRecord(value: unknown): JsonRecord { - return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; -} - -function getSerializedData(value: unknown): string | null { - const row = asRecord(value); - return typeof row.data === "string" ? row.data : null; -} - -function getSortOrder(value: unknown): number | null { - const row = asRecord(value); - return typeof row.sort_order === "number" ? row.sort_order : null; -} - -function withSortOrder(payload: string, sortOrder: number | null): JsonRecord { - const parsed = JSON.parse(payload) as JsonRecord; - if (typeof sortOrder === "number") { - parsed.sortOrder = sortOrder; - } - return parsed; -} - -function getComboNameSet( - db: ReturnType, - extraNames: string[] = [] -): Set { - const rows = db.prepare("SELECT name FROM combos").all(); - const names = new Set(); - - for (const row of rows) { - const record = asRecord(row); - if (typeof record.name === "string" && record.name.trim().length > 0) { - names.add(record.name.trim()); - } - } - - for (const name of extraNames) { - if (typeof name === "string" && name.trim().length > 0) { - names.add(name.trim()); - } - } - - return names; -} - -function normalizeStoredCombo( - combo: JsonRecord, - db: ReturnType, - extraNames: string[] = [] -) { - return normalizeComboRecord(combo, { - allCombos: getComboNameSet(db, extraNames), - }); -} - -function parseComboRow(row: unknown): JsonRecord | null { - const payload = getSerializedData(row); - if (!payload) return null; - const parsed = withSortOrder(payload, getSortOrder(row)); - // Merge deduplicated column values back into the record - const record = asRecord(row); - if (record.context_cache_protection !== undefined && record.context_cache_protection !== null) { - // Column is authoritative when explicitly enabled (1). - // When column is 0 (unset default) preserve the JSON blob value - // to avoid silently disabling the feature on pre-migration rows. - if (record.context_cache_protection === 1) { - parsed.context_cache_protection = true; - } - // Column is 0 — keep existing JSON blob value - } - return parsed; -} - -function getNextSortOrder() { - const db = getDbInstance(); - const row = db.prepare("SELECT COALESCE(MAX(sort_order), 0) AS sort_order FROM combos").get(); - const sortOrder = getSortOrder(row); - return (sortOrder ?? 0) + 1; -} - -export async function getCombos(limit?: number, offset?: number) { - const db = getDbInstance(); - let sql = - "SELECT id, data, sort_order, context_cache_protection FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC"; - const params: unknown[] = []; - if (limit !== undefined) { - sql += " LIMIT ? OFFSET ?"; - params.push(limit, offset ?? 0); - } - const rawCombos = db - .prepare(sql) - .all(...params) - .map((row) => parseComboRow(row)) - .filter((row): row is JsonRecord => row !== null); - - const comboNames = rawCombos - .map((combo) => (typeof combo.name === "string" ? combo.name.trim() : "")) - .filter((name): name is string => name.length > 0); - - return rawCombos.map((combo) => - normalizeComboRecord(combo, { - allCombos: comboNames, - }) - ); +export function getCombos(limit?: number, offset?: number): Promise { + return repository.list(limit, offset); } +/** Keep the existing synchronous facade contract while repository APIs become async. */ export function getCombosCount(): number { - const db = getDbInstance(); - const row = db.prepare("SELECT count(*) as cnt FROM combos").get() as { cnt: number }; - return row.cnt; + return routingConfigRepositories.legacySync.getCombosCount(); } -export async function getComboById(id: string) { - const db = getDbInstance(); - const row = db - .prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE id = ?") - .get(id); - const combo = parseComboRow(row); - if (!combo) return null; - return normalizeStoredCombo(combo, db, typeof combo.name === "string" ? [combo.name] : []); +export function getComboById(id: string): Promise { + return repository.findById(id); } -export async function getComboByName(name: string) { - const db = getDbInstance(); - const row = db - .prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE name = ?") - .get(name); - const combo = parseComboRow(row); - if (!combo) return null; - return normalizeStoredCombo(combo, db, [name]); +export function getComboByName(name: string): Promise { + return repository.findByName(name); } -// #4446: case-insensitive name lookup. The opencode dispatch path forwards a -// lowercased combo slug (e.g. "master-light") for a combo provisioned as -// "MASTER-LIGHT"; the default BINARY collation of getComboByName misses it. -// Used only as a fallback after the exact match fails, so it cannot change the -// resolution of any combo that already resolves today. -export async function getComboByNameInsensitive(name: string) { - const db = getDbInstance(); - const row = db - .prepare( - "SELECT data, sort_order, context_cache_protection FROM combos WHERE name = ? COLLATE NOCASE" - ) - .get(name); - const combo = parseComboRow(row); - if (!combo) return null; - const storedName = typeof combo.name === "string" ? combo.name : name; - return normalizeStoredCombo(combo, db, [storedName]); +export function getComboByNameInsensitive(name: string): Promise { + return repository.findByNameInsensitive(name); } -export async function createCombo(data: JsonRecord) { - const db = getDbInstance(); - const now = new Date().toISOString(); - const sortOrder = typeof data.sortOrder === "number" ? data.sortOrder : getNextSortOrder(); - const comboId = typeof data.id === "string" && data.id.trim().length > 0 ? data.id : uuidv4(); - const combo = normalizeStoredCombo( - { - ...data, - id: comboId, - name: data.name, - models: data.models || [], - strategy: data.strategy || "priority", - config: data.config || {}, - isHidden: Boolean(data.isHidden), - sortOrder, - createdAt: now, - updatedAt: now, - }, - db, - typeof data.name === "string" ? [data.name] : [] - ); - - validateComboInvariant(combo); - const contextCache = data.context_cache_protection ? 1 : 0; - db.prepare( - "INSERT INTO combos (id, name, data, sort_order, created_at, updated_at, context_cache_protection) VALUES (?, ?, ?, ?, ?, ?, ?)" - ).run(combo.id, combo.name, JSON.stringify(combo), sortOrder, now, now, contextCache); - +export async function createCombo(data: ComboRecord): Promise { + const combo = await repository.create(data); invalidateDbCache("combos"); backupDbFile("pre-write"); return combo; } -export async function updateCombo(id: string, data: JsonRecord) { - const db = getDbInstance(); - const existing = db - .prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE id = ?") - .get(id); - if (!existing) return null; +export async function updateCombo(id: string, data: ComboRecord): Promise { + const result = await repository.update(id, data); + if (!result) return null; - const current = parseComboRow(existing); - if (!current) return null; - const sortOrder = - typeof data.sortOrder === "number" - ? data.sortOrder - : typeof current.sortOrder === "number" - ? current.sortOrder - : getNextSortOrder(); - const merged: JsonRecord = { - ...current, - ...data, - sortOrder, - updatedAt: new Date().toISOString(), - }; - // Remove fields explicitly set to null (for deletion support) - for (const key of Object.keys(data)) { - if (data[key] === null) { - delete merged[key]; - } - } - const currentName = typeof current.name === "string" ? current.name : ""; - const nextName = - typeof merged["name"] === "string" && merged["name"].trim().length > 0 - ? merged["name"] - : currentName; - const normalizedMerged = normalizeStoredCombo({ ...merged, name: nextName }, db, [nextName]); - validateComboInvariant({ - ...normalizedMerged, - ...data, - name: nextName, - models: normalizedMerged.models, - }); - const contextCacheProtection = normalizedMerged.context_cache_protection ? 1 : 0; - - db.prepare( - "UPDATE combos SET name = ?, data = ?, sort_order = ?, updated_at = ?, context_cache_protection = ? WHERE id = ?" - ).run( - nextName, - JSON.stringify(normalizedMerged), - sortOrder, - normalizedMerged.updatedAt, - contextCacheProtection, - id - ); - - // Invalidate stale context-cache pins when combo targets change. - // Without this, sessions pinned to removed models keep routing there forever. - if (data.models !== undefined) { - const cleared = clearSessionModelHistoryForCombo(currentName); - if (cleared > 0) { - // Also clear under the new name if the combo was renamed - if (nextName !== currentName) { - clearSessionModelHistoryForCombo(nextName); - } + if (result.modelsFieldProvided) { + const cleared = clearSessionModelHistoryForCombo(result.previousName); + if (cleared > 0 && result.currentName !== result.previousName) { + clearSessionModelHistoryForCombo(result.currentName); } } invalidateDbCache("combos"); backupDbFile("pre-write"); - return normalizedMerged; + return result.combo; } -export async function reorderCombos(comboIds: string[]) { - const db = getDbInstance(); - const rows = db - .prepare( - "SELECT id, name, data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC" - ) - .all(); - if (rows.length === 0) return []; - - const existingIds = new Set( - rows - .map((row) => { - const record = asRecord(row); - return typeof record.id === "string" ? record.id : null; - }) - .filter((id): id is string => id !== null) - ); - - const seen = new Set(); - const requestedIds = comboIds.filter((id) => { - if (!existingIds.has(id) || seen.has(id)) return false; - seen.add(id); - return true; - }); - - const orderedIds = [ - ...requestedIds, - ...rows - .map((row) => { - const record = asRecord(row); - return typeof record.id === "string" ? record.id : null; - }) - .filter((id): id is string => id !== null && !seen.has(id)), - ]; - - const update = db.prepare( - "UPDATE combos SET data = ?, sort_order = ?, updated_at = ? WHERE id = ?" - ); - const now = new Date().toISOString(); - const rowById = new Map( - rows.map((row) => { - const record = asRecord(row); - return [String(record.id), row]; - }) - ); - const comboNames = rows - .map((row) => { - const combo = parseComboRow(row); - return combo && typeof combo.name === "string" ? combo.name.trim() : ""; - }) - .filter((name): name is string => name.length > 0); - - const reorderTransaction = db.transaction(() => { - orderedIds.forEach((id, index) => { - const row = rowById.get(id); - const combo = row ? parseComboRow(row) : null; - if (!combo) return; - const sortOrder = index + 1; - const updatedCombo = normalizeComboRecord( - { ...combo, sortOrder, updatedAt: now }, - { allCombos: comboNames } - ); - update.run(JSON.stringify(updatedCombo), sortOrder, now, id); - }); - }); - - reorderTransaction(); - invalidateDbCache("combos"); - backupDbFile("pre-write"); - return getCombos(); +export async function reorderCombos(comboIds: string[]): Promise { + const result = await repository.reorder(comboIds); + if (result.rowsReordered > 0) { + invalidateDbCache("combos"); + backupDbFile("pre-write"); + } + return result.combos; } -export async function deleteCombo(id: string) { - const db = getDbInstance(); - const result = db.prepare("DELETE FROM combos WHERE id = ?").run(id); - if (result.changes === 0) return false; +export async function deleteCombo(id: string): Promise { + const deleted = await repository.deleteById(id); + if (!deleted) return false; + invalidateDbCache("combos"); invalidateReasoningRoutingRuleCache(); backupDbFile("pre-write"); return true; } -export async function deleteComboByName(name: string) { - const combo = await getComboByName(name); +export async function deleteComboByName(name: string): Promise { + const combo = await repository.findByName(name); if (!combo || typeof combo.id !== "string") return false; return deleteCombo(combo.id); } -export function setActiveCombo(name: string, db = getDbInstance()) { +export function setActiveCombo(name: string, db = getDbInstance()): void { db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'activeCombo', ?)" ).run(JSON.stringify(name)); diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 76731147d9..50d874a3ba 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -13,6 +13,7 @@ import { openDatabaseAsync, } from "./adapters/driverFactory"; import path from "path"; +import { retryProbeIfTransient } from "./probeUtils"; import fs from "fs"; import { resolveWritableDataDir, getLegacyDotDataDir } from "../dataPaths"; import { runMigrations } from "./migrationRunner"; @@ -1142,18 +1143,19 @@ export function getDbInstance(): SqliteDatabase { `Original error: ${message}` ); } - preservedCriticalState = captureCriticalDbState(sqliteFile); - - // SAFETY: Never delete the database — rename to backup so data can be recovered. - // The old code would silently destroy all user data on any probe failure. - const failedPath = sqliteFile + `.probe-failed-${Date.now()}`; - try { - fs.renameSync(sqliteFile, failedPath); - console.warn(`[DB] Renamed corrupt DB to ${path.basename(failedPath)}`); - failedProbePath = failedPath; - failedProbeMessage = message; - } catch { - /* ok */ + if (!retryProbeIfTransient(sqliteFile, e, openSqliteDatabase, closeProbeIfSafe)) { + preservedCriticalState = captureCriticalDbState(sqliteFile); + // SAFETY: Never delete the database — rename to backup so data can be recovered. + // The old code would silently destroy all user data on any probe failure. + const failedPath = sqliteFile + `.probe-failed-${Date.now()}`; + try { + fs.renameSync(sqliteFile, failedPath); + console.warn(`[DB] Renamed corrupt DB to ${path.basename(failedPath)}`); + failedProbePath = failedPath; + failedProbeMessage = message; + } catch { + /* ok */ + } } } } diff --git a/src/lib/db/modelComboMappings.ts b/src/lib/db/modelComboMappings.ts index 41d9f1fe45..d3fc1c541c 100644 --- a/src/lib/db/modelComboMappings.ts +++ b/src/lib/db/modelComboMappings.ts @@ -1,249 +1,47 @@ /** - * db/modelComboMappings.ts — Per-model combo mapping CRUD + resolution. - * - * Maps model name patterns (glob-style wildcards) to specific combos. - * When a request arrives for a model string like "claude-sonnet-4", - * the resolver checks all enabled mappings (highest priority first) - * and returns the first matching combo. + * Compatibility facade for model-to-combo mapping persistence. */ -import { v4 as uuidv4 } from "uuid"; -import { getDbInstance } from "./core"; -import { globToRegex } from "@/shared/utils/globPattern"; +import type { + CreateModelComboMappingInput, + ModelComboMapping, + ModelComboMappingPage, + UpdateModelComboMappingInput, +} from "@/domain/persistence/comboRepositories"; +import { routingConfigRepositories } from "./repositories/routingConfigRepositories"; -// ────────────────────────────────────────────────────────── -// Types -// ────────────────────────────────────────────────────────── +export type { ModelComboMapping } from "@/domain/persistence/comboRepositories"; -export interface ModelComboMapping { - id: string; - pattern: string; - comboId: string; - comboName?: string; - priority: number; - enabled: boolean; - description: string; - createdAt: string; - updatedAt: string; -} +const repository = routingConfigRepositories.modelComboMappings; -interface MappingRow { - id: string; - pattern: string; - combo_id: string; - combo_name?: string; - priority: number; - enabled: number; - description: string; - created_at: string; - updated_at: string; -} - -// ────────────────────────────────────────────────────────── -// Row mapping -// ────────────────────────────────────────────────────────── - -function rowToMapping(row: MappingRow): ModelComboMapping { - return { - id: row.id, - pattern: row.pattern, - comboId: row.combo_id, - comboName: row.combo_name || undefined, - priority: row.priority, - enabled: row.enabled === 1, - description: row.description || "", - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -// ────────────────────────────────────────────────────────── -// CRUD -// ────────────────────────────────────────────────────────── - -/** - * List all model-combo mappings, joined with combo name. - * Ordered by priority descending (highest first). - */ -export async function getModelComboMappings(options?: { +export function getModelComboMappings(options?: { limit?: number; offset?: number; -}): Promise<{ items: ModelComboMapping[]; total: number }> { - const db = getDbInstance(); - const limit = options?.limit; - const offset = options?.offset ?? 0; - let sql = `SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name, - m.priority, m.enabled, m.description, - m.created_at, m.updated_at - FROM model_combo_mappings m - LEFT JOIN combos c ON c.id = m.combo_id - ORDER BY m.priority DESC, m.created_at ASC`; - const params: unknown[] = []; - if (limit !== undefined) { - sql += " LIMIT ? OFFSET ?"; - params.push(limit, offset); - } - const rows = db.prepare(sql).all(...params) as MappingRow[]; - const totalRow = db.prepare("SELECT count(*) as cnt FROM model_combo_mappings").get() as { - cnt: number; - }; - return { items: rows.map(rowToMapping), total: totalRow.cnt }; +}): Promise { + return repository.list(options); } -/** - * Get a single mapping by ID. - */ -export async function getModelComboMappingById(id: string): Promise { - const db = getDbInstance(); - const row = db - .prepare( - `SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name, - m.priority, m.enabled, m.description, - m.created_at, m.updated_at - FROM model_combo_mappings m - LEFT JOIN combos c ON c.id = m.combo_id - WHERE m.id = ?` - ) - .get(id) as MappingRow | undefined; - return row ? rowToMapping(row) : null; +export function getModelComboMappingById(id: string): Promise { + return repository.findById(id); } -/** - * Create a new model-combo mapping. - */ -export async function createModelComboMapping(data: { - pattern: string; - comboId: string; - priority?: number; - enabled?: boolean; - description?: string; -}): Promise { - const db = getDbInstance(); - const now = new Date().toISOString(); - const id = uuidv4(); - - db.prepare( - `INSERT INTO model_combo_mappings - (id, pattern, combo_id, priority, enabled, description, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)` - ).run( - id, - data.pattern, - data.comboId, - data.priority ?? 0, - data.enabled !== false ? 1 : 0, - data.description || "", - now, - now - ); - - return { - id, - pattern: data.pattern, - comboId: data.comboId, - priority: data.priority ?? 0, - enabled: data.enabled !== false, - description: data.description || "", - createdAt: now, - updatedAt: now, - }; +export function createModelComboMapping( + data: CreateModelComboMappingInput +): Promise { + return repository.create(data); } -/** - * Update an existing model-combo mapping. - */ -export async function updateModelComboMapping( +export function updateModelComboMapping( id: string, - data: Partial<{ - pattern: string; - comboId: string; - priority: number; - enabled: boolean; - description: string; - }> + data: UpdateModelComboMappingInput ): Promise { - const existing = await getModelComboMappingById(id); - if (!existing) return null; - - const db = getDbInstance(); - const now = new Date().toISOString(); - const updated = { - pattern: data.pattern ?? existing.pattern, - combo_id: data.comboId ?? existing.comboId, - priority: data.priority ?? existing.priority, - enabled: data.enabled !== undefined ? (data.enabled ? 1 : 0) : existing.enabled ? 1 : 0, - description: data.description ?? existing.description, - }; - - db.prepare( - `UPDATE model_combo_mappings - SET pattern = ?, combo_id = ?, priority = ?, enabled = ?, - description = ?, updated_at = ? - WHERE id = ?` - ).run( - updated.pattern, - updated.combo_id, - updated.priority, - updated.enabled, - updated.description, - now, - id - ); - - return getModelComboMappingById(id); + return repository.update(id, data); } -/** - * Delete a model-combo mapping. - */ -export async function deleteModelComboMapping(id: string): Promise { - const db = getDbInstance(); - const result = db.prepare("DELETE FROM model_combo_mappings WHERE id = ?").run(id); - return (result.changes ?? 0) > 0; +export function deleteModelComboMapping(id: string): Promise { + return repository.deleteById(id); } -// ────────────────────────────────────────────────────────── -// Core: Resolve combo for a model string -// ────────────────────────────────────────────────────────── - -/** - * Check if a model string matches any enabled model-combo mapping. - * Returns the full combo object if a match is found, null otherwise. - * - * Mappings are checked in priority order (highest first). - * Uses glob-style pattern matching (* = any chars, ? = single char). - */ -export async function resolveComboForModel( - modelStr: string -): Promise | null> { - const db = getDbInstance(); - - // Fetch enabled mappings, ordered by priority (highest first) - const rows = db - .prepare( - `SELECT m.pattern, m.combo_id, c.data AS combo_data - FROM model_combo_mappings m - JOIN combos c ON c.id = m.combo_id - WHERE m.enabled = 1 - ORDER BY m.priority DESC, m.created_at ASC` - ) - .all() as Array<{ pattern: string; combo_id: string; combo_data: string }>; - - for (const row of rows) { - const regex = globToRegex(row.pattern); - if (regex.test(modelStr)) { - try { - const combo = JSON.parse(row.combo_data) as Record; - if (combo.isActive === false) { - continue; - } - return combo; - } catch { - // Corrupted combo data — skip - continue; - } - } - } - - return null; +export function resolveComboForModel(model: string): Promise | null> { + return repository.resolveForModel(model); } diff --git a/src/lib/db/probeUtils.ts b/src/lib/db/probeUtils.ts new file mode 100644 index 0000000000..e5f2bd3885 --- /dev/null +++ b/src/lib/db/probeUtils.ts @@ -0,0 +1,96 @@ +/** + * Probe-retry utilities for the SQLite corruption-probe path in getDbInstance(). + * + * Transient probe errors (SQLITE_BUSY, ENOENT, SQLITE_PROTOCOL, SQLITE_IOERR) + * should be retried with backoff instead of immediately renaming the DB away + * and creating an empty one (data loss under concurrent load, #9541). + */ +import fs from "node:fs"; +import path from "node:path"; + +/** + * Identifies transient SQLite/OS probe errors that should be retried instead of + * triggering the corruption-rename path. + * + * Transient errors are conditions that can self-resolve within milliseconds: + * - SQLITE_BUSY: database is locked by another connection + * - SQLITE_PROTOCOL: locking protocol violation + * - SQLITE_IOERR: disk I/O error (can be transient under load) + * - ENOENT: file disappeared (race with another process/worker deleting it) + * + * Fatal errors (native load failures, OOM, module-not-found) are NOT transient. + */ +export function isTransientProbeError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /SQLITE_BUSY|SQLITE_PROTOCOL|SQLITE_IOERR|ENOENT/i.test(message); +} + +/** + * Synchronous sleep that blocks the event loop for `ms` milliseconds. + * Only used in the transient-probe-error retry path where we are already in + * a synchronous context (better-sqlite3). Uses `Atomics.wait` which yields to + * the OS scheduler during the wait, falling back to a busy-wait on runtimes + * where Atomics.wait is restricted. + */ +function syncSleep(ms: number): void { + if (typeof SharedArrayBuffer !== "undefined" && typeof Atomics !== "undefined") { + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + return; + } catch { + // Atomics.wait may throw on restricted runtimes — fall through to busy-wait + } + } + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + /* busy-wait */ + } +} + +/** + * Type for openSqliteDatabase callback — avoids importing the full SQLite adapter type. + */ +type OpenDbFn = ( + filePath: string, + options?: Record +) => { + driver: string; + open: boolean; + close(): void; +}; + +/** + * Retries opening a SQLite database probe when the initial attempt fails with + * a transient error. Uses exponential backoff (500ms, 1000ms, 2000ms). + * + * @param sqliteFile - Path to the SQLite database file + * @param openDb - Function to open the database (normally openSqliteDatabase) + * @param closeDb - Function to safely close the probe adapter + * @returns true if the retry succeeded (transient condition resolved) + * false if all retries were exhausted or error is non-transient + */ +export function retryProbeIfTransient( + sqliteFile: string, + probeError: unknown, + openDb: OpenDbFn, + closeDb: (adapter: { driver: string; open: boolean; close(): void } | null | undefined) => void +): boolean { + if (!isTransientProbeError(probeError)) return false; + + const retryDelays = [500, 1000, 2000]; + for (let i = 0; i < retryDelays.length; i++) { + syncSleep(retryDelays[i]); + try { + const retryAdapter = openDb(sqliteFile, { readonly: true }); + closeDb(retryAdapter); + return true; + } catch { + // Retry failed, try next delay + } + } + + console.warn( + `[DB] All ${retryDelays.length} transient probe retries exhausted — declaring corruption` + ); + return false; +} diff --git a/src/lib/db/repositories/routingConfigRepositories.ts b/src/lib/db/repositories/routingConfigRepositories.ts new file mode 100644 index 0000000000..0b44fb9fa6 --- /dev/null +++ b/src/lib/db/repositories/routingConfigRepositories.ts @@ -0,0 +1,32 @@ +import type { + ComboRepository, + ModelComboMappingRepository, +} from "@/domain/persistence/comboRepositories"; +import { + getCombosCount as getSqliteCombosCount, + sqliteComboRepository, +} from "./sqliteComboRepository"; +import { sqliteModelComboMappingRepository } from "./sqliteModelComboMappingRepository"; + +export interface RoutingConfigRepositories { + combos: ComboRepository; + modelComboMappings: ModelComboMappingRepository; + legacySync: { + getCombosCount(): number; + }; +} + +/** + * SQLite-only composition root for the first repository slice. + * + * Backend selection deliberately does not exist yet. Keeping the binding in one + * place prevents compatibility facades from constructing or reaching through a + * concrete driver when a later, separately approved backend is introduced. + */ +export const routingConfigRepositories: RoutingConfigRepositories = { + combos: sqliteComboRepository, + modelComboMappings: sqliteModelComboMappingRepository, + legacySync: { + getCombosCount: getSqliteCombosCount, + }, +}; diff --git a/src/lib/db/repositories/sqliteComboRepository.ts b/src/lib/db/repositories/sqliteComboRepository.ts new file mode 100644 index 0000000000..e7fb36c59b --- /dev/null +++ b/src/lib/db/repositories/sqliteComboRepository.ts @@ -0,0 +1,348 @@ +/** + * SQLite implementation of the combo repository contract. + */ + +import { v4 as uuidv4 } from "uuid"; +import type { + ComboReorderResult, + ComboRepository, + ComboUpdateResult, +} from "@/domain/persistence/comboRepositories"; +import { normalizeComboRecord } from "@/lib/combos/steps"; +import { validateComboInvariant } from "@/lib/combos/invariants"; +import { getDbInstance } from "../core"; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function getSerializedData(value: unknown): string | null { + const row = asRecord(value); + return typeof row.data === "string" ? row.data : null; +} + +function getSortOrder(value: unknown): number | null { + const row = asRecord(value); + return typeof row.sort_order === "number" ? row.sort_order : null; +} + +function withSortOrder(payload: string, sortOrder: number | null): JsonRecord { + const parsed = JSON.parse(payload) as JsonRecord; + if (typeof sortOrder === "number") { + parsed.sortOrder = sortOrder; + } + return parsed; +} + +function getComboNameSet( + db: ReturnType, + extraNames: string[] = [] +): Set { + const rows = db.prepare("SELECT name FROM combos").all(); + const names = new Set(); + + for (const row of rows) { + const record = asRecord(row); + if (typeof record.name === "string" && record.name.trim().length > 0) { + names.add(record.name.trim()); + } + } + + for (const name of extraNames) { + if (typeof name === "string" && name.trim().length > 0) { + names.add(name.trim()); + } + } + + return names; +} + +function normalizeStoredCombo( + combo: JsonRecord, + db: ReturnType, + extraNames: string[] = [] +): JsonRecord { + return normalizeComboRecord(combo, { + allCombos: getComboNameSet(db, extraNames), + }) as JsonRecord; +} + +function parseComboRow(row: unknown): JsonRecord | null { + const payload = getSerializedData(row); + if (!payload) return null; + const parsed = withSortOrder(payload, getSortOrder(row)); + // Merge deduplicated column values back into the record + const record = asRecord(row); + if (record.context_cache_protection !== undefined && record.context_cache_protection !== null) { + // Column is authoritative when explicitly enabled (1). + // When column is 0 (unset default) preserve the JSON blob value + // to avoid silently disabling the feature on pre-migration rows. + if (record.context_cache_protection === 1) { + parsed.context_cache_protection = true; + } + // Column is 0 — keep existing JSON blob value + } + return parsed; +} + +function getNextSortOrder() { + const db = getDbInstance(); + const row = db.prepare("SELECT COALESCE(MAX(sort_order), 0) AS sort_order FROM combos").get(); + const sortOrder = getSortOrder(row); + return (sortOrder ?? 0) + 1; +} + +export async function getCombos(limit?: number, offset?: number) { + const db = getDbInstance(); + let sql = + "SELECT data, sort_order, context_cache_protection FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC"; + const params: unknown[] = []; + if (limit !== undefined) { + sql += " LIMIT ? OFFSET ?"; + params.push(limit, offset ?? 0); + } + const rawCombos = db + .prepare(sql) + .all(...params) + .map((row) => parseComboRow(row)) + .filter((row): row is JsonRecord => row !== null); + + const comboNames = rawCombos + .map((combo) => (typeof combo.name === "string" ? combo.name.trim() : "")) + .filter((name): name is string => name.length > 0); + + return rawCombos.map((combo) => + normalizeComboRecord(combo, { + allCombos: comboNames, + }) + ); +} + +export function getCombosCount(): number { + const db = getDbInstance(); + const row = db.prepare("SELECT count(*) as cnt FROM combos").get() as { cnt: number }; + return row.cnt; +} + +export async function getComboById(id: string) { + const db = getDbInstance(); + const row = db + .prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE id = ?") + .get(id); + const combo = parseComboRow(row); + if (!combo) return null; + return normalizeStoredCombo(combo, db, typeof combo.name === "string" ? [combo.name] : []); +} + +export async function getComboByName(name: string) { + const db = getDbInstance(); + const row = db + .prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE name = ?") + .get(name); + const combo = parseComboRow(row); + if (!combo) return null; + return normalizeStoredCombo(combo, db, [name]); +} + +// #4446: case-insensitive name lookup. The opencode dispatch path forwards a +// lowercased combo slug (e.g. "master-light") for a combo provisioned as +// "MASTER-LIGHT"; the default BINARY collation of getComboByName misses it. +// Used only as a fallback after the exact match fails, so it cannot change the +// resolution of any combo that already resolves today. +export async function getComboByNameInsensitive(name: string) { + const db = getDbInstance(); + const row = db + .prepare( + "SELECT data, sort_order, context_cache_protection FROM combos WHERE name = ? COLLATE NOCASE" + ) + .get(name); + const combo = parseComboRow(row); + if (!combo) return null; + const storedName = typeof combo.name === "string" ? combo.name : name; + return normalizeStoredCombo(combo, db, [storedName]); +} + +export async function createCombo(data: JsonRecord) { + const db = getDbInstance(); + const now = new Date().toISOString(); + const sortOrder = typeof data.sortOrder === "number" ? data.sortOrder : getNextSortOrder(); + const comboId = typeof data.id === "string" && data.id.trim().length > 0 ? data.id : uuidv4(); + const combo = normalizeStoredCombo( + { + ...data, + id: comboId, + name: data.name, + models: data.models || [], + strategy: data.strategy || "priority", + config: data.config || {}, + isHidden: Boolean(data.isHidden), + sortOrder, + createdAt: now, + updatedAt: now, + }, + db, + typeof data.name === "string" ? [data.name] : [] + ); + + validateComboInvariant(combo); + const contextCache = data.context_cache_protection ? 1 : 0; + db.prepare( + "INSERT INTO combos (id, name, data, sort_order, created_at, updated_at, context_cache_protection) VALUES (?, ?, ?, ?, ?, ?, ?)" + ).run(combo.id, combo.name, JSON.stringify(combo), sortOrder, now, now, contextCache); + + return combo; +} + +export async function updateCombo(id: string, data: JsonRecord): Promise { + const db = getDbInstance(); + const existing = db + .prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE id = ?") + .get(id); + if (!existing) return null; + + const current = parseComboRow(existing); + if (!current) return null; + const sortOrder = + typeof data.sortOrder === "number" + ? data.sortOrder + : typeof current.sortOrder === "number" + ? current.sortOrder + : getNextSortOrder(); + const merged: JsonRecord = { + ...current, + ...data, + sortOrder, + updatedAt: new Date().toISOString(), + }; + // Remove fields explicitly set to null (for deletion support) + for (const key of Object.keys(data)) { + if (data[key] === null) { + delete merged[key]; + } + } + const currentName = typeof current.name === "string" ? current.name : ""; + const nextName = + typeof merged["name"] === "string" && merged["name"].trim().length > 0 + ? merged["name"] + : currentName; + const normalizedMerged = normalizeStoredCombo({ ...merged, name: nextName }, db, [nextName]); + validateComboInvariant({ + ...normalizedMerged, + ...data, + name: nextName, + models: normalizedMerged.models, + }); + const contextCacheProtection = normalizedMerged.context_cache_protection ? 1 : 0; + + db.prepare( + "UPDATE combos SET name = ?, data = ?, sort_order = ?, updated_at = ?, context_cache_protection = ? WHERE id = ?" + ).run( + nextName, + JSON.stringify(normalizedMerged), + sortOrder, + normalizedMerged.updatedAt, + contextCacheProtection, + id + ); + + return { + combo: normalizedMerged, + previousName: currentName, + currentName: nextName, + modelsFieldProvided: data.models !== undefined, + }; +} + +export async function reorderCombos(comboIds: string[]): Promise { + const db = getDbInstance(); + const rows = db + .prepare( + "SELECT id, name, data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC" + ) + .all(); + if (rows.length === 0) return { combos: [], rowsReordered: 0 }; + + const existingIds = new Set( + rows + .map((row) => { + const record = asRecord(row); + return typeof record.id === "string" ? record.id : null; + }) + .filter((id): id is string => id !== null) + ); + + const seen = new Set(); + const requestedIds = comboIds.filter((id) => { + if (!existingIds.has(id) || seen.has(id)) return false; + seen.add(id); + return true; + }); + + const orderedIds = [ + ...requestedIds, + ...rows + .map((row) => { + const record = asRecord(row); + return typeof record.id === "string" ? record.id : null; + }) + .filter((id): id is string => id !== null && !seen.has(id)), + ]; + + const update = db.prepare( + "UPDATE combos SET data = ?, sort_order = ?, updated_at = ? WHERE id = ?" + ); + const now = new Date().toISOString(); + const rowById = new Map( + rows.map((row) => { + const record = asRecord(row); + return [String(record.id), row]; + }) + ); + const comboNames = rows + .map((row) => { + const combo = parseComboRow(row); + return combo && typeof combo.name === "string" ? combo.name.trim() : ""; + }) + .filter((name): name is string => name.length > 0); + + const reorderTransaction = db.transaction(() => { + orderedIds.forEach((id, index) => { + const row = rowById.get(id); + const combo = row ? parseComboRow(row) : null; + if (!combo) return; + const sortOrder = index + 1; + const updatedCombo = normalizeComboRecord( + { ...combo, sortOrder, updatedAt: now }, + { allCombos: comboNames } + ); + update.run(JSON.stringify(updatedCombo), sortOrder, now, id); + }); + }); + + reorderTransaction(); + return { + combos: await getCombos(), + rowsReordered: orderedIds.length, + }; +} + +export async function deleteCombo(id: string) { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM combos WHERE id = ?").run(id); + if (result.changes === 0) return false; + return true; +} + +export const sqliteComboRepository: ComboRepository = { + list: getCombos, + count: async () => getCombosCount(), + findById: getComboById, + findByName: getComboByName, + findByNameInsensitive: getComboByNameInsensitive, + create: createCombo, + update: updateCombo, + reorder: reorderCombos, + deleteById: deleteCombo, +}; diff --git a/src/lib/db/repositories/sqliteModelComboMappingRepository.ts b/src/lib/db/repositories/sqliteModelComboMappingRepository.ts new file mode 100644 index 0000000000..d9aa223415 --- /dev/null +++ b/src/lib/db/repositories/sqliteModelComboMappingRepository.ts @@ -0,0 +1,244 @@ +/** + * SQLite implementation of per-model combo mapping persistence and resolution. + * + * Maps model name patterns (glob-style wildcards) to specific combos. + * When a request arrives for a model string like "claude-sonnet-4", + * the resolver checks all enabled mappings (highest priority first) + * and returns the first matching combo. + */ + +import { v4 as uuidv4 } from "uuid"; +import type { + CreateModelComboMappingInput, + ModelComboMapping, + ModelComboMappingRepository, + UpdateModelComboMappingInput, +} from "@/domain/persistence/comboRepositories"; +import { globToRegex } from "@/shared/utils/globPattern"; +import { getDbInstance } from "../core"; + +export type { ModelComboMapping } from "@/domain/persistence/comboRepositories"; + +// ────────────────────────────────────────────────────────── +// Types +// ────────────────────────────────────────────────────────── + +interface MappingRow { + id: string; + pattern: string; + combo_id: string; + combo_name?: string; + priority: number; + enabled: number; + description: string; + created_at: string; + updated_at: string; +} + +// ────────────────────────────────────────────────────────── +// Row mapping +// ────────────────────────────────────────────────────────── + +function rowToMapping(row: MappingRow): ModelComboMapping { + return { + id: row.id, + pattern: row.pattern, + comboId: row.combo_id, + comboName: row.combo_name || undefined, + priority: row.priority, + enabled: row.enabled === 1, + description: row.description || "", + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +// ────────────────────────────────────────────────────────── +// CRUD +// ────────────────────────────────────────────────────────── + +/** + * List all model-combo mappings, joined with combo name. + * Ordered by priority descending (highest first). + */ +export async function getModelComboMappings(options?: { + limit?: number; + offset?: number; +}): Promise<{ items: ModelComboMapping[]; total: number }> { + const db = getDbInstance(); + const limit = options?.limit; + const offset = options?.offset ?? 0; + let sql = `SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name, + m.priority, m.enabled, m.description, + m.created_at, m.updated_at + FROM model_combo_mappings m + LEFT JOIN combos c ON c.id = m.combo_id + ORDER BY m.priority DESC, m.created_at ASC`; + const params: unknown[] = []; + if (limit !== undefined) { + sql += " LIMIT ? OFFSET ?"; + params.push(limit, offset); + } + const rows = db.prepare(sql).all(...params) as MappingRow[]; + const totalRow = db.prepare("SELECT count(*) as cnt FROM model_combo_mappings").get() as { + cnt: number; + }; + return { items: rows.map(rowToMapping), total: totalRow.cnt }; +} + +/** + * Get a single mapping by ID. + */ +export async function getModelComboMappingById(id: string): Promise { + const db = getDbInstance(); + const row = db + .prepare( + `SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name, + m.priority, m.enabled, m.description, + m.created_at, m.updated_at + FROM model_combo_mappings m + LEFT JOIN combos c ON c.id = m.combo_id + WHERE m.id = ?` + ) + .get(id) as MappingRow | undefined; + return row ? rowToMapping(row) : null; +} + +/** + * Create a new model-combo mapping. + */ +export async function createModelComboMapping( + data: CreateModelComboMappingInput +): Promise { + const db = getDbInstance(); + const now = new Date().toISOString(); + const id = uuidv4(); + + db.prepare( + `INSERT INTO model_combo_mappings + (id, pattern, combo_id, priority, enabled, description, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + data.pattern, + data.comboId, + data.priority ?? 0, + data.enabled !== false ? 1 : 0, + data.description || "", + now, + now + ); + + return { + id, + pattern: data.pattern, + comboId: data.comboId, + priority: data.priority ?? 0, + enabled: data.enabled !== false, + description: data.description || "", + createdAt: now, + updatedAt: now, + }; +} + +/** + * Update an existing model-combo mapping. + */ +export async function updateModelComboMapping( + id: string, + data: UpdateModelComboMappingInput +): Promise { + const existing = await getModelComboMappingById(id); + if (!existing) return null; + + const db = getDbInstance(); + const now = new Date().toISOString(); + const updated = { + pattern: data.pattern ?? existing.pattern, + combo_id: data.comboId ?? existing.comboId, + priority: data.priority ?? existing.priority, + enabled: data.enabled !== undefined ? (data.enabled ? 1 : 0) : existing.enabled ? 1 : 0, + description: data.description ?? existing.description, + }; + + db.prepare( + `UPDATE model_combo_mappings + SET pattern = ?, combo_id = ?, priority = ?, enabled = ?, + description = ?, updated_at = ? + WHERE id = ?` + ).run( + updated.pattern, + updated.combo_id, + updated.priority, + updated.enabled, + updated.description, + now, + id + ); + + return getModelComboMappingById(id); +} + +/** + * Delete a model-combo mapping. + */ +export async function deleteModelComboMapping(id: string): Promise { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM model_combo_mappings WHERE id = ?").run(id); + return (result.changes ?? 0) > 0; +} + +// ────────────────────────────────────────────────────────── +// Core: Resolve combo for a model string +// ────────────────────────────────────────────────────────── + +/** + * Check if a model string matches any enabled model-combo mapping. + * Returns the full combo object if a match is found, null otherwise. + * + * Mappings are checked in priority order (highest first). + * Uses glob-style pattern matching (* = any chars, ? = single char). + */ +export async function resolveComboForModel( + modelStr: string +): Promise | null> { + const db = getDbInstance(); + + // Fetch enabled mappings, ordered by priority (highest first) + const rows = db + .prepare( + `SELECT m.pattern, m.combo_id, c.data AS combo_data + FROM model_combo_mappings m + JOIN combos c ON c.id = m.combo_id + WHERE m.enabled = 1 + ORDER BY m.priority DESC, m.created_at ASC` + ) + .all() as Array<{ pattern: string; combo_id: string; combo_data: string }>; + + for (const row of rows) { + const regex = globToRegex(row.pattern); + if (regex.test(modelStr)) { + try { + const combo = JSON.parse(row.combo_data) as Record; + if (combo.isActive === false) { + continue; + } + return combo; + } catch { + // Corrupted combo data — skip + continue; + } + } + } + + return null; +} + +export const sqliteModelComboMappingRepository: ModelComboMappingRepository = { + list: getModelComboMappings, + findById: getModelComboMappingById, + create: createModelComboMapping, + update: updateModelComboMapping, + deleteById: deleteModelComboMapping, + resolveForModel: resolveComboForModel, +}; diff --git a/src/mitm/manager.ts b/src/mitm/manager.ts index 4f9bae1b90..452f8d15e5 100644 --- a/src/mitm/manager.ts +++ b/src/mitm/manager.ts @@ -234,8 +234,12 @@ const urlPath = ? decodeURIComponent(MITM_SERVER_URL.pathname.slice(1)) : decodeURIComponent(MITM_SERVER_URL.pathname); -const cwdPath = path.join(process.cwd(), "src", "mitm", "server.cjs"); -const MITM_SERVER_PATH = fs.existsSync(cwdPath) ? cwdPath : urlPath; +// Lazy-resolve to avoid module-level fs.existsSync + process.cwd() at module scope, +// which causes Turbopack's NFT tracer to follow the path into the entire src/ tree. +function resolveMitmServerPath(): string { + const cwdPath = path.join(/* turbopackIgnore: true */ process.cwd(), "src", "mitm", "server.cjs"); + return fs.existsSync(cwdPath) ? cwdPath : urlPath; +} // Check if a PID is alive function isProcessAlive(pid: number): boolean { @@ -607,7 +611,7 @@ async function startMitmInternal( } } - serverProcess = spawn(process.execPath, [MITM_SERVER_PATH], { + serverProcess = spawn(process.execPath, [resolveMitmServerPath()], { windowsHide: true, env: { ...process.env, diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index b3b5cffee5..2a562dce13 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -528,9 +528,6 @@ const getExpectedParentPaths = (): string[] => { ].filter(Boolean); }; -// Cache expected parent paths at module startup (avoid recalculation on every checkKnownPath call) -const EXPECTED_PARENT_PATHS = getExpectedParentPaths(); - const getExtraPaths = () => String(process.env.CLI_EXTRA_PATHS || "") .split(path.delimiter) @@ -820,7 +817,7 @@ export const checkKnownPath = async (commandPath: string) => { const isWithinExpected = await isLocationTrusted( commandPath, realPath, - EXPECTED_PARENT_PATHS, + getExpectedParentPaths(), isPathWithin, fs.realpath ); diff --git a/tests/helpers/persistence/comboRepositoryConformance.ts b/tests/helpers/persistence/comboRepositoryConformance.ts new file mode 100644 index 0000000000..e80af670fd --- /dev/null +++ b/tests/helpers/persistence/comboRepositoryConformance.ts @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { + ComboRepository, + ModelComboMappingRepository, +} from "../../../src/domain/persistence/comboRepositories.ts"; + +export interface ComboRepositoryHarness { + combos: ComboRepository; + mappings: ModelComboMappingRepository; + reset(): Promise; + corruptComboPayload(comboId: string): Promise; +} + +export function registerComboRepositoryConformance( + createHarness: () => Promise +): void { + test("combo repository: CRUD, defaults, lookup, count, and pagination", async () => { + const harness = await createHarness(); + await harness.reset(); + + const zulu = await harness.combos.create({ + name: "Zulu", + models: [{ provider: "openai", model: "gpt-4.1" }], + }); + const alpha = await harness.combos.create({ + name: "Alpha", + models: [{ provider: "anthropic", model: "claude-3-7-sonnet" }], + }); + + assert.equal(zulu.version, 2); + assert.equal(zulu.strategy, "priority"); + assert.equal(zulu.sortOrder, 1); + assert.equal(alpha.sortOrder, 2); + assert.equal(await harness.combos.count(), 2); + assert.deepEqual(await harness.combos.findById(String(zulu.id)), zulu); + assert.deepEqual(await harness.combos.findByName("Zulu"), zulu); + assert.equal(await harness.combos.findByName("zulu"), null); + assert.deepEqual(await harness.combos.findByNameInsensitive("zulu"), zulu); + + const page = await harness.combos.list(1, 1); + assert.deepEqual( + page.map((combo) => combo.name), + ["Alpha"] + ); + }); + + test("combo repository: partial update, explicit null deletion, and missing rows", async () => { + const harness = await createHarness(); + await harness.reset(); + + const created = await harness.combos.create({ + name: "Mutable", + description: "remove me", + models: [{ provider: "openai", model: "gpt-4.1" }], + config: { retries: 1 }, + }); + + const updateResult = await harness.combos.update(String(created.id), { + description: null, + strategy: "round-robin", + config: { retries: 3 }, + }); + + assert.ok(updateResult); + const updated = updateResult.combo; + assert.equal(updated.id, created.id); + assert.equal(updated.name, "Mutable"); + assert.equal("description" in updated, false); + assert.equal(updated.strategy, "round-robin"); + assert.deepEqual(updated.config, { retries: 3 }); + assert.equal(updateResult.previousName, "Mutable"); + assert.equal(updateResult.currentName, "Mutable"); + assert.equal(updateResult.modelsFieldProvided, false); + assert.equal(await harness.combos.update("missing", { strategy: "priority" }), null); + }); + + test("combo repository: reorder is atomic and delete reports affected-row semantics", async () => { + const harness = await createHarness(); + await harness.reset(); + + const alpha = await harness.combos.create({ + name: "Alpha", + models: [{ provider: "openai", model: "gpt-4.1" }], + }); + const bravo = await harness.combos.create({ + name: "Bravo", + models: [{ provider: "anthropic", model: "claude-3-7-sonnet" }], + }); + const charlie = await harness.combos.create({ + name: "Charlie", + models: [{ provider: "google", model: "gemini-2.5-pro" }], + }); + + const reorderResult = await harness.combos.reorder([ + String(charlie.id), + "unknown", + String(charlie.id), + String(alpha.id), + ]); + const reordered = reorderResult.combos; + assert.equal(reorderResult.rowsReordered, 3); + assert.deepEqual( + reordered.map((combo) => combo.name), + ["Charlie", "Alpha", "Bravo"] + ); + assert.deepEqual( + reordered.map((combo) => combo.sortOrder), + [1, 2, 3] + ); + + assert.equal(await harness.combos.deleteById("missing"), false); + assert.equal(await harness.combos.deleteById(String(bravo.id)), true); + assert.equal(await harness.combos.deleteById(String(bravo.id)), false); + + await harness.corruptComboPayload(String(alpha.id)); + await harness.corruptComboPayload(String(charlie.id)); + const corruptResult = await harness.combos.reorder([String(alpha.id), String(charlie.id)]); + assert.equal(corruptResult.rowsReordered, 2); + assert.deepEqual(corruptResult.combos, []); + }); + + test("model mapping repository: CRUD, ordering, pagination, and atomic cascade", async () => { + const harness = await createHarness(); + await harness.reset(); + + const comboA = await harness.combos.create({ + name: "alpha", + models: [{ provider: "openai", model: "gpt-4o" }], + }); + const comboB = await harness.combos.create({ + name: "beta", + models: [{ provider: "openai", model: "gpt-4o-mini" }], + }); + + const first = await harness.mappings.create({ + pattern: "gpt-*", + comboId: String(comboA.id), + priority: 20, + description: "primary", + }); + const second = await harness.mappings.create({ + pattern: "claude-*", + comboId: String(comboB.id), + priority: 10, + enabled: false, + }); + + const all = await harness.mappings.list(); + assert.equal(all.total, 2); + assert.deepEqual( + all.items.map((mapping) => mapping.id), + [first.id, second.id] + ); + assert.equal(all.items[0].comboName, "alpha"); + assert.equal(all.items[0].enabled, true); + assert.equal(all.items[1].comboName, "beta"); + assert.equal(all.items[1].enabled, false); + + const page = await harness.mappings.list({ limit: 1, offset: 1 }); + assert.equal(page.total, 2); + assert.deepEqual( + page.items.map((mapping) => mapping.id), + [second.id] + ); + + const updated = await harness.mappings.update(first.id, { + pattern: "openai/*", + comboId: String(comboB.id), + enabled: false, + description: "rerouted", + }); + assert.ok(updated); + assert.equal(updated.pattern, "openai/*"); + assert.equal(updated.comboId, comboB.id); + assert.equal(updated.comboName, "beta"); + assert.equal(updated.enabled, false); + assert.equal(updated.description, "rerouted"); + assert.equal(await harness.mappings.update("missing", { pattern: "*" }), null); + + assert.equal(await harness.mappings.deleteById(first.id), true); + assert.equal(await harness.mappings.deleteById(first.id), false); + + // The SQLite foreign key performs the combo + related mapping removal in + // one statement/transaction; portable backends must preserve that behavior. + assert.equal(await harness.combos.deleteById(String(comboB.id)), true); + assert.equal(await harness.mappings.findById(second.id), null); + assert.equal((await harness.mappings.list()).total, 0); + }); + + test("model mapping repository: resolution skips disabled, inactive, and corrupt combos", async () => { + const harness = await createHarness(); + await harness.reset(); + + const broken = await harness.combos.create({ + name: "broken", + models: [{ provider: "openai", model: "gpt-4o" }], + }); + const inactive = await harness.combos.create({ + name: "inactive", + models: [{ provider: "openai", model: "gpt-4.1" }], + isActive: false, + }); + const selected = await harness.combos.create({ + name: "selected", + models: [{ provider: "openai", model: "gpt-4o-mini" }], + }); + + await harness.mappings.create({ + pattern: "gpt-*", + comboId: String(broken.id), + priority: 30, + }); + await harness.mappings.create({ + pattern: "gpt-*", + comboId: String(inactive.id), + priority: 20, + }); + await harness.mappings.create({ + pattern: "gpt-*", + comboId: String(selected.id), + priority: 10, + }); + await harness.mappings.create({ + pattern: "gpt-*", + comboId: String(selected.id), + priority: 100, + enabled: false, + }); + + assert.ok(harness.corruptComboPayload); + await harness.corruptComboPayload(String(broken.id)); + + const resolved = await harness.mappings.resolveForModel("gpt-4o"); + assert.ok(resolved); + assert.equal(resolved.name, "selected"); + assert.equal(await harness.mappings.resolveForModel("claude-sonnet"), null); + }); +} diff --git a/tests/unit/9536-usage-misreporting-openai-to-claude.test.ts b/tests/unit/9536-usage-misreporting-openai-to-claude.test.ts new file mode 100644 index 0000000000..0605a54c56 --- /dev/null +++ b/tests/unit/9536-usage-misreporting-openai-to-claude.test.ts @@ -0,0 +1,160 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts"; +import { FORMATS } from "../../open-sse/translator/formats.ts"; + +/** + * Test for #9536: Usage misreported on OpenAI-shaped upstreams when translating + * to Claude format (non-streaming path). + * + * Two defects: + * 1. cache_read_input_tokens is always 0 (missing mapping) + * 2. input_tokens is inflated by cached tokens (not subtracting prompt_tokens_details.cached_tokens) + * + * Plus regression guard for #8331 (buffer isolation via context_budget_* fields). + */ + +const DEEPSEEK_OPENAI_RESPONSE = { + id: "chatcmpl-deepseek-abc123", + object: "chat.completion", + model: "deepseek/deepseek-v4-flash", + choices: [ + { + index: 0, + message: { role: "assistant", content: "I am an AI assistant." }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 4364, + prompt_tokens_details: { cached_tokens: 4352 }, + prompt_cache_hit_tokens: 4352, + prompt_cache_miss_tokens: 12, + completion_tokens: 27, + total_tokens: 4391, + }, +}; + +const DEEPSEEK_OPENAI_RESPONSE_NO_CACHE = { + id: "chatcmpl-deepseek-no-cache", + object: "chat.completion", + model: "deepseek/deepseek-v4-flash", + choices: [ + { + index: 0, + message: { role: "assistant", content: "Hello." }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 125, + prompt_tokens_details: {}, + completion_tokens: 5, + total_tokens: 130, + }, +}; + +/** + * OpenAI response that (before #8331's context_budget_* fix) would have had + * input_tokens += buffer. After #8331, the buffer values go into + * context_budget_* fields that filterUsageForFormat strips. + */ +const RESPONSE_WITH_BUFFER = { + id: "chatcmpl-buffer-test", + object: "chat.completion", + model: "gpt-4o", + choices: [ + { + index: 0, + message: { role: "assistant", content: "Hello." }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 50, + completion_tokens: 10, + total_tokens: 60, + }, +}; + +describe("9536 - usage misreporting OpenAI->Claude (non-streaming)", () => { + it("Defect 1: cache_read_input_tokens should be present when cached_tokens > 0", () => { + const result = translateNonStreamingResponse( + DEEPSEEK_OPENAI_RESPONSE, + FORMATS.OPENAI, + FORMATS.CLAUDE + ); + + const usage = (result as Record).usage as Record; + + // cache_read_input_tokens should be mapped from prompt_tokens_details.cached_tokens + assert.equal( + usage.cache_read_input_tokens, + 4352, + `cache_read_input_tokens = ${usage.cache_read_input_tokens} (expected 4352)` + ); + }); + + it("Defect 2: input_tokens should be prompt_tokens minus cached tokens", () => { + const result = translateNonStreamingResponse( + DEEPSEEK_OPENAI_RESPONSE, + FORMATS.OPENAI, + FORMATS.CLAUDE + ); + + const usage = (result as Record).usage as Record; + + // input_tokens = prompt_tokens(4364) - cached_tokens(4352) = 12 + assert.equal(usage.input_tokens, 12, `input_tokens = ${usage.input_tokens} (expected 12)`); + }); + + it("Regression guard #8331: buffer should NOT inflate input_tokens", () => { + const result = translateNonStreamingResponse( + RESPONSE_WITH_BUFFER, + FORMATS.OPENAI, + FORMATS.CLAUDE + ); + + const usage = (result as Record).usage as Record; + + // input_tokens should be exactly prompt_tokens (50), no buffer added + assert.equal(usage.input_tokens, 50, `input_tokens = ${usage.input_tokens} (expected 50)`); + + // No context_budget_* fields should leak into the translated response + assert.equal(usage.context_budget_remaining, undefined); + assert.equal(usage.context_budget_consume, undefined); + assert.equal(usage.context_budget_add, undefined); + }); + + it("No cache data: input_tokens unchanged, no cache_read_input_tokens", () => { + const result = translateNonStreamingResponse( + DEEPSEEK_OPENAI_RESPONSE_NO_CACHE, + FORMATS.OPENAI, + FORMATS.CLAUDE + ); + + const usage = (result as Record).usage as Record; + + // Without cached_tokens, input_tokens = prompt_tokens = 125 + assert.equal(usage.input_tokens, 125, `input_tokens = ${usage.input_tokens} (expected 125)`); + + // cache_read_input_tokens should NOT be present when there's no caching + assert.equal(usage.cache_read_input_tokens, undefined); + }); + + it("Pass-through: same format returns usage unchanged", () => { + // When source === target, the function returns the response as-is + const result = translateNonStreamingResponse( + DEEPSEEK_OPENAI_RESPONSE, + FORMATS.OPENAI, + FORMATS.OPENAI + ); + + const usage = (result as Record).usage as Record; + + // OpenAI format should preserve all fields, including cached_tokens + assert.equal(usage.prompt_tokens, 4364); + assert.equal(usage.completion_tokens, 27); + assert.ok(usage.prompt_tokens_details, "prompt_tokens_details should be preserved"); + }); +}); diff --git a/tests/unit/9545-gpt56-reasoning-tools.test.ts b/tests/unit/9545-gpt56-reasoning-tools.test.ts new file mode 100644 index 0000000000..4899e59f1f --- /dev/null +++ b/tests/unit/9545-gpt56-reasoning-tools.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; + +describe("Issue #9545 — GPT-5.6 URL routing + reasoning_effort with tools", () => { + it("getModelTargetFormat should resolve gpt-5.6-luna with and without provider prefix", async () => { + const { getModelTargetFormat } = await import("../../open-sse/config/providerModels.ts"); + assert.strictEqual(getModelTargetFormat("openai", "gpt-5.6-luna"), "openai-responses"); + assert.strictEqual(getModelTargetFormat("openai", "openai/gpt-5.6-luna"), "openai-responses"); + }); + + it("getModelTargetFormat should resolve non-prefixed models unchanged", async () => { + const { getModelTargetFormat } = await import("../../open-sse/config/providerModels.ts"); + assert.strictEqual(getModelTargetFormat("openai", "gpt-4o"), null); + assert.strictEqual(getModelTargetFormat("openai", "gpt-5.5-pro"), "openai-responses"); + assert.strictEqual(getModelTargetFormat("openai", "openai/gpt-5.5-pro"), "openai-responses"); + }); + + it("stripGpt5ReasoningWhenTools should not strip when targetFormat=openai-responses", async () => { + const { stripGpt5ReasoningWhenTools } = + await import("../../open-sse/services/gpt5SamplingGuard.ts"); + const body = { + model: "gpt-5.6-luna", + tools: [{ type: "function", function: { name: "test" } }], + reasoning_effort: "high", + }; + const r = stripGpt5ReasoningWhenTools(body, "openai", "gpt-5.6-luna", "openai-responses", null); + assert.strictEqual(r.reasoning_effort, "high"); + }); +}); diff --git a/tests/unit/9551-proxyfetch-no-proxy-context-bypass.test.ts b/tests/unit/9551-proxyfetch-no-proxy-context-bypass.test.ts new file mode 100644 index 0000000000..04a3fd2b17 --- /dev/null +++ b/tests/unit/9551-proxyfetch-no-proxy-context-bypass.test.ts @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { runWithProxyContext, resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; + +async function withEnv( + overrides: Record, + fn: () => unknown +): Promise { + const previous = new Map(); + + for (const [key, value] of Object.entries(overrides)) { + previous.set(key, process.env[key]); + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + try { + return await fn(); + } finally { + for (const [key, value] of previous.entries()) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +test("[9551] BUG: context-proxy ignores NO_PROXY for non-local domains", async () => { + await withEnv( + { + NO_PROXY: "ark.cn-beijing.volces.com", + HTTP_PROXY: undefined, + }, + async () => { + await runWithProxyContext({ type: "http", host: "127.0.0.1", port: 7897 }, () => { + const resolved = resolveProxyForRequest("https://ark.cn-beijing.volces.com/api/v3/models"); + assert.equal(resolved.source, "direct", "NO_PROXY should bypass context proxy"); + }); + } + ); +}); + +test("[9551] resolveProxyForRequest: context-proxy respects NO_PROXY=*", async () => { + await withEnv( + { + NO_PROXY: "*", + HTTP_PROXY: undefined, + }, + async () => { + await runWithProxyContext({ type: "http", host: "127.0.0.1", port: 7897 }, () => { + const resolved = resolveProxyForRequest("https://api.openai.com/v1/chat/completions"); + assert.equal(resolved.source, "direct", "NO_PROXY=* should bypass context proxy"); + }); + } + ); +}); diff --git a/tests/unit/9560-turbopack-nft-lazy-module-fs.test.ts b/tests/unit/9560-turbopack-nft-lazy-module-fs.test.ts new file mode 100644 index 0000000000..444c8efae7 --- /dev/null +++ b/tests/unit/9560-turbopack-nft-lazy-module-fs.test.ts @@ -0,0 +1,52 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; + +// The bug: module-level fs.existsSync(path.join(process.cwd(), ...)) calls cause +// Turbopack's NFT tracer to follow paths into the entire src/ tree, producing +// "Encountered unexpected file in NFT list" warnings during build. +// +// Fix: Move module-level fs/process.cwd calls to lazy functions so they are +// invoked from route handlers (not at module scope), letting the NFT tracer +// skip them during build. + +describe("#9560 — Turbopack NFT guard: lazy module-level fs resolution", () => { + it("MITM lazy resolver returns a non-empty string path", async () => { + // resolveMitmServerPath() is not exported — test through the module's + // startMitm-like path by exercising the lazy resolution indirectly. + // Import the MITM module to verify it loads without module-level fs calls. + const mitm = await import("../../src/mitm/manager.ts"); + // The module should export functions; just confirm it loaded cleanly. + assert.ok(typeof mitm.startMitm === "function"); + assert.ok(typeof mitm.getMitmStatus === "function"); + }); + + it("cliRuntime exports known path check function", async () => { + // Verify cliRuntime imports without module-level getExpectedParentPaths call. + const cliRuntime = await import("../../src/shared/services/cliRuntime.ts"); + assert.ok(typeof cliRuntime.checkKnownPath === "function"); + }); + + it("known path check produces deterministic result for a known-bad input", async () => { + const { checkKnownPath } = await import("../../src/shared/services/cliRuntime.ts"); + // A relative path is rejected without hitting any expected-parent-paths logic. + const result = await checkKnownPath("../evil"); + assert.equal(result.installed, false); + assert.equal(result.reason, "not_absolute"); + }); + + it("known path check rejects path with dangerous characters", async () => { + const { checkKnownPath } = await import("../../src/shared/services/cliRuntime.ts"); + const result = await checkKnownPath("/tmp/foo;$PATH"); + assert.equal(result.installed, false); + assert.equal(result.reason, "unsafe_path"); + }); + + it("getExpectedParentPathsCached returns same shape as direct call", async () => { + // getExpectedParentPaths is module-internal, but we can indirectly verify + // that cliRuntime's known-path logic reaches it by checking that absolute + // paths to known-locations like /usr/bin/env resolve correctly. + const { checkKnownPath } = await import("../../src/shared/services/cliRuntime.ts"); + await assert.doesNotReject(checkKnownPath("/usr/bin/env")); + }); +}); diff --git a/tests/unit/9568-gemini-tool-casing-mismatch.test.ts b/tests/unit/9568-gemini-tool-casing-mismatch.test.ts new file mode 100644 index 0000000000..854fe77f41 --- /dev/null +++ b/tests/unit/9568-gemini-tool-casing-mismatch.test.ts @@ -0,0 +1,138 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { geminiToOpenAIResponse } = + await import("../../open-sse/translator/response/gemini-to-openai.ts"); +const { geminiToClaudeResponse } = + await import("../../open-sse/translator/response/gemini-to-claude.ts"); + +function flatten(items) { + return items.flatMap((item) => item || []); +} + +// ── Gemini -> OpenAI tool name casing fix (#9568) ────────────────────── + +test("gemini-to-openai: no toolNameMap — Gemini returns lowercase 'bash', translator outputs 'bash' (bug)", () => { + const state = { toolCalls: new Map(), toolNameMap: null }; + const result = geminiToOpenAIResponse( + { + responseId: "resp-9568-1", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: "bash", args: { code: "echo hi" } }, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + state + ); + + const toolCall = result.find((c) => c.choices?.[0]?.delta?.tool_calls); + const name = toolCall?.choices?.[0]?.delta?.tool_calls?.[0]?.function?.name; + assert.equal(name, "bash", "Without toolNameMap, lowercase tool name should pass through as-is"); +}); + +test("gemini-to-openai: toolNameMap has lowercase alias — Gemini returns 'bash', translator outputs 'Bash' (fix)", () => { + const state = { + toolCalls: new Map(), + toolNameMap: new Map([["bash", "Bash"]]), + }; + const result = geminiToOpenAIResponse( + { + responseId: "resp-9568-2", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: "bash", args: { code: "echo hi" } }, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + state + ); + + const toolCall = result.find((c) => c.choices?.[0]?.delta?.tool_calls); + const name = toolCall?.choices?.[0]?.delta?.tool_calls?.[0]?.function?.name; + assert.equal( + name, + "Bash", + "With toolNameMap={{'bash','Bash'}}, lowercase tool name should be restored to TitleCase" + ); +}); + +// ── Gemini -> Claude tool name casing fix (#9568) ────────────────────── + +test("gemini-to-claude: no toolNameMap — Gemini returns 'bash', translator outputs 'bash' (bug)", () => { + const state = {}; + const result = geminiToClaudeResponse( + { + responseId: "resp-9568-3", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: "bash", args: { code: "echo hi" } }, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + state + ); + + const toolUse = result.find((c) => c.type === "content_block_start"); + assert.equal( + toolUse?.content_block?.name, + "bash", + "Without toolNameMap, lowercase tool name should pass through as-is (gemini-to-claude)" + ); +}); + +test("gemini-to-claude: toolNameMap has lowercase alias — Gemini returns 'bash', translator outputs 'Bash' (fix)", () => { + const state = { + toolNameMap: new Map([["bash", "Bash"]]), + }; + const result = geminiToClaudeResponse( + { + responseId: "resp-9568-4", + modelVersion: "gemini-2.5-pro", + candidates: [ + { + content: { + parts: [ + { + functionCall: { name: "bash", args: { code: "echo hi" } }, + }, + ], + }, + finishReason: "STOP", + }, + ], + }, + state + ); + + const toolUse = result.find((c) => c.type === "content_block_start"); + assert.equal( + toolUse?.content_block?.name, + "Bash", + "With toolNameMap={{'bash','Bash'}}, lowercase tool name should be restored to TitleCase without normalizeToolName reversing it" + ); +}); diff --git a/tests/unit/agentrouter-chatcore-protocols.test.ts b/tests/unit/agentrouter-chatcore-protocols.test.ts index dc4b7dd3c2..e572de4f60 100644 --- a/tests/unit/agentrouter-chatcore-protocols.test.ts +++ b/tests/unit/agentrouter-chatcore-protocols.test.ts @@ -29,14 +29,14 @@ function createSseResponse(events: string[]) { }); } -async function waitForAsyncSideEffects() { - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setTimeout(resolve, 20)); +async function flushAsyncSideEffects() { + // setImmediate rounds drain the event loop more reliably than setTimeout under CI load. + for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve)); } test.afterEach(async () => { globalThis.fetch = originalFetch; - await waitForAsyncSideEffects(); + await flushAsyncSideEffects(); core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); diff --git a/tests/unit/authz/probe-9033-repro.test.ts b/tests/unit/authz/probe-9033-repro.test.ts index 6044de755e..29e44ebde8 100644 --- a/tests/unit/authz/probe-9033-repro.test.ts +++ b/tests/unit/authz/probe-9033-repro.test.ts @@ -10,8 +10,14 @@ import os from "node:os"; import path from "node:path"; import { NextRequest } from "next/server"; -const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9033-repro-")); -process.env.DATA_DIR = TEST_DATA_DIR; +const TEST_DATA_DIR = path.join(process.env.DATA_DIR!, "probe-9033-repro"); +// NOTE: Not reassigning process.env.DATA_DIR at module scope because +// node --test spawns test files as worker threads sharing process.env. +// A module-level DATA_DIR override would leak to ALL concurrently running +// workers, causing them to share the same SQLite file and race on it (#9541). +// isolateDataDir.ts (--import) already set DATA_DIR to a unique temp dir per +// process; we use a subdirectory within it instead. + process.env.JWT_SECRET = "test-secret-9033"; const core = await import("../../../src/lib/db/core.ts"); diff --git a/tests/unit/chat-combo-live-test.test.ts b/tests/unit/chat-combo-live-test.test.ts index 11e54e41f9..da01178824 100644 --- a/tests/unit/chat-combo-live-test.test.ts +++ b/tests/unit/chat-combo-live-test.test.ts @@ -250,7 +250,7 @@ test("chat completions route emits early keepalive while waiting for stream read await seedHealthyConnection(); globalThis.fetch = async () => { - await new Promise((resolve) => setTimeout(resolve, 2200)); + await new Promise((resolve) => setTimeout(resolve, 100)); return new Response( [ `data: ${JSON.stringify({ @@ -274,10 +274,7 @@ test("chat completions route emits early keepalive while waiting for stream read assert.match(response.headers.get("content-type") || "", /text\/event-stream/); const body = await readAll(response); - assert.match( - body, - /data: \{"id":"chatcmpl-keepalive","object":"chat\.completion\.chunk"/ - ); + assert.match(body, /data: \{"id":"chatcmpl-keepalive","object":"chat\.completion\.chunk"/); assert.match(body, /OK/); assert.match(body, /\[DONE\]/); }); @@ -286,7 +283,7 @@ test("chat completions route returns JSON without early SSE framing when stream await seedHealthyConnection(); globalThis.fetch = async () => { - await new Promise((resolve) => setTimeout(resolve, 2200)); + await new Promise((resolve) => setTimeout(resolve, 100)); return Response.json({ id: "chatcmpl-slow-json", choices: [ diff --git a/tests/unit/chatcore-sanitization.test.ts b/tests/unit/chatcore-sanitization.test.ts index 50cf0fe12b..90e9b1e279 100644 --- a/tests/unit/chatcore-sanitization.test.ts +++ b/tests/unit/chatcore-sanitization.test.ts @@ -84,9 +84,9 @@ function ensureLegacyMemoryTable() { `); } -async function waitForAsyncMemoryFlush() { - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setTimeout(resolve, 10)); +async function flushAsyncSideEffects() { + // setImmediate rounds drain the event loop more reliably than setTimeout under CI load. + for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve)); } async function invokeChatCore({ @@ -647,7 +647,7 @@ test("chatCore does not share or persist memories when apiKeyInfo is missing", a }, }); - await waitForAsyncMemoryFlush(); + await flushAsyncSideEffects(); const localMemoriesResult = await listMemories({ apiKeyId: "local" }); const localMemories = Array.isArray(localMemoriesResult) @@ -751,7 +751,7 @@ test("chatCore extracts memories from Claude content arrays and Responses output assert.equal(responsesResult.result.success, true); - await waitForAsyncMemoryFlush(); + await flushAsyncSideEffects(); const claudeMemoriesResult = await listMemories({ apiKeyId: claudeKeyId }); const responsesMemoriesResult = await listMemories({ apiKeyId: responsesKeyId }); @@ -819,7 +819,7 @@ test("chatCore request memory extraction for responses input ignores assistant i assert.equal(responsesResult.result.success, true); - await waitForAsyncMemoryFlush(); + await flushAsyncSideEffects(); const memoriesResult = await listMemories({ apiKeyId: responsesKeyId }); const memories = Array.isArray(memoriesResult) ? memoriesResult : (memoriesResult.data ?? []); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index a3bea6c861..5b1aec1c1c 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -287,9 +287,9 @@ async function waitFor(fn, timeoutMs = 30000) { return null; } -async function waitForAsyncSideEffects() { - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setTimeout(resolve, 10)); +async function flushAsyncSideEffects() { + // setImmediate rounds drain the event loop more reliably than setTimeout under CI load. + for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve)); } async function getLatestCallLog() { @@ -363,7 +363,7 @@ async function invokeChatCore({ onCredentialsRefreshed, onRequestSuccess, } as any); - await waitForAsyncSideEffects(); + await flushAsyncSideEffects(); return { result, calls, call: calls.at(-1) }; } finally { @@ -376,7 +376,7 @@ test.afterEach(async () => { restorePipelineCaptureEnv(); clearPendingRequests(); resetAccountSemaphores(); - await waitForAsyncSideEffects(); + await flushAsyncSideEffects(); await resetStorage(); }); @@ -385,7 +385,7 @@ test.after(async () => { restorePipelineCaptureEnv(); clearPendingRequests(); resetAccountSemaphores(); - await waitForAsyncSideEffects(); + await flushAsyncSideEffects(); await resetStorage(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); @@ -443,7 +443,7 @@ test("chatCore times out upstream execution before provider response headers", a assert.equal(pendingDetail?.providerRequest?.model, "gpt-4o-mini"); assert.deepEqual(pendingDetail?.providerRequest?.messages, body.messages); const result = await invocation; - await waitForAsyncSideEffects(); + await flushAsyncSideEffects(); assert.equal(upstreamBodies[0]?.model, "gpt-4o-mini"); assert.deepEqual(upstreamBodies[0]?.messages, body.messages); @@ -472,7 +472,7 @@ test("chatCore can disable pipeline stream chunk capture through environment", a assert.equal(result.success, true); await result.response.text(); - await waitForAsyncSideEffects(); + await flushAsyncSideEffects(); const detail = await waitFor(getLatestCallLog); assert.ok(detail, "expected call log detail to be persisted"); @@ -1702,7 +1702,7 @@ test("chatCore returns a semantic cache HIT for repeated deterministic requests" const payload = (await second.result.response.json()) as any; assert.equal(payload.choices[0].message.content, "cached-once"); - await waitForAsyncSideEffects(); + await flushAsyncSideEffects(); const semanticLog = await waitFor(async () => { const rows = await getCallLogs({ limit: 10 }); const hit = rows.find((row) => row.cacheSource === "semantic"); @@ -2632,7 +2632,7 @@ test("chatCore releases account semaphore slots when upstream execution throws", }, }); - await waitForAsyncSideEffects(); + await flushAsyncSideEffects(); assert.equal(result.success, false); assert.equal(result.status, 502); @@ -2709,7 +2709,7 @@ test("chatCore caches streaming response and serves cache HIT on repeat", async assert.equal(first.result.success, true); // Consume the stream to trigger onStreamComplete and cache write await first.result.response.text(); - await waitForAsyncSideEffects(); + await flushAsyncSideEffects(); // Second request with same body should get cache HIT (JSON, not SSE) const second = await invokeChatCore({ @@ -2762,7 +2762,7 @@ test("chatCore does not cache streaming response when temperature > 0", async () }); await first.result.response.text(); - await waitForAsyncSideEffects(); + await flushAsyncSideEffects(); const second = await invokeChatCore({ provider: "openai", @@ -2804,7 +2804,7 @@ test("chatCore skips streaming cache when X-OmniRoute-No-Cache header is set", a }); await first.result.response.text(); - await waitForAsyncSideEffects(); + await flushAsyncSideEffects(); // Verify nothing was cached const sig = generateSignature("gpt-4o-mini", sharedBody.messages, 0, 1); diff --git a/tests/unit/codex-stream-false.test.ts b/tests/unit/codex-stream-false.test.ts index 5af1f80f3c..138272d8e7 100644 --- a/tests/unit/codex-stream-false.test.ts +++ b/tests/unit/codex-stream-false.test.ts @@ -143,9 +143,9 @@ async function resetStorage() { fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); } -async function waitForAsyncSideEffects() { - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setTimeout(resolve, 10)); +async function flushAsyncSideEffects() { + // setImmediate rounds drain the event loop more reliably than setTimeout under CI load. + for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve)); } async function invokeChatCore({ @@ -192,7 +192,7 @@ async function invokeChatCore({ }, userAgent: "unit-test", }); - await waitForAsyncSideEffects(); + await flushAsyncSideEffects(); return { result, calls, call: calls.at(-1) }; } finally { globalThis.fetch = originalFetch; diff --git a/tests/unit/combo-cache-invalidation.test.ts b/tests/unit/combo-cache-invalidation.test.ts index 30b1b8ffb6..5291c5c76a 100644 --- a/tests/unit/combo-cache-invalidation.test.ts +++ b/tests/unit/combo-cache-invalidation.test.ts @@ -27,6 +27,7 @@ process.env.DATA_DIR = TEST_DATA_DIR; const core = await import("../../src/lib/db/core.ts"); const combosDb = await import("../../src/lib/db/combos.ts"); +const contextHandoffsDb = await import("../../src/lib/db/contextHandoffs.ts"); const readCache = await import("../../src/lib/db/readCache.ts"); async function resetStorage() { @@ -38,8 +39,9 @@ async function resetStorage() { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } break; - } catch (error: any) { - if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) { + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException).code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); } else { throw error; @@ -113,7 +115,7 @@ test("editing a combo invalidates the nested-expansion cache within the 10s wind const freshVersion = readCache.getCombosCacheVersion(); assert.equal(cacheStillValid(freshTs, freshVersion), true); - await combosDb.updateCombo((parent as any).id, { strategy: "round-robin" }); + await combosDb.updateCombo(String(parent.id), { strategy: "round-robin" }); assert.equal( cacheStillValid(freshTs, freshVersion), false, @@ -133,19 +135,77 @@ test("deleteCombo and reorderCombos also invalidate the cache", async () => { let ts = Date.now(); let version = readCache.getCombosCacheVersion(); - await combosDb.reorderCombos([(b as any).id, (a as any).id]); - assert.equal( - cacheStillValid(ts, version), - false, - "reorderCombos must invalidate the cache" - ); + await combosDb.reorderCombos([String(b.id), String(a.id)]); + assert.equal(cacheStillValid(ts, version), false, "reorderCombos must invalidate the cache"); ts = Date.now(); version = readCache.getCombosCacheVersion(); - await combosDb.deleteCombo((a as any).id); + await combosDb.deleteCombo(String(a.id)); + assert.equal(cacheStillValid(ts, version), false, "deleteCombo must invalidate the cache"); +}); + +test("reorderCombo side effects follow physical writes even when stored JSON is corrupt", async () => { + const combo = await combosDb.createCombo({ + name: "Corrupt Payload", + models: [{ provider: "openai", model: "gpt-4.1" }], + }); + core.getDbInstance().prepare("UPDATE combos SET data = '' WHERE id = ?").run(String(combo.id)); + + const before = readCache.getCombosCacheVersion(); + const reordered = await combosDb.reorderCombos([String(combo.id)]); + + assert.deepEqual(reordered, []); + assert.notEqual( + readCache.getCombosCacheVersion(), + before, + "a physical reorder write must preserve the legacy invalidation side effect" + ); +}); + +test("updateCombo preserves the existing session-pin cleanup contract", async () => { + const combo = await combosDb.createCombo({ + name: "Before Rename", + models: [{ provider: "openai", model: "gpt-4.1" }], + }); + + contextHandoffsDb.recordSessionModelUsage( + "session-before", + "Before Rename", + "openai/gpt-4.1", + "openai" + ); + contextHandoffsDb.recordSessionModelUsage( + "session-after", + "After Rename", + "openai/gpt-4.1-mini", + "openai" + ); + + await combosDb.updateCombo(String(combo.id), { + name: "After Rename", + models: [{ provider: "openai", model: "gpt-4.1-mini" }], + }); + + assert.equal(contextHandoffsDb.getLastSessionModel("session-before", "Before Rename"), null); + assert.equal(contextHandoffsDb.getLastSessionModel("session-after", "After Rename"), null); +}); + +test("updateCombo does not clear session pins when models are omitted", async () => { + const combo = await combosDb.createCombo({ + name: "Metadata Only", + models: [{ provider: "openai", model: "gpt-4.1" }], + }); + contextHandoffsDb.recordSessionModelUsage( + "session-metadata", + "Metadata Only", + "openai/gpt-4.1", + "openai" + ); + + await combosDb.updateCombo(String(combo.id), { description: "metadata change" }); + assert.equal( - cacheStillValid(ts, version), - false, - "deleteCombo must invalidate the cache" + contextHandoffsDb.getLastSessionModel("session-metadata", "Metadata Only"), + "openai/gpt-4.1" ); }); diff --git a/tests/unit/compression/responses-orphan-tool-call.test.ts b/tests/unit/compression/responses-orphan-tool-call.test.ts new file mode 100644 index 0000000000..d0091f142a --- /dev/null +++ b/tests/unit/compression/responses-orphan-tool-call.test.ts @@ -0,0 +1,144 @@ +/** + * #8946 — "No tool output found" for custom tool calls (Codex desktop) + * + * Compaction Layer-3 purify_history drops oldest messages. The restore path's + * orphan-call cleanup only removed function_call items whose outputs vanished. + * custom_tool_call / local_shell_call / apply_patch_call were left orphaned, + * causing a 400 from the upstream Responses API. + * + * These tests pin the fix: the compaction restore co-drops any tool-call item + * whose output was removed, mirroring the existing function_call logic. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { adaptBodyForCompression } from "../../../open-sse/services/compression/bodyAdapter.ts"; +import { compressContext, estimateTokens } from "../../../open-sse/services/contextManager.ts"; + +function isRecord(v: unknown): v is Record { + return !!v && typeof v === "object" && !Array.isArray(v); +} + +const TOOL_CALL_TYPES = new Set([ + "function_call", + "custom_tool_call", + "local_shell_call", + "apply_patch_call", +]); + +const OUTPUT_TYPES = new Set([ + "function_call_output", + "custom_tool_call_output", + "local_shell_call_output", + "apply_patch_call_output", +]); + +/** + * Scan restored input for orphaned tool calls (a call item whose matching + * output is absent). Returns an array of descriptive strings, empty = clean. + */ +function findOrphanToolCalls(input: unknown[]): string[] { + const orphans: string[] = []; + for (const item of input) { + if (!isRecord(item)) continue; + if (!TOOL_CALL_TYPES.has(String(item.type))) continue; + const callId = typeof item.call_id === "string" && item.call_id.length > 0 ? item.call_id : ""; + if (!callId) continue; + + const hasMatchingOutput = input.some( + (other) => isRecord(other) && OUTPUT_TYPES.has(String(other.type)) && other.call_id === callId + ); + if (!hasMatchingOutput) { + orphans.push(`${String(item.type)} ${callId}`); + } + } + return orphans.sort(); +} + +/** + * Build a Responses body with N tool-using turns. + * Each turn: tool_call + tool_call_output + assistant message + user message. + * The user messages carry substantial text to survive Layer-1 trim_tools and + * force Layer-3 purify_history to engage. + */ +function buildToolTurnBody( + numTurns: number, + outputText: string, + userText: string +): { input: Record[] } { + const input: Record[] = []; + for (let i = 0; i < numTurns; i++) { + // Each turn has one of each tool call type, cycling through them + const toolTypes: Array<{ + callType: string; + outputType: string; + name: string; + }> = [ + { callType: "custom_tool_call", outputType: "custom_tool_call_output", name: "my_tool" }, + { callType: "function_call", outputType: "function_call_output", name: "run_command" }, + { callType: "local_shell_call", outputType: "local_shell_call_output", name: "run_shell" }, + { callType: "apply_patch_call", outputType: "apply_patch_call_output", name: "apply_diff" }, + ]; + const t = toolTypes[i % toolTypes.length]; + const callId = `${t.callType}-${i}`; + + input.push({ type: t.callType, call_id: callId, name: t.name, arguments: "{}" }); + input.push({ type: t.outputType, call_id: callId, output: outputText }); + input.push({ + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `response ${i}` }], + }); + input.push({ + type: "message", + role: "user", + content: [{ type: "input_text", text: `${userText} turn ${i}` }], + }); + } + return { input }; +} + +test("#8946: compaction drops orphan custom_tool_call / local_shell_call / apply_patch_call with vanished outputs", () => { + // Build many turns: user messages carry enough text to survive Layer-1 + // trim_tools (which only trims role:"tool" content) so the aggregate + // token count still exceeds the compact target after trimming, forcing + // Layer-3 purify_history to engage. + const outputText = "result: ok"; + const userText = "x".repeat(3_000); // ~750 tokens each + const body = buildToolTurnBody(8, outputText, userText); + + const adapter = adaptBodyForCompression(body); + assert.equal(adapter.adapted, true); + + // Calculate before so we can set a target that forces Layer-3. + const before = estimateTokens(adapter.body.messages as Record[]); + const target = Math.max(5_000, Math.floor(before * 0.5)); + + const result = compressContext(adapter.body as Record, { + provider: "codex", + model: "gpt-5.6-terra", + maxTokens: target, + reserveTokens: 0, + }); + + assert.equal( + result.compressed, + true, + `compression should engage (before=${before}, target=${target}, stats=${JSON.stringify(result.stats)})` + ); + + const restored = adapter.restore(result.body as Record, { + dropMissingMappedItems: true, + }); + + const input = Array.isArray(restored.input) ? restored.input : []; + const orphans = findOrphanToolCalls(input); + + // Before the fix: custom_tool_call, local_shell_call, apply_patch_call + // orphans survive. After the fix: none survive. + assert.equal( + orphans.length, + 0, + `restored input contains orphaned tool calls whose outputs were dropped: ${JSON.stringify(orphans)} ` + + `(restored input length: ${input.length})` + ); +}); diff --git a/tests/unit/db/repositories/sqliteComboRepositories.test.ts b/tests/unit/db/repositories/sqliteComboRepositories.test.ts new file mode 100644 index 0000000000..a9d9181234 --- /dev/null +++ b/tests/unit/db/repositories/sqliteComboRepositories.test.ts @@ -0,0 +1,46 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import assert from "node:assert/strict"; +import test from "node:test"; + +import { registerComboRepositoryConformance } from "../../../helpers/persistence/comboRepositoryConformance.ts"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-repository-contract-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../../../src/lib/db/core.ts"); +const { sqliteComboRepository } = + await import("../../../../src/lib/db/repositories/sqliteComboRepository.ts"); +const { sqliteModelComboMappingRepository } = + await import("../../../../src/lib/db/repositories/sqliteModelComboMappingRepository.ts"); +const combosDb = await import("../../../../src/lib/db/combos.ts"); + +async function resetStorage(): Promise { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +registerComboRepositoryConformance(async () => ({ + combos: sqliteComboRepository, + mappings: sqliteModelComboMappingRepository, + reset: resetStorage, + async corruptComboPayload(comboId: string): Promise { + core.getDbInstance().prepare("UPDATE combos SET data = ? WHERE id = ?").run("", comboId); + }, +})); + +test("legacy combo count facade remains synchronous", async () => { + await resetStorage(); + + assert.equal(typeof combosDb.getCombosCount(), "number"); + assert.equal(combosDb.getCombosCount(), 0); + await sqliteComboRepository.create({ name: "Counted", models: [] }); + assert.equal(combosDb.getCombosCount(), 1); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); diff --git a/tests/unit/early-stream-keepalive.test.ts b/tests/unit/early-stream-keepalive.test.ts index d11a08aa1b..824b49cf44 100644 --- a/tests/unit/early-stream-keepalive.test.ts +++ b/tests/unit/early-stream-keepalive.test.ts @@ -382,6 +382,6 @@ test("aborting the client signal stops the keepalive stream (#2544)", async () = if (done) return true; } })(); - const timed = new Promise((resolve) => setTimeout(() => resolve(false), 500)); + const timed = new Promise((resolve) => setTimeout(() => resolve(false), 5000)); assert.equal(await Promise.race([drained, timed]), true, "stream should close after abort"); }); diff --git a/tests/unit/kiro-import-overwrite-9435.test.ts b/tests/unit/kiro-import-overwrite-9435.test.ts new file mode 100644 index 0000000000..d4037f7033 --- /dev/null +++ b/tests/unit/kiro-import-overwrite-9435.test.ts @@ -0,0 +1,87 @@ +/** + * TDD for #9435 — Kiro import token endpoint overwrites existing connection + * instead of creating new one for Builder ID / social imports. + * + * Root cause: `findKiroConnectionByIdentity` matches by cached OIDC `clientId` + * before `email`. When importing a second Builder ID token, the shared machine-wide + * cached `clientId` matches the FIRST connection instead of creating a new one. + * + * The fix: do NOT pass `clientId` in the identity object for Non-IDC (Builder ID / + * social) imports at the route level, so the fallback to email-based matching works. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { findKiroConnectionByIdentity } from "../../src/lib/oauth/kiroConnectionIdentity.js"; + +// ── Unit-level repro: the function matches by shared clientId before email ────── +// These two connections simulate two distinct Kiro Builder ID accounts on the same +// machine. They share a cached OIDC clientId but have different emails. +const aliceAndBob = [ + { + id: "conn-alice", + authType: "oauth", + email: "alice@example.com", + providerSpecificData: { clientId: "shared-cached-cid" }, + }, + { + id: "conn-bob", + authType: "oauth", + email: "bob@example.com", + providerSpecificData: { clientId: "shared-cached-cid" }, + }, +]; + +test("#9435 findKiroConnectionByIdentity with shared clientId + distinct emails: when BOTH clientId and email are passed, clientId match wins (the bug)", () => { + // Searching with the shared cached clientId + Bob's email. + // The function checks clientId FIRST so it returns conn-alice (first match by + // shared clientId), even though conn-bob is the correct one (email match). + const match = findKiroConnectionByIdentity(aliceAndBob, { + clientId: "shared-cached-cid", + email: "bob@example.com", + }); + assert.equal( + match?.id, + "conn-alice", + `BUG: expected conn-alice (first match by shared clientId), got: ${match?.id}` + ); +}); + +test("#9435 findKiroConnectionByIdentity with ONLY email (no clientId): correctly finds Bob by email", () => { + // When clientId is NOT in the identity (as the fix does for non-IDC imports), + // the function falls through to email matching and finds the right connection. + const match = findKiroConnectionByIdentity(aliceAndBob, { + email: "bob@example.com", + }); + assert.equal(match?.id, "conn-bob", `expected conn-bob (email match), got: ${match?.id}`); +}); + +test("#9435 findKiroConnectionByIdentity with ONLY email for Alice: correctly finds Alice by email", () => { + const match = findKiroConnectionByIdentity(aliceAndBob, { + email: "alice@example.com", + }); + assert.equal(match?.id, "conn-alice", `expected conn-alice (email match), got: ${match?.id}`); +}); + +test("#9435 findKiroConnectionByIdentity with shared clientId + new email (no match): returns null", () => { + // A third user with no existing connection should get null (create new connection) + const match = findKiroConnectionByIdentity(aliceAndBob, { + clientId: "shared-cached-cid", + email: "charlie@example.com", + }); + // With clientId in the identity, it matches conn-alice (by shared clientId) + // instead of returning null — this IS the bug. + assert.equal( + match?.id, + "conn-alice", + `BUG: expected conn-alice (first match by shared clientId), got: ${match?.id} — charlie is new, should not match any` + ); +}); + +test("#9435 findKiroConnectionByIdentity with ONLY email (no clientId) for new user: correctly returns null (create new)", () => { + // Without clientId, the function checks email and finds no match → null = create new + const match = findKiroConnectionByIdentity(aliceAndBob, { + email: "charlie@example.com", + }); + assert.equal(match, null, `expected null for new user when no clientId, got: ${match?.id}`); +}); diff --git a/tests/unit/modelsDevSync-extended.test.ts b/tests/unit/modelsDevSync-extended.test.ts index 9b96a8440b..7b604f2639 100644 --- a/tests/unit/modelsDevSync-extended.test.ts +++ b/tests/unit/modelsDevSync-extended.test.ts @@ -145,422 +145,424 @@ test.after(async () => { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); }); -test("fetchModelsDev caches successful responses and rejects invalid JSON or non-ok responses", async () => { - const modelsDev = await importFresh("fetch-cache"); - let calls = 0; - globalThis.fetch = async () => { - calls += 1; - return new Response(JSON.stringify(MOCK_MODELS_DEV_DATA), { - status: 200, - headers: { "content-type": "application/json" }, - }); - }; +test.describe("modelsDevSync-extended", { concurrency: 1 }, async () => { + test("fetchModelsDev caches successful responses and rejects invalid JSON or non-ok responses", async () => { + const modelsDev = await importFresh("fetch-cache"); + let calls = 0; + globalThis.fetch = async () => { + calls += 1; + return new Response(JSON.stringify(MOCK_MODELS_DEV_DATA), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; - const first = await modelsDev.fetchModelsDev(); - const second = await modelsDev.fetchModelsDev(); + const first = await modelsDev.fetchModelsDev(); + const second = await modelsDev.fetchModelsDev(); - assert.strictEqual(first, second); - assert.equal(calls, 1); + assert.strictEqual(first, second); + assert.equal(calls, 1); - const invalid = await importFresh("fetch-invalid-json"); - mockFetchWith("not-json"); - await assert.rejects(() => invalid.fetchModelsDev(), /invalid JSON/); + const invalid = await importFresh("fetch-invalid-json"); + mockFetchWith("not-json"); + await assert.rejects(() => invalid.fetchModelsDev(), /invalid JSON/); - const nonOk = await importFresh("fetch-non-ok"); - mockFetchWith({ error: "boom" }, 503, "Service Unavailable"); - await assert.rejects(() => nonOk.fetchModelsDev(), /models\.dev fetch failed \[503\]/); -}); - -test("modelsDev interval falls back to the default when env values are invalid or non-positive", async () => { - process.env.MODELS_DEV_SYNC_INTERVAL = "0"; - const zeroInterval = await importFresh("interval-zero"); - zeroInterval.startPeriodicSync(); - assert.equal(zeroInterval.getSyncStatus().intervalMs, 86400 * 1000); - zeroInterval.stopPeriodicSync(); - - process.env.MODELS_DEV_SYNC_INTERVAL = "not-a-number"; - const invalidInterval = await importFresh("interval-invalid"); - invalidInterval.startPeriodicSync(); - assert.equal(invalidInterval.getSyncStatus().intervalMs, 86400 * 1000); - invalidInterval.stopPeriodicSync(); -}); - -test("transform helpers skip incomplete pricing entries and preserve partial capability defaults", async () => { - const modelsDev = await importFresh("transform-edge-cases"); - const raw = { - sparse: { - id: "sparse", - models: { - "missing-cost": { - id: "missing-cost", - name: "Missing Cost", - }, - "missing-input": { - id: "missing-input", - name: "Missing Input", - cost: { output: 4.2 }, - }, - complete: { - id: "complete", - name: "Complete", - cost: { input: 1.5 }, - interleaved: { field: "" }, - }, - }, - }, - nomodels: { - id: "nomodels", - }, - }; - - const pricing = modelsDev.transformModelsDevToPricing(raw); - assert.deepEqual(pricing.sparse, { - complete: { - input: 1.5, - output: 0, - }, + const nonOk = await importFresh("fetch-non-ok"); + mockFetchWith({ error: "boom" }, 503, "Service Unavailable"); + await assert.rejects(() => nonOk.fetchModelsDev(), /models\.dev fetch failed \[503\]/); }); - assert.equal(pricing.nomodels, undefined); - const capabilities = modelsDev.transformModelsDevToCapabilities(raw); - assert.equal(capabilities.sparse.complete.tool_call, null); - assert.equal(capabilities.sparse.complete.reasoning, null); - assert.equal(capabilities.sparse.complete.attachment, null); - assert.equal(capabilities.sparse.complete.structured_output, null); - assert.equal(capabilities.sparse.complete.temperature, null); - assert.equal(capabilities.sparse.complete.modalities_input, "[]"); - assert.equal(capabilities.sparse.complete.modalities_output, "[]"); - assert.equal(capabilities.sparse.complete.limit_context, null); - assert.equal(capabilities.sparse.complete.limit_input, null); - assert.equal(capabilities.sparse.complete.limit_output, null); - assert.equal(capabilities.sparse.complete.interleaved_field, null); - assert.equal(capabilities.nomodels, undefined); -}); + test("modelsDev interval falls back to the default when env values are invalid or non-positive", async () => { + process.env.MODELS_DEV_SYNC_INTERVAL = "0"; + const zeroInterval = await importFresh("interval-zero"); + zeroInterval.startPeriodicSync(); + assert.equal(zeroInterval.getSyncStatus().intervalMs, 86400 * 1000); + zeroInterval.stopPeriodicSync(); -test("modelsDev pricing helpers persist records, skip corrupted rows, and clear the namespace", async () => { - const modelsDev = await importFresh("pricing-storage"); - const pricing = modelsDev.transformModelsDevToPricing(MOCK_MODELS_DEV_DATA); + process.env.MODELS_DEV_SYNC_INTERVAL = "not-a-number"; + const invalidInterval = await importFresh("interval-invalid"); + invalidInterval.startPeriodicSync(); + assert.equal(invalidInterval.getSyncStatus().intervalMs, 86400 * 1000); + invalidInterval.stopPeriodicSync(); + }); - modelsDev.saveModelsDevPricing(pricing); - const saved = modelsDev.getModelsDevPricing(); - assert.equal(saved.openai["gpt-4o"].input, 2.5); - assert.equal(saved.cx["gpt-4o"].cache_creation, 2.5); - - const db = core.getDbInstance(); - db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( - "models_dev_pricing", - "corrupted", - "{oops" - ); - - const withCorruption = modelsDev.getModelsDevPricing(); - assert.equal(withCorruption.corrupted, undefined); - - modelsDev.clearModelsDevPricing(); - assert.deepEqual(modelsDev.getModelsDevPricing(), {}); -}); - -test("modelsDev capabilities helpers create the table, persist rows, filter by provider/model, and expose context limits", async () => { - const modelsDev = await importFresh("capabilities-storage"); - const capabilities = modelsDev.transformModelsDevToCapabilities(MOCK_MODELS_DEV_DATA); - - modelsDev.ensureCapabilitiesTable(); - modelsDev.saveModelsDevCapabilities(capabilities); - - const allCaps = modelsDev.getSyncedCapabilities(); - const openaiOnly = modelsDev.getSyncedCapabilities("openai", "gpt-4o"); - - assert.equal(allCaps.openai["gpt-4o"].tool_call, true); - assert.equal(allCaps.anthropic["claude-sonnet-4-20250514"].attachment, true); - assert.deepEqual(Object.keys(openaiOnly), ["openai"]); - assert.equal(openaiOnly.openai["gpt-4o"].limit_context, 128000); - assert.equal("getModelContextLimit" in modelsDev, false); - - modelsDev.clearModelsDevCapabilities(); - assert.deepEqual(modelsDev.getSyncedCapabilities(), {}); -}); - -test("modelsDev capability helpers coerce false/null values and ignore malformed rows", async () => { - const modelsDev = await importFresh("capabilities-malformed"); - const db = core.getDbInstance(); - const originalPrepare = db.prepare.bind(db); - db.prepare = (sql) => { - if (String(sql).includes("SELECT * FROM model_capabilities")) { - return { - all: () => [ - 123, - { provider: null, model_id: "missing-provider" }, - { - provider: "openai", - model_id: "coerced-model", - tool_call: 0, - reasoning: null, - attachment: 0, - structured_output: 0, - temperature: 0, - modalities_input: null, - modalities_output: 42, - knowledge_cutoff: 77, - release_date: 88, - last_updated: 99, - status: 123, - family: 456, - open_weights: null, - limit_context: "bad", - limit_input: 4096, - limit_output: "nope", - interleaved_field: 321, + test("transform helpers skip incomplete pricing entries and preserve partial capability defaults", async () => { + const modelsDev = await importFresh("transform-edge-cases"); + const raw = { + sparse: { + id: "sparse", + models: { + "missing-cost": { + id: "missing-cost", + name: "Missing Cost", }, - ], - }; - } - return originalPrepare(sql); - }; + "missing-input": { + id: "missing-input", + name: "Missing Input", + cost: { output: 4.2 }, + }, + complete: { + id: "complete", + name: "Complete", + cost: { input: 1.5 }, + interleaved: { field: "" }, + }, + }, + }, + nomodels: { + id: "nomodels", + }, + }; - try { - const openai = modelsDev.getSyncedCapabilities("openai"); - assert.deepEqual(openai.openai["coerced-model"], { - tool_call: false, - reasoning: null, - attachment: false, - structured_output: false, - temperature: false, - modalities_input: "[]", - modalities_output: "[]", - knowledge_cutoff: null, - release_date: null, - last_updated: null, - status: null, - family: null, - open_weights: null, - limit_context: null, - limit_input: 4096, - limit_output: null, - interleaved_field: null, + const pricing = modelsDev.transformModelsDevToPricing(raw); + assert.deepEqual(pricing.sparse, { + complete: { + input: 1.5, + output: 0, + }, }); + assert.equal(pricing.nomodels, undefined); - const all = modelsDev.getSyncedCapabilities(); - assert.equal(all["7"], undefined); - assert.equal(all.openai["missing-provider"], undefined); - } finally { - db.prepare = originalPrepare; - } -}); + const capabilities = modelsDev.transformModelsDevToCapabilities(raw); + assert.equal(capabilities.sparse.complete.tool_call, null); + assert.equal(capabilities.sparse.complete.reasoning, null); + assert.equal(capabilities.sparse.complete.attachment, null); + assert.equal(capabilities.sparse.complete.structured_output, null); + assert.equal(capabilities.sparse.complete.temperature, null); + assert.equal(capabilities.sparse.complete.modalities_input, "[]"); + assert.equal(capabilities.sparse.complete.modalities_output, "[]"); + assert.equal(capabilities.sparse.complete.limit_context, null); + assert.equal(capabilities.sparse.complete.limit_input, null); + assert.equal(capabilities.sparse.complete.limit_output, null); + assert.equal(capabilities.sparse.complete.interleaved_field, null); + assert.equal(capabilities.nomodels, undefined); + }); -test("modelsDev pricing helpers ignore malformed sqlite rows without crashing", async () => { - const modelsDev = await importFresh("pricing-malformed"); - const db = core.getDbInstance(); - const originalPrepare = db.prepare.bind(db); - db.prepare = (sql) => { - if (String(sql).includes("SELECT key, value FROM key_value")) { - return { - all: () => [ - 123, - { key: 123, value: JSON.stringify({ ignored: true }) }, - { key: "missing-value", value: 456 }, - { key: "broken", value: "{oops" }, - { key: "openai", value: JSON.stringify({ "gpt-4o": { input: 2.5, output: 10 } }) }, - ], - }; + test("modelsDev pricing helpers persist records, skip corrupted rows, and clear the namespace", async () => { + const modelsDev = await importFresh("pricing-storage"); + const pricing = modelsDev.transformModelsDevToPricing(MOCK_MODELS_DEV_DATA); + + modelsDev.saveModelsDevPricing(pricing); + const saved = modelsDev.getModelsDevPricing(); + assert.equal(saved.openai["gpt-4o"].input, 2.5); + assert.equal(saved.cx["gpt-4o"].cache_creation, 2.5); + + const db = core.getDbInstance(); + db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run( + "models_dev_pricing", + "corrupted", + "{oops" + ); + + const withCorruption = modelsDev.getModelsDevPricing(); + assert.equal(withCorruption.corrupted, undefined); + + modelsDev.clearModelsDevPricing(); + assert.deepEqual(modelsDev.getModelsDevPricing(), {}); + }); + + test("modelsDev capabilities helpers create the table, persist rows, filter by provider/model, and expose context limits", async () => { + const modelsDev = await importFresh("capabilities-storage"); + const capabilities = modelsDev.transformModelsDevToCapabilities(MOCK_MODELS_DEV_DATA); + + modelsDev.ensureCapabilitiesTable(); + modelsDev.saveModelsDevCapabilities(capabilities); + + const allCaps = modelsDev.getSyncedCapabilities(); + const openaiOnly = modelsDev.getSyncedCapabilities("openai", "gpt-4o"); + + assert.equal(allCaps.openai["gpt-4o"].tool_call, true); + assert.equal(allCaps.anthropic["claude-sonnet-4-20250514"].attachment, true); + assert.deepEqual(Object.keys(openaiOnly), ["openai"]); + assert.equal(openaiOnly.openai["gpt-4o"].limit_context, 128000); + assert.equal("getModelContextLimit" in modelsDev, false); + + modelsDev.clearModelsDevCapabilities(); + assert.deepEqual(modelsDev.getSyncedCapabilities(), {}); + }); + + test("modelsDev capability helpers coerce false/null values and ignore malformed rows", async () => { + const modelsDev = await importFresh("capabilities-malformed"); + const db = core.getDbInstance(); + const originalPrepare = db.prepare.bind(db); + db.prepare = (sql) => { + if (String(sql).includes("SELECT * FROM model_capabilities")) { + return { + all: () => [ + 123, + { provider: null, model_id: "missing-provider" }, + { + provider: "openai", + model_id: "coerced-model", + tool_call: 0, + reasoning: null, + attachment: 0, + structured_output: 0, + temperature: 0, + modalities_input: null, + modalities_output: 42, + knowledge_cutoff: 77, + release_date: 88, + last_updated: 99, + status: 123, + family: 456, + open_weights: null, + limit_context: "bad", + limit_input: 4096, + limit_output: "nope", + interleaved_field: 321, + }, + ], + }; + } + return originalPrepare(sql); + }; + + try { + const openai = modelsDev.getSyncedCapabilities("openai"); + assert.deepEqual(openai.openai["coerced-model"], { + tool_call: false, + reasoning: null, + attachment: false, + structured_output: false, + temperature: false, + modalities_input: "[]", + modalities_output: "[]", + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: null, + limit_context: null, + limit_input: 4096, + limit_output: null, + interleaved_field: null, + }); + + const all = modelsDev.getSyncedCapabilities(); + assert.equal(all["7"], undefined); + assert.equal(all.openai["missing-provider"], undefined); + } finally { + db.prepare = originalPrepare; } - return originalPrepare(sql); - }; + }); - try { - assert.deepEqual(modelsDev.getModelsDevPricing(), { + test("modelsDev pricing helpers ignore malformed sqlite rows without crashing", async () => { + const modelsDev = await importFresh("pricing-malformed"); + const db = core.getDbInstance(); + const originalPrepare = db.prepare.bind(db); + db.prepare = (sql) => { + if (String(sql).includes("SELECT key, value FROM key_value")) { + return { + all: () => [ + 123, + { key: 123, value: JSON.stringify({ ignored: true }) }, + { key: "missing-value", value: 456 }, + { key: "broken", value: "{oops" }, + { key: "openai", value: JSON.stringify({ "gpt-4o": { input: 2.5, output: 10 } }) }, + ], + }; + } + return originalPrepare(sql); + }; + + try { + assert.deepEqual(modelsDev.getModelsDevPricing(), { + openai: { + "gpt-4o": { + input: 2.5, + output: 10, + }, + }, + }); + } finally { + db.prepare = originalPrepare; + } + }); + + test("saveModelsDevCapabilities round-trips false and null booleans", async () => { + const modelsDev = await importFresh("capabilities-roundtrip-falsey"); + modelsDev.saveModelsDevCapabilities({ openai: { - "gpt-4o": { - input: 2.5, - output: 10, + "gpt-falsey": { + tool_call: false, + reasoning: null, + attachment: false, + structured_output: false, + temperature: null, + modalities_input: "[]", + modalities_output: '["text"]', + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: false, + limit_context: null, + limit_input: null, + limit_output: 1024, + interleaved_field: null, }, }, }); - } finally { - db.prepare = originalPrepare; - } -}); -test("saveModelsDevCapabilities round-trips false and null booleans", async () => { - const modelsDev = await importFresh("capabilities-roundtrip-falsey"); - modelsDev.saveModelsDevCapabilities({ - openai: { - "gpt-falsey": { - tool_call: false, - reasoning: null, - attachment: false, - structured_output: false, - temperature: null, - modalities_input: "[]", - modalities_output: '["text"]', - knowledge_cutoff: null, - release_date: null, - last_updated: null, - status: null, - family: null, - open_weights: false, - limit_context: null, - limit_input: null, - limit_output: 1024, - interleaved_field: null, + assert.deepEqual(modelsDev.getSyncedCapabilities("openai", "gpt-falsey"), { + openai: { + "gpt-falsey": { + tool_call: false, + reasoning: null, + attachment: false, + structured_output: false, + temperature: null, + modalities_input: "[]", + modalities_output: '["text"]', + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: false, + limit_context: null, + limit_input: null, + limit_output: 1024, + interleaved_field: null, + }, }, - }, - }); - - assert.deepEqual(modelsDev.getSyncedCapabilities("openai", "gpt-falsey"), { - openai: { - "gpt-falsey": { - tool_call: false, - reasoning: null, - attachment: false, - structured_output: false, - temperature: null, - modalities_input: "[]", - modalities_output: '["text"]', - knowledge_cutoff: null, - release_date: null, - last_updated: null, - status: null, - family: null, - open_weights: false, - limit_context: null, - limit_input: null, - limit_output: 1024, - interleaved_field: null, - }, - }, - }); -}); - -test("syncModelsDev supports dry-run mode, persistence, capability toggles, and failure reporting", async () => { - const modelsDev = await importFresh("sync-main"); - mockFetchWith(MOCK_MODELS_DEV_DATA); - - const dryRun = await modelsDev.syncModelsDev({ dryRun: true, syncCapabilities: false }); - assert.equal(dryRun.success, true); - assert.equal(dryRun.dryRun, true); - assert.equal(dryRun.capabilityCount, 0); - assert.ok(dryRun.data.pricing.openai["gpt-4o"]); - assert.deepEqual(modelsDev.getModelsDevPricing(), {}); - - const persisted = await modelsDev.syncModelsDev(); - assert.equal(persisted.success, true); - assert.equal(persisted.dryRun, false); - assert.ok(persisted.modelCount > 0); - assert.ok(modelsDev.getModelsDevPricing().anthropic["claude-sonnet-4-20250514"]); - assert.ok(modelsDev.getSyncedCapabilities().openai["gpt-4o"]); - assert.ok(modelsDev.getSyncStatus().lastSync); - assert.ok(modelsDev.getSyncStatus().lastSyncModelCount > 0); - - const failing = await importFresh("sync-failure"); - globalThis.fetch = async () => { - throw new Error("network down"); - }; - const failed = await failing.syncModelsDev(); - assert.equal(failed.success, false); - assert.match(failed.error, /network down/); -}); - -test("syncModelsDev string failures are normalized into an error payload", async () => { - const modelsDev = await importFresh("sync-string-error"); - globalThis.fetch = async () => { - throw "hard fail"; - }; - - const failed = await modelsDev.syncModelsDev({ dryRun: true }); - assert.equal(failed.success, false); - assert.equal(failed.error, "hard fail"); - assert.equal(failed.dryRun, true); -}); - -test("syncModelsDev honors abort signals during retry backoff", async () => { - const modelsDev = await importFresh("sync-abort"); - const warnings = []; - const originalWarn = console.warn; - console.warn = (...args) => warnings.push(args.map((arg) => String(arg)).join(" ")); - globalThis.fetch = async () => { - throw new Error("network down"); - }; - - try { - const controller = new AbortController(); - const pending = modelsDev.syncModelsDev({ signal: controller.signal, maxRetries: 3 }); - const warned = await waitFor(() => warnings.length > 0, 100); - assert.ok(warned, "expected the first retry warning before aborting"); - - controller.abort(); - const aborted = await pending; - assert.equal(aborted.success, false); - assert.equal(aborted.error, "aborted"); - assert.equal(warnings.length, 1); - } finally { - console.warn = originalWarn; - } -}); - -test("startPeriodicSync, stopPeriodicSync, getSyncStatus, and initModelsDevSync honor settings and avoid duplicate timers", async () => { - const modelsDev = await importFresh("periodic-sync"); - mockFetchWith(MOCK_MODELS_DEV_DATA); - - modelsDev.startPeriodicSync(25); - const started = modelsDev.getSyncStatus(); - assert.equal(started.enabled, true); - assert.equal(started.intervalMs, 25); - - modelsDev.startPeriodicSync(99); - assert.equal(modelsDev.getSyncStatus().intervalMs, 25); - - const syncedAt = await waitFor(() => modelsDev.getSyncStatus().lastSync, 2000); - assert.ok(syncedAt, "expected initial periodic sync to complete"); - assert.ok(modelsDev.getSyncStatus().nextSync); - - modelsDev.stopPeriodicSync(); - const stopped = modelsDev.getSyncStatus(); - assert.equal(stopped.enabled, false); - assert.equal(stopped.nextSync, null); - - await settingsDb.updateSettings({ - modelsDevSyncEnabled: false, - modelsDevSyncInterval: 15, - }); - const disabled = await importFresh("init-disabled"); - await disabled.initModelsDevSync(); - assert.equal(disabled.getSyncStatus().enabled, false); - - await settingsDb.updateSettings({ - modelsDevSyncEnabled: true, - modelsDevSyncInterval: 15, - }); - const enabled = await importFresh("init-enabled"); - mockFetchWith(MOCK_MODELS_DEV_DATA); - await enabled.initModelsDevSync(); - assert.equal(enabled.getSyncStatus().enabled, true); - assert.equal(enabled.getSyncStatus().intervalMs, 15); - await waitFor(() => enabled.getSyncStatus().lastSync, 2000); -}); - -test("stopPeriodicSync aborts the in-flight initial sync", async () => { - const modelsDev = await importFresh("periodic-stop-abort"); - let aborted = false; - - globalThis.fetch = async (_url, init) => - await new Promise((_resolve, reject) => { - const signal = init?.signal; - const onAbort = () => { - aborted = true; - const error = new Error("aborted"); - error.name = "AbortError"; - reject(error); - }; - - signal?.addEventListener("abort", onAbort, { once: true }); }); + }); - modelsDev.startPeriodicSync(25); - await waitFor(() => modelsDev.getSyncStatus().enabled, 50); - modelsDev.stopPeriodicSync(); + test("syncModelsDev supports dry-run mode, persistence, capability toggles, and failure reporting", async () => { + const modelsDev = await importFresh("sync-main"); + mockFetchWith(MOCK_MODELS_DEV_DATA); - const stopped = await waitFor(() => aborted, 200); - assert.equal(stopped, true); - assert.equal(modelsDev.getSyncStatus().enabled, false); - assert.equal(modelsDev.getSyncStatus().lastSync, null); + const dryRun = await modelsDev.syncModelsDev({ dryRun: true, syncCapabilities: false }); + assert.equal(dryRun.success, true); + assert.equal(dryRun.dryRun, true); + assert.equal(dryRun.capabilityCount, 0); + assert.ok(dryRun.data.pricing.openai["gpt-4o"]); + assert.deepEqual(modelsDev.getModelsDevPricing(), {}); + + const persisted = await modelsDev.syncModelsDev(); + assert.equal(persisted.success, true); + assert.equal(persisted.dryRun, false); + assert.ok(persisted.modelCount > 0); + assert.ok(modelsDev.getModelsDevPricing().anthropic["claude-sonnet-4-20250514"]); + assert.ok(modelsDev.getSyncedCapabilities().openai["gpt-4o"]); + assert.ok(modelsDev.getSyncStatus().lastSync); + assert.ok(modelsDev.getSyncStatus().lastSyncModelCount > 0); + + const failing = await importFresh("sync-failure"); + globalThis.fetch = async () => { + throw new Error("network down"); + }; + const failed = await failing.syncModelsDev(); + assert.equal(failed.success, false); + assert.match(failed.error, /network down/); + }); + + test("syncModelsDev string failures are normalized into an error payload", async () => { + const modelsDev = await importFresh("sync-string-error"); + globalThis.fetch = async () => { + throw "hard fail"; + }; + + const failed = await modelsDev.syncModelsDev({ dryRun: true }); + assert.equal(failed.success, false); + assert.equal(failed.error, "hard fail"); + assert.equal(failed.dryRun, true); + }); + + test("syncModelsDev honors abort signals during retry backoff", async () => { + const modelsDev = await importFresh("sync-abort"); + const warnings = []; + const originalWarn = console.warn; + console.warn = (...args) => warnings.push(args.map((arg) => String(arg)).join(" ")); + globalThis.fetch = async () => { + throw new Error("network down"); + }; + + try { + const controller = new AbortController(); + const pending = modelsDev.syncModelsDev({ signal: controller.signal, maxRetries: 3 }); + const warned = await waitFor(() => warnings.length > 0, 100); + assert.ok(warned, "expected the first retry warning before aborting"); + + controller.abort(); + const aborted = await pending; + assert.equal(aborted.success, false); + assert.equal(aborted.error, "aborted"); + assert.equal(warnings.length, 1); + } finally { + console.warn = originalWarn; + } + }); + + test("startPeriodicSync, stopPeriodicSync, getSyncStatus, and initModelsDevSync honor settings and avoid duplicate timers", async () => { + const modelsDev = await importFresh("periodic-sync"); + mockFetchWith(MOCK_MODELS_DEV_DATA); + + modelsDev.startPeriodicSync(25); + const started = modelsDev.getSyncStatus(); + assert.equal(started.enabled, true); + assert.equal(started.intervalMs, 25); + + modelsDev.startPeriodicSync(99); + assert.equal(modelsDev.getSyncStatus().intervalMs, 25); + + const syncedAt = await waitFor(() => modelsDev.getSyncStatus().lastSync, 2000); + assert.ok(syncedAt, "expected initial periodic sync to complete"); + assert.ok(modelsDev.getSyncStatus().nextSync); + + modelsDev.stopPeriodicSync(); + const stopped = modelsDev.getSyncStatus(); + assert.equal(stopped.enabled, false); + assert.equal(stopped.nextSync, null); + + await settingsDb.updateSettings({ + modelsDevSyncEnabled: false, + modelsDevSyncInterval: 15, + }); + const disabled = await importFresh("init-disabled"); + await disabled.initModelsDevSync(); + assert.equal(disabled.getSyncStatus().enabled, false); + + await settingsDb.updateSettings({ + modelsDevSyncEnabled: true, + modelsDevSyncInterval: 15, + }); + const enabled = await importFresh("init-enabled"); + mockFetchWith(MOCK_MODELS_DEV_DATA); + await enabled.initModelsDevSync(); + assert.equal(enabled.getSyncStatus().enabled, true); + assert.equal(enabled.getSyncStatus().intervalMs, 15); + await waitFor(() => enabled.getSyncStatus().lastSync, 2000); + }); + + test("stopPeriodicSync aborts the in-flight initial sync", async () => { + const modelsDev = await importFresh("periodic-stop-abort"); + let aborted = false; + + globalThis.fetch = async (_url, init) => + await new Promise((_resolve, reject) => { + const signal = init?.signal; + const onAbort = () => { + aborted = true; + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }; + + signal?.addEventListener("abort", onAbort, { once: true }); + }); + + modelsDev.startPeriodicSync(25); + await waitFor(() => modelsDev.getSyncStatus().enabled, 50); + modelsDev.stopPeriodicSync(); + + const stopped = await waitFor(() => aborted, 200); + assert.equal(stopped, true); + assert.equal(modelsDev.getSyncStatus().enabled, false); + assert.equal(modelsDev.getSyncStatus().lastSync, null); + }); }); diff --git a/tests/unit/openai-to-gemini-helpers-split.test.ts b/tests/unit/openai-to-gemini-helpers-split.test.ts index 7ad469b849..28c9d1ab43 100644 --- a/tests/unit/openai-to-gemini-helpers-split.test.ts +++ b/tests/unit/openai-to-gemini-helpers-split.test.ts @@ -19,15 +19,21 @@ test("isVertexGeminiProvider matches only the vertex provider ids", () => { assert.equal(h.isVertexGeminiProvider(undefined), false); }); -test("buildChangedToolNameMap keeps only renamed entries, else null", () => { +test("buildChangedToolNameMap includes all entries with lowercase aliases", () => { const changed = h.buildChangedToolNameMap( new Map([ - ["a", "a"], + ["Bash", "Bash"], ["b_sanitized", "b"], ]) ); - assert.deepEqual([...(changed ?? new Map()).entries()], [["b_sanitized", "b"]]); - assert.equal(h.buildChangedToolNameMap(new Map([["a", "a"]])), null); + const entries = [...(changed ?? new Map()).entries()]; + // Identity entry ("Bash" → "Bash") is included, plus lowercase alias ("bash" → "Bash") + assert.ok(entries.some(([k]) => k === "Bash")); + assert.ok(entries.some(([k, v]) => k === "bash" && v === "Bash")); + // Renamed entry is included as before + assert.ok(entries.some(([k, v]) => k === "b_sanitized" && v === "b")); + // Empty map still returns null + assert.equal(h.buildChangedToolNameMap(new Map()), null); }); test("extractClientThoughtSignature reads the first non-empty signature field", () => { diff --git a/tests/unit/probe-9541-repro.test.ts b/tests/unit/probe-9541-repro.test.ts new file mode 100644 index 0000000000..1faab99844 --- /dev/null +++ b/tests/unit/probe-9541-repro.test.ts @@ -0,0 +1,135 @@ +// TDD verification for #9541 — DB corruption probe transient-error retry. +// +// RED: The repro confirms transient errors (BUSY, ENOENT, PROTOCOL, IOERR) +// fall through to the corruption-rename path (data loss confirmed). +// GREEN: After the fix, isTransientProbeError() exists in core.ts and correctly +// classifies transient vs fatal errors, and a retry loop prevents immediate +// corruption declaration. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Import the fix function from probeUtils.ts +const probeUtils = await import("../../src/lib/db/probeUtils.ts"); + +// ── Tests from the original probe that confirmed the bug ── + +test("FIX-GREEN: isTransientProbeError is exported and classifies BUSY", () => { + const busy = new Error("SQLITE_BUSY: database is locked"); + + // The fix must exist + assert.equal( + typeof probeUtils.isTransientProbeError, + "function", + "isTransientProbeError must be exported from core.ts" + ); + + assert.equal(probeUtils.isTransientProbeError(busy), true, "BUSY is transient"); +}); + +test("FIX-GREEN: isTransientProbeError does NOT classify fatal errors", () => { + const fatalPatterns = [ + "out of memory", + "allocation failure", + "Array buffer allocation failed", + "could not be found", + "Module did not self-register", + ]; + + for (const msg of fatalPatterns) { + assert.equal( + probeUtils.isTransientProbeError(new Error(msg)), + false, + `fatal should NOT be transient: ${msg}` + ); + } +}); + +test("FIX-GREEN: isTransientProbeError classifies BUSY/PROTOCOL/IOERR/ENOENT", () => { + const transientPatterns = [ + "SQLITE_BUSY: database is locked", + "SQLITE_PROTOCOL: locking protocol", + "SQLITE_IOERR: disk I/O error", + "ENOENT: no such file or directory, open '/tmp/db.sqlite'", + ]; + + for (const msg of transientPatterns) { + assert.equal(probeUtils.isTransientProbeError(new Error(msg)), true, `transient: ${msg}`); + } +}); + +test("FIX-GREEN: isTransientProbeError handles non-Error input gracefully", () => { + assert.equal(probeUtils.isTransientProbeError("SQLITE_BUSY"), true, "string error works"); + assert.equal( + probeUtils.isTransientProbeError("random string"), + false, + "non-matching string returns false" + ); + assert.equal(probeUtils.isTransientProbeError(null), false, "null returns false"); + assert.equal(probeUtils.isTransientProbeError(undefined), false, "undefined returns false"); + assert.equal(probeUtils.isTransientProbeError({}), false, "object without message returns false"); +}); + +test("BUG-CONFIRMED (regression guard): probe failure renames DB and loses persisted config", () => { + // This test confirms the SCENARIO we're preventing — if the probe path is + // reached (all transient retries exhausted or non-transient), data IS lost. + // This is the EXISTING behavior on non-transient errors; the fix only + // ADDED a retry window for transient errors before this path. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9541-data-loss-")); + const sqliteFile = path.join(dir, "storage.sqlite"); + + try { + const header = Buffer.alloc(100); + header.write("SQLite format 3\0"); + fs.writeFileSync(sqliteFile, header); + fs.writeFileSync(sqliteFile, "DATA_MARKER_PERSISTED_CONFIG", { flag: "a" }); + + const beforeContent = fs.readFileSync(sqliteFile, "utf-8"); + assert.ok( + beforeContent.includes("DATA_MARKER_PERSISTED_CONFIG"), + "data must be present before probe failure" + ); + + // Simulate probe failure: rename + create new empty DB + const failedPath = sqliteFile + `.probe-failed-${Date.now()}`; + fs.renameSync(sqliteFile, failedPath); + const newHeader = Buffer.alloc(100); + newHeader.write("SQLite format 3\0"); + fs.writeFileSync(sqliteFile, newHeader); + + const afterContent = fs.readFileSync(sqliteFile, "utf-8"); + assert.equal( + afterContent.includes("DATA_MARKER_PERSISTED_CONFIG"), + false, + "data MUST be lost when DB is renamed and recreated (corruption path behavior)" + ); + } finally { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + /* ok */ + } + } +}); + +test("FIX-GREEN: DATA_DIR no longer overridden at module scope in probe-9033-repro", async () => { + // Verify the fix in probe-9033-repro.test.ts no longer sets process.env.DATA_DIR + // at module scope. Read-only accesses to process.env.DATA_DIR are fine. + const reproTestSource = fs.readFileSync( + new URL("../../tests/unit/authz/probe-9033-repro.test.ts", import.meta.url), + "utf-8" + ); + + // Find lines that ASSIGN to process.env.DATA_DIR (not just read it) + const assignLines = reproTestSource + .split("\n") + .filter((line) => /process\.env\.DATA_DIR\s*=/.test(line) && !line.trim().startsWith("//")); + + assert.equal( + assignLines.length, + 0, + `probe-9033-repro must not assign process.env.DATA_DIR at module scope. Found: ${assignLines.map((l) => l.trim()).join(", ")}` + ); +}); diff --git a/tests/unit/probe-9575-tool-name-case.test.ts b/tests/unit/probe-9575-tool-name-case.test.ts new file mode 100644 index 0000000000..2b6cfa819b --- /dev/null +++ b/tests/unit/probe-9575-tool-name-case.test.ts @@ -0,0 +1,128 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +// We test the helper that will be added to toolCallHelper.ts. +// For the TDD probe, we directly test the scenario: case-sensitive Map.get +// fails for lowercase names, and the fix (case-insensitive fallback) resolves it. +// After the fix is implemented, the actual functions being tested here are +// restoreOpenAIToolNames (already exported) and the new caseInsensitiveToolNameLookup. + +describe("9575 - tool call name case sensitivity", () => { + const toolNameMap = new Map([ + ["Bash", "Bash"], + ["Read", "Read"], + ["Write", "Write"], + ["Glob", "Glob"], + ["Skill", "Skill"], + ["Edit", "Edit"], + ]); + + it("case-sensitive Map.get fails for lowercase tool names (THE BUG)", () => { + // Simulate upstream returning lowercase "bash" when tool is "Bash" + const upstreamName = "bash"; + const result = toolNameMap.get(upstreamName); + // Case-sensitive lookup returns undefined - this IS the bug + assert.equal(result, undefined, "case-sensitive get should fail for lowercase 'bash'"); + // The fallback expression: get() || name — passes through unchanged + const passthrough = toolNameMap.get(upstreamName) ?? upstreamName; + assert.equal(passthrough, "bash", "lowercase 'bash' passes through unchanged (THE BUG)"); + }); + + it("case-insensitive fallback resolves lowercase to PascalCase (THE FIX)", () => { + const upstreamName = "bash"; + // Simulate the fix: iteration-based case-insensitive lookup + const lowerName = upstreamName.toLowerCase(); + let found: string | undefined; + for (const [key, value] of toolNameMap) { + if (key.toLowerCase() === lowerName) { + found = value; + break; + } + } + assert.equal(found, "Bash", "case-insensitive lookup finds 'Bash' from 'bash'"); + }); + + it("exact match still works for already-correct PascalCase names", () => { + // When upstream returns correct PascalCase, exact Match.get should work + const result = toolNameMap.get("Bash"); + assert.equal(result, "Bash", "exact match works for PascalCase 'Bash'"); + }); + + it("restoreOpenAIToolNames: lowercase in aliases map", async () => { + // Test restoreOpenAIToolNames which uses aliases.get(fn.name) + const { restoreOpenAIToolNames } = + await import("../../open-sse/translator/helpers/toolCallHelper.ts"); + + // Simulate aliases where the key is the shortened lowercase version + const aliases = new Map([["bash", "Bash"]]); + const body = { + choices: [ + { + message: { + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "bash", arguments: "{}" }, + }, + ], + }, + }, + ], + }; + + // Before fix: aliases.get("bash") returns "Bash" directly because + // the key IS "bash" — this one actually works with exact match. + // The bug scenario is when aliases key is "Bash" and upstream returns "bash". + const aliasesReversed = new Map([["Bash", "bash"]]); + const bodyReversed = { + choices: [ + { + message: { + tool_calls: [ + { + id: "call_2", + type: "function", + function: { name: "bash", arguments: "{}" }, + }, + ], + }, + }, + ], + }; + + // Without fix: "bash" is not in map (has "Bash" as key), so lookup fails + const originalGet = aliasesReversed.get("bash"); + assert.equal( + originalGet, + undefined, + "case-sensitive get fails when key is 'Bash' but input is 'bash'" + ); + }); + + it("full pipeline: toolNameMap with PascalCase keys, response with lowercase", async () => { + // This simulates the exact bug scenario: + // toolNameMap has PascalCase entries from request translation + // Upstream model returns lowercase function call names + + const { caseInsensitiveToolNameLookup } = + await import("../../open-sse/translator/helpers/toolCallHelper.ts"); + + // Test the fix function + // Exact match case + const exactResult = caseInsensitiveToolNameLookup("Bash", toolNameMap); + assert.equal(exactResult, "Bash", "exact match works"); + + // Case-insensitive fallback case (THE BUG SCENARIO) + const fallbackResult = caseInsensitiveToolNameLookup("bash", toolNameMap); + assert.equal(fallbackResult, "Bash", "case-insensitive fallback resolves 'bash' to 'Bash'"); + + // Non-existent tool name + const noResult = caseInsensitiveToolNameLookup("nonexistent", toolNameMap); + assert.equal(noResult, undefined, "non-existent tool returns undefined"); + + // Null/undefined map + const nullResult = caseInsensitiveToolNameLookup("bash", null); + assert.equal(nullResult, undefined, "null map returns undefined"); + }); +}); diff --git a/tests/unit/provider-request-failure-pipeline.test.ts b/tests/unit/provider-request-failure-pipeline.test.ts index 1437f43f5e..8eadc9b7a6 100644 --- a/tests/unit/provider-request-failure-pipeline.test.ts +++ b/tests/unit/provider-request-failure-pipeline.test.ts @@ -43,11 +43,6 @@ async function waitFor(fn, timeoutMs = 1500) { return null; } -async function waitForAsyncSideEffects() { - await new Promise((resolve) => setImmediate(resolve)); - await new Promise((resolve) => setTimeout(resolve, 10)); -} - async function getLatestCallLog() { const rows = await getCallLogs({ limit: 5 }); if (!Array.isArray(rows) || rows.length === 0) return null; @@ -85,7 +80,6 @@ test.afterEach(async () => { globalThis.fetch = originalFetch; clearPendingRequests(); resetAccountSemaphores(); - await waitForAsyncSideEffects(); await resetStorage(); }); @@ -124,8 +118,8 @@ test("network failure persisted call log includes providerRequest in pipeline pa assert.equal(result.success, false); assert.equal(result.status, 502); - await waitForAsyncSideEffects(); - + // waitFor below polls for the exact DB state with 25ms intervals — no + // unreliable fixed-delay timer needed, even under CI load contention. const detail = await waitFor(getLatestCallLog); assert.ok(detail, "expected a call log to be persisted"); @@ -188,7 +182,6 @@ test("network timeout persisted call log includes providerRequest in pipeline pa } as any); const result = await invocation; - await waitForAsyncSideEffects(); assert.equal(result.success, false); assert.ok(result.status === 504, `expected 504 timeout, got ${result.status}`); @@ -244,8 +237,6 @@ test("provider error response (HTTP 502) includes both providerRequest and provi assert.equal(result.success, false); assert.equal(result.status, 502); - await waitForAsyncSideEffects(); - const detail = await waitFor(getLatestCallLog); assert.ok(detail, "expected a call log to be persisted"); @@ -312,8 +303,6 @@ test("successful response includes both providerRequest and providerResponse in assert.equal(result.success, true); - await waitForAsyncSideEffects(); - const detail = await waitFor(getLatestCallLog); assert.ok(detail, "expected a call log to be persisted"); @@ -391,7 +380,6 @@ test("streaming response preserves request headers in providerRequest pipeline p assert.equal(result.success, true); await result.response.text(); - await waitForAsyncSideEffects(); const detail = await waitFor(getLatestCallLog); assert.ok(detail, "expected a call log to be persisted"); @@ -475,7 +463,6 @@ test("CC-compatible providerRequest log keeps request beta headers and summarize assert.equal(result.success, true); await result.response.json(); - await waitForAsyncSideEffects(); const detail = await waitFor(getLatestCallLog); assert.ok(detail, "expected a call log to be persisted"); diff --git a/tests/unit/repro-9550-amazon-q-alias-resolution.test.ts b/tests/unit/repro-9550-amazon-q-alias-resolution.test.ts new file mode 100644 index 0000000000..7f31ef4b83 --- /dev/null +++ b/tests/unit/repro-9550-amazon-q-alias-resolution.test.ts @@ -0,0 +1,39 @@ +// repro-9550-amazon-q-alias-resolution.test.ts +// Issue #9550: amazon-q provider silently falls back to OpenAI's endpoint +// because the "aq" alias is never resolved to "amazon-q". + +import { describe, it } from "node:test"; +import { strict as assert } from "node:assert"; +import { resolveProviderAlias, parseModel } from "../../open-sse/services/model.ts"; +import { getExecutor } from "../../open-sse/executors/index.ts"; + +describe("Issue #9550 - amazon-q alias resolution", () => { + it("resolveProviderAlias('aq') should return 'amazon-q'", () => { + const provider = resolveProviderAlias("aq"); + assert.equal( + provider, + "amazon-q", + `Expected "amazon-q" but got "${provider}" — ALIAS_TO_PROVIDER_ID["aq"] is missing` + ); + }); + + it('parseModel("aq/amazon-q") should resolve provider to "amazon-q"', () => { + const parsed = parseModel("aq/amazon-q"); + assert.equal( + parsed.provider, + "amazon-q", + `parseModel("aq/amazon-q") provider should be "amazon-q" but got "${parsed.provider}"` + ); + assert.equal(parsed.model, "amazon-q"); + }); + + it('getExecutor("amazon-q") should exist and be a KiroExecutor', () => { + const executor = getExecutor("amazon-q"); + assert.ok(executor, "getExecutor('amazon-q') should return an executor"); + assert.equal( + executor.constructor.name, + "KiroExecutor", + "amazon-q executor should be a KiroExecutor" + ); + }); +}); diff --git a/tests/unit/search-registry.test.ts b/tests/unit/search-registry.test.ts index 138a13d83d..94c2dd84b3 100644 --- a/tests/unit/search-registry.test.ts +++ b/tests/unit/search-registry.test.ts @@ -201,7 +201,7 @@ test("selectProvider with unknown provider returns null", () => { test("selectProvider without argument returns cheapest provider", () => { const config = selectProvider(); assert.ok(config); - assert.equal(config.id, "searxng-search"); + assert.notEqual(config.id, "searxng-search"); }); test("selectProvider auto-selection never returns a fallbackOnly provider", () => { @@ -223,7 +223,7 @@ test("selectProvider still honors an explicit fallbackOnly provider", () => { test("selectProvider filters by search type support", () => { const config = selectProvider(undefined, "news"); assert.ok(config); - assert.equal(config.id, "searxng-search"); + assert.equal(config.id, "serper-search"); assert.equal(selectProvider("linkup-search", "news"), null); }); diff --git a/tests/unit/search-route.test.ts b/tests/unit/search-route.test.ts index 621381da65..ce634b4877 100644 --- a/tests/unit/search-route.test.ts +++ b/tests/unit/search-route.test.ts @@ -417,7 +417,7 @@ test("v1 search POST preserves stored SearXNG baseUrl for authless providers", a } }); -test("v1 search POST auto-select uses authless SearXNG when no API-key providers are configured", async () => { +test("v1 search POST returns 400 when auto-select finds no configured provider (searxng-search is now fallbackOnly)", async () => { const originalFetch = globalThis.fetch; let capturedUrl = ""; @@ -451,13 +451,8 @@ test("v1 search POST auto-select uses authless SearXNG when no API-key providers ); const body = (await response.json()) as any; - assert.equal(response.status, 200); - assert.equal( - capturedUrl, - "http://localhost:8888/search?q=auto+select+self+hosted+search&format=json&categories=general" - ); - assert.equal(body.provider, "searxng-search"); - assert.equal(body.results[0].title, "Auto-selected SearXNG result"); + assert.equal(response.status, 400); + assert.ok(body.error?.message || body.error); } finally { globalThis.fetch = originalFetch; } diff --git a/tests/unit/search-select-provider-searxng-bug-9543.test.ts b/tests/unit/search-select-provider-searxng-bug-9543.test.ts new file mode 100644 index 0000000000..b42b1885af --- /dev/null +++ b/tests/unit/search-select-provider-searxng-bug-9543.test.ts @@ -0,0 +1,30 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { SEARCH_PROVIDERS, selectProvider } = + await import("../../open-sse/config/searchRegistry.ts"); + +test("searxng-search has fallbackOnly: true (fix #9543)", () => { + const s = SEARCH_PROVIDERS["searxng-search"]; + assert.ok(s); + assert.equal(s.authType, "none"); + assert.equal(s.costPerQuery, 0); + assert.equal(s.fallbackOnly, true); +}); + +test("selectProvider does NOT auto-select searxng-search (fix #9543)", () => { + const auto = selectProvider(); + assert.ok(auto); + assert.notEqual(auto.id, "searxng-search"); +}); + +test("duckduckgo-free IS correctly fallbackOnly (design reference)", () => { + const d = SEARCH_PROVIDERS["duckduckgo-free"]; + assert.equal(d.fallbackOnly, true); +}); + +test("selectProvider with explicit searxng-search still works", () => { + const explicit = selectProvider("searxng-search", "web"); + assert.ok(explicit); + assert.equal(explicit.id, "searxng-search"); +});