Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,8 @@ export function clearModelCache(config?: OmniRouteConfig, apiKey?: string): void
modelCache.clear();
debug('All model caches cleared');
}
// Also clear combo cache
clearComboCache();
// Also clear matching combo cache entry (or all when no config provided)
clearComboCache(config && apiKey ? { baseUrl: config.baseUrl, apiKey } : undefined);
}

/**
Expand Down
63 changes: 47 additions & 16 deletions src/omniroute-combos.ts
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 {
Expand Down Expand Up @@ -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>();

Copy link
Copy Markdown
Contributor

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 comboCache was a single variable that got overwritten on every fetch, naturally bounding memory. Now comboCaches grows indefinitely until clearComboCache() 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_TTL before inserting the fresh one. This keeps the Map bounded without relying on explicit cleanup calls.

📏 Severity: suggestion


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Author

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 calls pruneExpiredComboCaches() before inserting a fresh entry, so identities older than COMBO_CACHE_TTL are removed proactively. This keeps the multi-entry map bounded without waiting for an explicit clear.

Regression: expired combo cache entries are pruned on write.

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
*/
Expand All @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If baseUrl or apiKey is missing or undefined in the configuration, calling getComboCacheKey will throw a synchronous TypeError (e.g., when calling .replace on undefined or passing a non-string to createHash().update()). Adding a defensive guard here ensures that we handle missing configuration gracefully by logging a warning and returning null instead of crashing the process.

if (!baseUrl || !apiKey) {
  warn('Missing baseUrl or apiKey in config');
  return null;
}

const cacheKey = getComboCacheKey(baseUrl, apiKey);

Copy link
Copy Markdown
Author

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 returns null before deriving a cache key when either value is empty, with regression coverage that proves no fetch occurs.


// 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`;
Expand Down Expand Up @@ -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;
Expand All @@ -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).
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: clearComboCache() is still living in the singleton era. It unconditionally wipes ALL endpoint/credential caches, which means callers like clearModelCache(config, apiKey) in src/models.ts:386 — designed to clear a single config's data — also nukes every other endpoint's combo cache. That's emptying the entire office fridge because your yogurt expired.

🩹 The Fix: Either accept an optional cache key parameter for selective deletion, or update clearModelCache to only clear the matching combo cache entry when specific config and apiKey are provided. Match the granularity to the caller's intent.

📏 Severity: suggestion


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Author

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.

clearComboCache(config?) is now selective:

  • with baseUrl + apiKey → delete only that endpoint/credential identity
  • without config → clear the full map (legacy behavior)

clearModelCache(config, apiKey) routes through the selective form so a single-config model-cache clear no longer wipes every combo identity.

Regression: clearComboCache can target one endpoint/credential pair.

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

/**
Expand Down Expand Up @@ -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;
}

Expand Down
170 changes: 170 additions & 0 deletions test/omniroute-combos.test.mjs
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;
}
});