From 9cd9b796caa953e146077d8402971db7a63a2c8f Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 17:03:49 -0400 Subject: [PATCH] fix(models-dev): sanitize models.dev registry entries and harden pricing resolver (#1345) * fix(models-dev): sanitize cached model entries before pricing lookup * style: apply prettier formatting --- src/web-server/models-dev/pricing-resolver.ts | 6 +++- src/web-server/models-dev/registry-cache.ts | 18 +++++++++--- tests/unit/model-pricing.test.ts | 17 +++++++++++ tests/unit/models-dev-registry-cache.test.ts | 29 +++++++++++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/web-server/models-dev/pricing-resolver.ts b/src/web-server/models-dev/pricing-resolver.ts index 08599e3c..715e7600 100644 --- a/src/web-server/models-dev/pricing-resolver.ts +++ b/src/web-server/models-dev/pricing-resolver.ts @@ -36,6 +36,10 @@ function normalizeId(value: string): string { return value.trim().toLowerCase(); } +function isModelEntry(value: unknown): value is ModelsDevModel { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + export function normalizeModelsDevProviderId( provider: string | null | undefined ): string | undefined { @@ -91,7 +95,7 @@ function findModel(provider: ModelsDevProvider, model: string): ModelsDevModel | const normalizedEntries = new Map(); for (const [key, value] of Object.entries(models)) { - if (!value || typeof value !== 'object') continue; + if (!isModelEntry(value)) continue; normalizedEntries.set(normalizeId(key), value); if (typeof value.id === 'string') normalizedEntries.set(normalizeId(value.id), value); } diff --git a/src/web-server/models-dev/registry-cache.ts b/src/web-server/models-dev/registry-cache.ts index 650f41b4..4f5253e7 100644 --- a/src/web-server/models-dev/registry-cache.ts +++ b/src/web-server/models-dev/registry-cache.ts @@ -38,10 +38,20 @@ function normalizeRegistryPayload(payload: unknown): ModelsDevRegistry | null { for (const [key, value] of Object.entries(payload)) { if (!isPlainObject(value)) continue; const id = typeof value.id === 'string' && value.id.trim() ? value.id.trim() : key; - const models = isPlainObject(value.models) - ? (value.models as NonNullable) - : undefined; - if (!models || Object.keys(models).length === 0) continue; + const modelsPayload = isPlainObject(value.models) ? value.models : undefined; + if (!modelsPayload) continue; + + const models: NonNullable = {}; + for (const [modelKey, modelValue] of Object.entries(modelsPayload)) { + if (!isPlainObject(modelValue)) continue; + const modelId = + typeof modelValue.id === 'string' && modelValue.id.trim() ? modelValue.id.trim() : modelKey; + models[modelKey] = { + ...(modelValue as NonNullable[string]), + id: modelId, + }; + } + if (Object.keys(models).length === 0) continue; providers[id] = { ...(value as ModelsDevProvider), diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index b19e9f75..9e6eca84 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -467,5 +467,22 @@ describe('model-pricing', () => { expect(calculateCost(usage, 'gpt-5.5', { provider: 'openai' })).toBe(35.5); expect(calculateCost(usage, 'gpt-5.5', { provider: 'ghcp' })).toBe(0); }); + + it('gracefully ignores malformed cached model entries', () => { + setCachedModelsDevRegistry( + { + openai: { + id: 'openai', + models: { + 'null-entry': null, + 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } }, + }, + }, + } as unknown as Parameters[0] + ); + + expect(() => getModelPricing('openai/gpt-5.5')).not.toThrow(); + expect(getModelPricing('openai/gpt-5.5').inputPerMillion).toBe(5); + }); }); }); diff --git a/tests/unit/models-dev-registry-cache.test.ts b/tests/unit/models-dev-registry-cache.test.ts index 20b3dfb5..9f625607 100644 --- a/tests/unit/models-dev-registry-cache.test.ts +++ b/tests/unit/models-dev-registry-cache.test.ts @@ -86,6 +86,35 @@ describe('models.dev registry cache', () => { expect(registry?.openai.models?.['gpt-5.5']?.cost?.output).toBe(30); }); + it('filters malformed model entries before caching remote payloads', async () => { + const fetchImpl: typeof fetch = async () => + new Response( + JSON.stringify({ + openai: { + id: 'openai', + models: { + 'null-entry': null, + 'string-entry': 'bad', + 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } }, + }, + }, + }), + { status: 200 } + ); + + const registry = await refreshModelsDevRegistry({ + force: true, + fetchImpl, + now: () => 123, + }); + + expect(registry?.openai.models?.['gpt-5.5']?.cost?.input).toBe(5); + expect(registry?.openai.models?.['null-entry']).toBeUndefined(); + expect(registry?.openai.models?.['string-entry']).toBeUndefined(); + expect(getCachedModelsDevRegistry({ allowStale: true })?.openai.models?.['null-entry']).toBeUndefined(); + expect(getCachedModelsDevRegistry({ allowStale: true })?.openai.models?.['string-entry']).toBeUndefined(); + }); + it('ignores malformed cache files', () => { fs.mkdirSync(getCcsDir(), { recursive: true }); fs.writeFileSync(path.join(getCcsDir(), 'models-dev-registry-cache.json'), '{not json');