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
5 changes: 5 additions & 0 deletions .changeset/clean-catalog-payloads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code-sdk": patch
---

Reject invalid catalog payloads consistently.
14 changes: 11 additions & 3 deletions packages/node-sdk/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,13 @@ export async function fetchCatalog(
if (!res.ok) {
throw new CatalogFetchError(`Failed to fetch catalog (HTTP ${res.status}).`, res.status);
}
const payload: unknown = await res.json();
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
let payload: unknown;
try {
payload = await res.json();
} catch {
throw new Error(`Unexpected catalog response from ${url}.`);
}
if (!isCatalogPayload(payload)) throw new Error(`Unexpected catalog response from ${url}.`);
return payload as Catalog;
}

Expand Down Expand Up @@ -109,12 +112,17 @@ export interface ApplyCatalogProviderOptions {
export function loadBuiltInCatalog(text?: string): Catalog | undefined {
if (typeof text !== 'string' || text.length === 0) return undefined;
try {
return JSON.parse(text) as Catalog;
const payload: unknown = JSON.parse(text);
return isCatalogPayload(payload) ? (payload as Catalog) : undefined;
} catch {
return undefined;
}
}

function isCatalogPayload(payload: unknown): boolean {
return typeof payload === 'object' && payload !== null && !Array.isArray(payload);
}

/**
* Writes a catalog-selected provider and its model aliases into `config` and
* marks it the default. Model metadata (context, output limit, capabilities)
Expand Down
22 changes: 22 additions & 0 deletions packages/node-sdk/test/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
catalogProviderModels,
CatalogFetchError,
fetchCatalog,
loadBuiltInCatalog,
type CatalogModel,
} from '../src/catalog';

Expand All @@ -17,6 +18,13 @@ function catalogResponse(body: unknown, status = 200): Response {
});
}

function rawCatalogResponse(body: string, status = 200): Response {
return new Response(body, {
status,
headers: { 'Content-Type': 'application/json' },
});
}

const model: CatalogModel = {
id: 'm1',
name: 'M1',
Expand Down Expand Up @@ -55,6 +63,13 @@ describe('fetchCatalog', () => {
).rejects.toThrow(/Unexpected catalog response/);
});

it('throws on invalid JSON', async () => {
const fetchMock = vi.fn(async () => rawCatalogResponse('{'));
await expect(
fetchCatalog('https://x', { fetchImpl: fetchMock as unknown as typeof fetch }),
).rejects.toThrow(/Unexpected catalog response/);
});

it('sends the given User-Agent, and none by default', async () => {
const fetchMock = vi.fn(async () => catalogResponse({}));

Expand All @@ -79,6 +94,13 @@ describe('fetchCatalog', () => {
});
});

describe('loadBuiltInCatalog', () => {
it('returns undefined for invalid or non-object payloads', () => {
expect(loadBuiltInCatalog('{')).toBeUndefined();
expect(loadBuiltInCatalog('[1, 2]')).toBeUndefined();
});
});

describe('catalogModelToAlias', () => {
it('flattens a catalog model capability into alias fields', () => {
expect(catalogModelToAlias('anthropic', model)).toEqual({
Expand Down