-
Notifications
You must be signed in to change notification settings - Fork 23
fix: isolate combo cache by endpoint and credential #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| import { createHash } from 'node:crypto'; | ||
|
|
||
| import type { OmniRouteConfig, OmniRouteModel, OmniRouteModelMetadata } from './types.js'; | ||
| import type { ModelsDevIndex, ModelsDevModel } from './models-dev.js'; | ||
| import { | ||
|
|
@@ -46,10 +48,24 @@ interface ComboCache { | |
| timestamp: number; | ||
| } | ||
|
|
||
| // In-memory cache for combo data | ||
| let comboCache: ComboCache | null = null; | ||
| // Cache entries are isolated by endpoint and a non-reversible credential digest. | ||
| const comboCaches = new Map<string, ComboCache>(); | ||
| const COMBO_CACHE_TTL = 5 * 60 * 1000; // 5 minutes | ||
|
|
||
| function pruneExpiredComboCaches(now = Date.now()): void { | ||
| for (const [key, cached] of comboCaches) { | ||
| if (now - cached.timestamp >= COMBO_CACHE_TTL) { | ||
| comboCaches.delete(key); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function getComboCacheKey(baseUrl: string, apiKey: string): string { | ||
| const endpoint = `${baseUrl.replace(/\/v1\/?$/, '').replace(/\/$/, '')}/api/combos`; | ||
| const credentialDigest = createHash('sha256').update(apiKey).digest('hex'); | ||
| return `${endpoint}\0${credentialDigest}`; | ||
| } | ||
|
|
||
| /** | ||
| * Fetch combo data from OmniRoute /api/combos endpoint | ||
| */ | ||
|
|
@@ -59,10 +75,18 @@ export async function fetchComboData( | |
| const baseUrl = config.baseUrl; | ||
| const apiKey = config.apiKey; | ||
|
|
||
| // Check cache first | ||
| if (comboCache && Date.now() - comboCache.timestamp < COMBO_CACHE_TTL) { | ||
| if (!baseUrl || !apiKey) { | ||
| warn('Cannot fetch combo data without baseUrl and apiKey'); | ||
| return null; | ||
| } | ||
|
|
||
| const cacheKey = getComboCacheKey(baseUrl, apiKey); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If if (!baseUrl || !apiKey) {
warn('Missing baseUrl or apiKey in config');
return null;
}
const cacheKey = getComboCacheKey(baseUrl, apiKey);
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in the latest push: |
||
|
|
||
| // Check the cache for this endpoint and credential identity. | ||
| const cached = comboCaches.get(cacheKey); | ||
| if (cached && Date.now() - cached.timestamp < COMBO_CACHE_TTL) { | ||
| debug('Using cached combo data'); | ||
| return comboCache.combos; | ||
| return cached.combos; | ||
| } | ||
|
|
||
| const combosUrl = `${baseUrl.replace(/\/v1\/?$/, '').replace(/\/$/, '')}/api/combos`; | ||
|
|
@@ -102,11 +126,12 @@ export async function fetchComboData( | |
| } | ||
| } | ||
|
|
||
| // Update cache | ||
| comboCache = { | ||
| // Update only this endpoint/credential cache entry. | ||
| pruneExpiredComboCaches(); | ||
| comboCaches.set(cacheKey, { | ||
| combos: comboMap, | ||
| timestamp: Date.now(), | ||
| }; | ||
| }); | ||
|
|
||
| debug(`Successfully fetched ${comboMap.size} combos`); | ||
| return comboMap; | ||
|
|
@@ -119,11 +144,18 @@ export async function fetchComboData( | |
| } | ||
|
|
||
| /** | ||
| * Clear the combo cache | ||
| * Clear combo cache entries. | ||
| * When config is provided, only that endpoint/credential identity is cleared. | ||
| * Without config, the entire map is cleared (legacy behavior). | ||
| */ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔥 The Roast: 🩹 The Fix: Either accept an optional cache key parameter for selective deletion, or update 📏 Severity: suggestion Reply with
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in the latest push.
Regression: |
||
| export function clearComboCache(): void { | ||
| comboCache = null; | ||
| debug('Combo cache cleared'); | ||
| export function clearComboCache(config?: Pick<OmniRouteConfig, 'baseUrl' | 'apiKey'>): void { | ||
| if (!config?.baseUrl || !config.apiKey) { | ||
| comboCaches.clear(); | ||
| debug('All combo caches cleared'); | ||
| return; | ||
| } | ||
| comboCaches.delete(getComboCacheKey(config.baseUrl, config.apiKey)); | ||
| debug('Combo cache cleared for provided configuration'); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -327,11 +359,10 @@ export function isComboModel(model: OmniRouteModel): boolean { | |
| return true; | ||
| } | ||
|
|
||
| // Fallback: check if it's in our combo cache | ||
| if (comboCache?.combos?.has(model.id)) { | ||
| return true; | ||
| // Fallback: check all endpoint/credential-specific combo caches. | ||
| for (const cached of comboCaches.values()) { | ||
| if (cached.combos.has(model.id)) return true; | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| import { afterEach, test } from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
|
|
||
| import { clearComboCache, fetchComboData } from '../dist/runtime.js'; | ||
|
|
||
| const ORIGINAL_FETCH = global.fetch; | ||
|
|
||
| function config(baseUrl, apiKey) { | ||
| return { | ||
| baseUrl, | ||
| apiKey, | ||
| apiMode: 'chat', | ||
| modelCacheTtl: 60_000, | ||
| }; | ||
| } | ||
|
|
||
| afterEach(() => { | ||
| clearComboCache(); | ||
| global.fetch = ORIGINAL_FETCH; | ||
| }); | ||
|
|
||
| test('combo cache is isolated by endpoint and API key', async () => { | ||
| const calls = []; | ||
| global.fetch = async (input, init = {}) => { | ||
| const url = input instanceof Request ? input.url : input.toString(); | ||
| const authorization = new Headers(init.headers).get('authorization'); | ||
| calls.push({ url, authorization }); | ||
| const suffix = url.includes('a.example') && authorization === 'Bearer key-a' ? 'a' : 'b'; | ||
| return new Response(JSON.stringify({ | ||
| combos: [{ | ||
| id: `combo-${suffix}`, | ||
| name: `combo-${suffix}`, | ||
| models: [`provider/model-${suffix}`], | ||
| strategy: 'priority', | ||
| config: {}, | ||
| createdAt: '2026-01-01T00:00:00Z', | ||
| updatedAt: '2026-01-01T00:00:00Z', | ||
| }], | ||
| }), { status: 200, headers: { 'content-type': 'application/json' } }); | ||
| }; | ||
|
|
||
| const a = await fetchComboData(config('https://a.example/v1', 'key-a')); | ||
| const b = await fetchComboData(config('https://b.example/v1', 'key-b')); | ||
| const aAgain = await fetchComboData(config('https://a.example/v1', 'key-a')); | ||
|
|
||
| assert.deepEqual([...a.keys()], ['combo-a']); | ||
| assert.deepEqual([...b.keys()], ['combo-b']); | ||
| assert.deepEqual([...aAgain.keys()], ['combo-a']); | ||
| assert.equal(calls.length, 2, 'each cache identity should fetch once'); | ||
| }); | ||
|
|
||
| test('combo cache is isolated when credentials change for the same endpoint', async () => { | ||
| let calls = 0; | ||
| global.fetch = async (_input, init = {}) => { | ||
| calls += 1; | ||
| const authorization = new Headers(init.headers).get('authorization'); | ||
| const suffix = authorization === 'Bearer key-a' ? 'a' : 'b'; | ||
| return new Response(JSON.stringify({ | ||
| combos: [{ | ||
| id: `combo-${suffix}`, | ||
| name: `combo-${suffix}`, | ||
| models: [], | ||
| strategy: 'priority', | ||
| config: {}, | ||
| createdAt: '2026-01-01T00:00:00Z', | ||
| updatedAt: '2026-01-01T00:00:00Z', | ||
| }], | ||
| }), { status: 200, headers: { 'content-type': 'application/json' } }); | ||
| }; | ||
|
|
||
| const first = await fetchComboData(config('https://same.example/v1', 'key-a')); | ||
| const second = await fetchComboData(config('https://same.example/v1', 'key-b')); | ||
|
|
||
| assert.deepEqual([...first.keys()], ['combo-a']); | ||
| assert.deepEqual([...second.keys()], ['combo-b']); | ||
| assert.equal(calls, 2); | ||
| }); | ||
|
|
||
|
|
||
| test('fetchComboData returns null when endpoint or credential is missing', async () => { | ||
| global.fetch = async () => { | ||
| throw new Error('fetch must not be called'); | ||
| }; | ||
|
|
||
| assert.equal(await fetchComboData(config('', 'key-a')), null); | ||
| assert.equal(await fetchComboData(config('https://a.example/v1', '')), null); | ||
| }); | ||
|
|
||
| test('clearComboCache can target one endpoint/credential pair', async () => { | ||
| clearComboCache(); | ||
| const first = config('https://a.example/v1', 'key-a'); | ||
| const second = config('https://b.example/v1', 'key-b'); | ||
| let calls = 0; | ||
| global.fetch = async (url) => { | ||
| calls += 1; | ||
| const host = String(url); | ||
| return { | ||
| ok: true, | ||
| async json() { | ||
| return { | ||
| combos: [ | ||
| { | ||
| id: host.includes('a.example') ? 'combo-a' : 'combo-b', | ||
| name: host.includes('a.example') ? 'combo-a' : 'combo-b', | ||
| models: ['model-1'], | ||
| strategy: 'priority', | ||
| config: {}, | ||
| createdAt: '2026-01-01T00:00:00Z', | ||
| updatedAt: '2026-01-01T00:00:00Z', | ||
| }, | ||
| ], | ||
| }; | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| assert.ok(await fetchComboData(first)); | ||
| assert.ok(await fetchComboData(second)); | ||
| assert.equal(calls, 2); | ||
|
|
||
| clearComboCache(first); | ||
| assert.ok(await fetchComboData(first)); | ||
| assert.equal(calls, 3, 'first identity should refetch after selective clear'); | ||
| assert.ok(await fetchComboData(second)); | ||
| assert.equal(calls, 3, 'second identity should remain cached'); | ||
| }); | ||
|
|
||
| test('expired combo cache entries are pruned on write', async () => { | ||
| clearComboCache(); | ||
| const first = config('https://a.example/v1', 'key-a'); | ||
| const second = config('https://b.example/v1', 'key-b'); | ||
| let now = 1_000_000; | ||
| const realNow = Date.now; | ||
| Date.now = () => now; | ||
| let calls = 0; | ||
| global.fetch = async (url) => { | ||
| calls += 1; | ||
| const host = String(url); | ||
| return { | ||
| ok: true, | ||
| async json() { | ||
| return { | ||
| combos: [ | ||
| { | ||
| id: host.includes('a.example') ? 'combo-a' : 'combo-b', | ||
| name: host.includes('a.example') ? 'combo-a' : 'combo-b', | ||
| models: ['model-1'], | ||
| strategy: 'priority', | ||
| config: {}, | ||
| createdAt: '2026-01-01T00:00:00Z', | ||
| updatedAt: '2026-01-01T00:00:00Z', | ||
| }, | ||
| ], | ||
| }; | ||
| }, | ||
| }; | ||
| }; | ||
|
|
||
| try { | ||
| assert.ok(await fetchComboData(first)); | ||
| now += 6 * 60 * 1000; // beyond TTL | ||
| assert.ok(await fetchComboData(second)); | ||
| // first is expired and should have been pruned on the second write; refetch first | ||
| assert.ok(await fetchComboData(first)); | ||
| assert.equal(calls, 3); | ||
| } finally { | ||
| Date.now = realNow; | ||
| } | ||
| }); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔥 The Roast: This Map is a hoarder's paradise — it accepts every endpoint/credential pair it ever meets and never throws anything away. The old
comboCachewas a single variable that got overwritten on every fetch, naturally bounding memory. NowcomboCachesgrows indefinitely untilclearComboCache()is called. In a long-running process or server that rotates credentials, you've just scheduled a memory leak.🩹 The Fix: Evict stale entries proactively. On a cache miss, prune any entries older than
COMBO_CACHE_TTLbefore inserting the fresh one. This keeps the Map bounded without relying on explicit cleanup calls.📏 Severity: suggestion
Reply with
@kilocode-bot fix itto have Kilo Code address this issue.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in the latest push.
fetchComboData()now callspruneExpiredComboCaches()before inserting a fresh entry, so identities older thanCOMBO_CACHE_TTLare removed proactively. This keeps the multi-entry map bounded without waiting for an explicit clear.Regression:
expired combo cache entries are pruned on write.