Merge remote-tracking branch 'origin/release/v3.8.50' into babysit/9571-plugin-streaming-usage-timing

This commit is contained in:
diegosouzapw
2026-08-06 23:58:33 -03:00
59 changed files with 1767 additions and 528 deletions

View File

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

View File

@@ -0,0 +1 @@
- fix(compression): drop orphan custom_tool_call/local_shell_call/apply_patch_call on compaction restore (#8946)

View File

@@ -0,0 +1 @@
- fix(oauth): Kiro import token endpoint no longer overwrites existing connection when using shared cached OIDC clientId (#9435)

View File

@@ -0,0 +1 @@
- fix(ci): include combo-matrix tests in test-integration job (#9531)

View File

@@ -0,0 +1 @@
- fix(test): prevent flaky modelsDevSync timer assertions by serializing test execution within the file (#9534)

View File

@@ -0,0 +1 @@
- fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536)

View File

@@ -0,0 +1 @@
- fix(db): add transient-error retry to corruption probe to prevent data loss under concurrent load (#9541)

View File

@@ -0,0 +1 @@
- fix(search): mark searxng-search as fallbackOnly to prevent auto-select without instance (#9543)

View File

@@ -0,0 +1 @@
- fix(providers): strip provider prefix in getModelTargetFormat to route GPT-5.6 models to /v1/responses (#9545)

View File

@@ -0,0 +1 @@
- fix(model): add "aq" alias for amazon-q provider so parseModel resolves it instead of falling back to OpenAI (#9550)

View File

@@ -0,0 +1 @@
- fix(proxy): NO_PROXY now bypasses context-level proxy in resolveProxyForRequest (#9551)

View File

@@ -0,0 +1 @@
- fix(build): lazy-resolve module-level fs paths to avoid Turbopack NFT whole-source trace (#9560)

View File

@@ -0,0 +1 @@
- fix(sse): replace timer-based waits with polling to fix flaky chatCore/SSE tests under CI load (#9567)

View File

@@ -0,0 +1 @@
- **fix(translator):** restore original tool name casing in Gemini/Antigravity response translators ([#9568](https://github.com/diegosouzapw/OmniRoute/issues/9568))

View File

@@ -0,0 +1 @@
- fix(translator): add case-insensitive fallback for upstream tool call name lookups (#9575)

View File

@@ -12,27 +12,27 @@ export const PROVIDER_MODELS: Record<string, RegistryModel[]> = new Proxy(
{} as Record<string, RegistryModel[]>,
{
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<string, RegistryModel[]>)[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<string, string> = new Proxy(
{} as Record<string, string>,
{
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<string, string>)[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;
}

View File

@@ -207,6 +207,7 @@ export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
maxMaxResults: 50,
timeoutMs: 10_000,
cacheTTLMs: 3 * 60 * 1000,
fallbackOnly: true,
},
"ollama-search": {

View File

@@ -2309,10 +2309,24 @@ export async function handleChatCore({
const nativeClaudeToolNameMap = isClaudePassthrough
? buildClaudePassthroughToolNameMap(body)
: null;
const toolNameMap =
let toolNameMap: Map<string, string> | 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;

View File

@@ -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<string, unknown>;
@@ -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;

View File

@@ -259,7 +259,14 @@ async function captureViaCdp(opts: {
if (capturedAccessToken) return;
const request = params.request as
{ url?: string; headers?: Record<string, string> } | 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);

View File

@@ -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?.(

View File

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

View File

@@ -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/<model>")
// 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.

View File

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

View File

@@ -37,10 +37,22 @@ type OpenAIToolCallLike = {
export function buildChangedToolNameMap(
toolNameMap: Map<string, string>
): Map<string, string> | 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<string, string>();
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 {

View File

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

View File

@@ -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<Record<string, unknown>>
) {
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 = {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown> = { 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();

View File

@@ -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 */
}
}
}
}

96
src/lib/db/probeUtils.ts Normal file
View File

@@ -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<string, unknown>
) => {
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;
}

View File

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

View File

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

View File

@@ -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<string, unknown>).usage as Record<string, unknown>;
// 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<string, unknown>).usage as Record<string, unknown>;
// 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<string, unknown>).usage as Record<string, unknown>;
// 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<string, unknown>).usage as Record<string, unknown>;
// 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<string, unknown>).usage as Record<string, unknown>;
// 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");
});
});

View File

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

View File

@@ -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<string, string | undefined>,
fn: () => unknown
): Promise<unknown> {
const previous = new Map<string, string | undefined>();
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");
});
}
);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown> {
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<string, unknown>[] } {
const input: Record<string, unknown>[] = [];
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<string, unknown>[]);
const target = Math.max(5_000, Math.floor(before * 0.5));
const result = compressContext(adapter.body as Record<string, unknown>, {
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<string, unknown>, {
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})`
);
});

View File

@@ -382,6 +382,6 @@ test("aborting the client signal stops the keepalive stream (#2544)", async () =
if (done) return true;
}
})();
const timed = new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 500));
const timed = new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 5000));
assert.equal(await Promise.race([drained, timed]), true, "stream should close after abort");
});

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

@@ -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<string, string>([
["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<string, string>([["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<string, string>([["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");
});
});

View File

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

View File

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

View File

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

View File

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

View File

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