diff --git a/CHANGELOG.md b/CHANGELOG.md index 52fd17a945..f2ff073a2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,17 @@ ### ✅ Tests +- **Combo strategy fallback coverage** (`tests/unit/combo-strategy-fallbacks.test.ts`, 11 tests): fill-first / p2c / random / cost-optimized / strict-random fallback paths (previously happy-path only), price-tie stability, stale strict-random deck degradation, unknown-strategy normalization to priority, and circuit-breaker HALF_OPEN recovery inside the combo loop + `preScreenTargets` (lazy-recovery contract). +- **`#1731` fast-skip suite restored** (`tests/integration/combo-provider-exhaustion.test.ts`): the five skipped tests were rewritten against the current routing policy (quota-exhausted 429 marks the provider for the request; transient 429 retries other connections; connection errors skip per-connection; nothing persists across requests) and re-enabled — 8/8 green. +- **Proxy context passthrough** (`tests/integration/proxy-context-passthrough.test.ts`): combo targets each execute under their own connection's proxy; `count_tokens` runs inside the connection's proxy context. +### 🐛 Fixed + +- chore(quality-gate): reconcile check-file-size baseline — freeze 27 inherited/this-round grown files at current LOC + register providerLimits.ts (greens the file-size gate; shrink tracked via #3501) +- fix(gemini): standard Gemini provider no longer sends tool calls without a thought_signature (falls back to context mode when the signature is unavailable), fixing HTTP 400 on multi-turn thinking-model tool calls (#3688) +- fix(antigravity): preserve gemini-3.1-pro High/Low budget tiers (upstream accepts the suffixed ids; stop collapsing to bare gemini-3.1-pro) (#3696) +- fix: streaming combos now fail over to the next target when an upstream returns an empty/content-filtered response instead of surfacing a blank reply (#3685) +- fix(qwen-web): migrate the Qwen Web provider to the v2 chat API. The legacy `/api/chat/completions` endpoint was retired upstream and returned `504` HTML from Alibaba's gateway for every request regardless of credentials, so no token refresh could help (#3288, discussion #2768). The executor now uses the two-step v2 flow (`/api/v2/chats/new` → `/api/v2/chat/completions?chat_id=`), replays the full browser cookie jar (cna + ssxmod_itna/itna2 + token) required by Alibaba's WAF instead of only a Bearer token, parses the phase-based SSE (think→reasoning, answer→content), and maps the WAF/expired HTML page to a clear actionable auth error instead of surfacing raw HTML. The credential requirement is now a full Cookie header, browser-login capture grabs the WAF cookies, and the model catalog is refreshed to the current upstream ids (`qwen3.7-max`, `qwen3.7-plus`, `qwen3.6-plus`; legacy ids kept as aliases). 17 unit tests. +- fix(cache): semantic cache responses are now scoped to the requesting API key — two callers with different API keys that send the same prompt and model will no longer receive each other's cached responses (#3740). `generateSignature` includes the `api_key_id` dimension in the SHA-256 hash; unauthenticated requests (no API key) remain isolated from keyed requests. Existing cache entries (generated without the key dimension) are cleared by migration `098`. - **Combo strategy fallback coverage** (`tests/unit/combo-strategy-fallbacks.test.ts`, 11 tests — thanks @diegosouzapw): fill-first / p2c / random / cost-optimized / strict-random fallback paths (previously happy-path only), price-tie stability, stale strict-random deck degradation, unknown-strategy normalization to priority, and circuit-breaker HALF_OPEN recovery inside the combo loop + `preScreenTargets` (lazy-recovery contract). - **`#1731` fast-skip suite restored** (`tests/integration/combo-provider-exhaustion.test.ts` — thanks @diegosouzapw): 5 previously-skipped integration tests rewritten against the current routing policy — 8/8 green. - **Proxy context passthrough** (`tests/integration/proxy-context-passthrough.test.ts` — thanks @diegosouzapw): combo targets each execute under their own connection's proxy; `count_tokens` runs inside the connection's proxy context. diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index c5f5188619..c2bf1943f3 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2292,6 +2292,7 @@ export async function handleChatCore({ startTime, log, persistAttemptLogs, + apiKeyId: apiKeyInfo?.id ?? undefined, }); if (cacheHit) { return cacheHit; @@ -5186,7 +5187,8 @@ export async function handleChatCore({ model, body.messages ?? body.input, body.temperature, - body.top_p + body.top_p, + apiKeyInfo?.id ?? undefined ); const tokensSaved = usage?.prompt_tokens + usage?.completion_tokens || 0; setCachedResponse(signature, model, translatedResponse, tokensSaved); @@ -5580,7 +5582,8 @@ export async function handleChatCore({ model, body.messages ?? body.input, body.temperature, - body.top_p + body.top_p, + apiKeyInfo?.id ?? undefined ); const u = streamUsage as Record | null; const tokensSaved = diff --git a/open-sse/handlers/chatCore/semanticCache.ts b/open-sse/handlers/chatCore/semanticCache.ts index 9ce133c1a2..6e99989e02 100644 --- a/open-sse/handlers/chatCore/semanticCache.ts +++ b/open-sse/handlers/chatCore/semanticCache.ts @@ -23,6 +23,7 @@ export async function checkSemanticCache({ startTime, log, persistAttemptLogs, + apiKeyId, }: { semanticCacheEnabled: boolean; body: Record; @@ -36,13 +37,15 @@ export async function checkSemanticCache({ startTime: number; log: unknown; persistAttemptLogs: (args: unknown) => void; + apiKeyId?: string | null; }) { if (semanticCacheEnabled && isCacheableForRead(body, clientRawRequest?.headers)) { const signature = generateSignature( model, body.messages ?? body.input, body.temperature, - body.top_p + body.top_p, + apiKeyId ?? undefined ); const cached = getCachedResponse(signature); if (cached) { diff --git a/src/lib/db/migrations/098_clear_semantic_cache_for_key_isolation.sql b/src/lib/db/migrations/098_clear_semantic_cache_for_key_isolation.sql new file mode 100644 index 0000000000..92e0570305 --- /dev/null +++ b/src/lib/db/migrations/098_clear_semantic_cache_for_key_isolation.sql @@ -0,0 +1,4 @@ +-- Migration 098: Clear semantic_cache after key-isolation fix (#3740) +-- Old signatures were computed without the API key ID dimension, so existing +-- entries would be shared across users. Truncating forces new scoped entries. +DELETE FROM semantic_cache; diff --git a/src/lib/semanticCache.ts b/src/lib/semanticCache.ts index 1fbc6a6548..b8ddea8a07 100644 --- a/src/lib/semanticCache.ts +++ b/src/lib/semanticCache.ts @@ -113,14 +113,16 @@ function getMemoryCache() { * @param {Array} messages - Normalized messages array * @param {number} temperature * @param {number} topP + * @param {string} [apiKeyId] - API key ID for per-key isolation (prevents cross-user cache hits) * @returns {string} hex signature */ -export function generateSignature(model, conversation, temperature = 0, topP = 1) { +export function generateSignature(model, conversation, temperature = 0, topP = 1, apiKeyId?: string) { const payload = JSON.stringify({ model, messages: normalizeConversation(conversation), temperature, top_p: topP, + ...(apiKeyId ? { api_key_id: apiKeyId } : {}), }); return crypto.createHash("sha256").update(payload).digest("hex"); } diff --git a/tests/unit/semantic-cache.test.ts b/tests/unit/semantic-cache.test.ts index 69185613a0..2205594b17 100644 --- a/tests/unit/semantic-cache.test.ts +++ b/tests/unit/semantic-cache.test.ts @@ -71,6 +71,29 @@ describe("Semantic Cache", () => { const sig2 = generateSignature("gpt-5", input2, 0, 1); assert.equal(sig1, sig2); }); + + // #3740: cross-user cache isolation — different API keys must not share cached responses + it("generates different signatures for different API key IDs (#3740)", () => { + const messages = [{ role: "user", content: "what is 2+2?" }]; + const sigKeyA = generateSignature("gpt-4o", messages, 0, 1, "key-id-alice"); + const sigKeyB = generateSignature("gpt-4o", messages, 0, 1, "key-id-bob"); + assert.notEqual(sigKeyA, sigKeyB, "different API keys must produce different cache signatures"); + }); + + it("generates consistent signatures for same API key ID (#3740)", () => { + const messages = [{ role: "user", content: "what is 2+2?" }]; + const sig1 = generateSignature("gpt-4o", messages, 0, 1, "key-id-alice"); + const sig2 = generateSignature("gpt-4o", messages, 0, 1, "key-id-alice"); + assert.equal(sig1, sig2); + }); + + it("matches keyless signature when apiKeyId is undefined (#3740)", () => { + const messages = [{ role: "user", content: "hello" }]; + // Unauthenticated requests (apiKeyId=undefined) must not collide with keyed requests + const sigKeyed = generateSignature("gpt-4o", messages, 0, 1, "some-key-id"); + const sigKeyless = generateSignature("gpt-4o", messages, 0, 1, undefined); + assert.notEqual(sigKeyed, sigKeyless); + }); }); describe("isCacheableForRead", () => {