fix(cache): scope semantic-cache signature to API key (#3740)

Adds the api_key_id dimension to generateSignature's SHA-256 hash so two callers with different API keys never receive each other's cached responses. Threads apiKeyId through checkSemanticCache + both write sites; migration 098 clears pre-existing key-less entries; unauthenticated requests stay isolated from keyed ones. 3 TDD tests.

Integrated into release/v3.8.23.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-12 22:12:24 -03:00
committed by GitHub
parent d6cfb4bab9
commit 858aa4521c
6 changed files with 50 additions and 4 deletions

View File

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

View File

@@ -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<string, unknown> | null;
const tokensSaved =

View File

@@ -23,6 +23,7 @@ export async function checkSemanticCache({
startTime,
log,
persistAttemptLogs,
apiKeyId,
}: {
semanticCacheEnabled: boolean;
body: Record<string, unknown>;
@@ -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) {

View File

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

View File

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

View File

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