mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
d6cfb4bab9
commit
858aa4521c
11
CHANGELOG.md
11
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.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user