diff --git a/.changeset/catalog-builtin-fallback.md b/.changeset/catalog-builtin-fallback.md new file mode 100644 index 00000000000..712144ec889 --- /dev/null +++ b/.changeset/catalog-builtin-fallback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fall back to the built-in models.dev catalog snapshot when the public catalog is unreachable, so Known third-party provider import still works offline or in blocked networks. diff --git a/apps/kimi-code/src/cli/sub/provider.ts b/apps/kimi-code/src/cli/sub/provider.ts index 3ade36aa422..bb61b0add3d 100644 --- a/apps/kimi-code/src/cli/sub/provider.ts +++ b/apps/kimi-code/src/cli/sub/provider.ts @@ -25,7 +25,6 @@ import { CatalogFetchError, createKimiHarness, DEFAULT_CATALOG_URL, - fetchCatalog, resolveCatalogImport, type Catalog, type CatalogProviderEntry, @@ -35,6 +34,7 @@ import { import type { Command } from 'commander'; import { createKimiCodeHostIdentity, createKimiCodeUserAgent } from '#/cli/version'; +import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch'; interface WritableLike { write(chunk: string): boolean; @@ -434,7 +434,13 @@ export async function handleCatalogAdd( async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise { try { - return await fetchCatalog(url, { userAgent: createKimiCodeUserAgent() }); + const loaded = await fetchCatalogOrBuiltIn(url, { userAgent: createKimiCodeUserAgent() }); + if (loaded.fromBuiltIn) { + deps.stderr.write( + `Warning: failed to reach ${url}; using the built-in models.dev catalog snapshot.\n`, + ); + } + return loaded.catalog; } catch (error) { const suffix = error instanceof CatalogFetchError ? ` (HTTP ${String(error.status)})` : ''; deps.stderr.write(`Failed to fetch catalog from ${url}${suffix}: ${errorMessage(error)}\n`); diff --git a/apps/kimi-code/src/tui/commands/provider.ts b/apps/kimi-code/src/tui/commands/provider.ts index 23bef02ba14..dbfbddfcb2b 100644 --- a/apps/kimi-code/src/tui/commands/provider.ts +++ b/apps/kimi-code/src/tui/commands/provider.ts @@ -9,13 +9,13 @@ import { catalogProviderModels, CatalogFetchError, DEFAULT_CATALOG_URL, - fetchCatalog, resolveCatalogImport, type Catalog, type ThinkingEffort, } from '@moonshot-ai/kimi-code-sdk'; import { createKimiCodeUserAgent } from '#/cli/version'; +import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch'; import { ChoicePickerComponent } from '../components/dialogs/choice-picker'; import { CustomRegistryImportDialogComponent, @@ -162,11 +162,17 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise { const spinner = host.showLoginProgressSpinner(`Fetching catalog from ${DEFAULT_CATALOG_URL}`); let catalog: Catalog | undefined; try { - catalog = await fetchCatalog(DEFAULT_CATALOG_URL, { + const loaded = await fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { signal: controller.signal, userAgent: createKimiCodeUserAgent(), }); - spinner.stop({ ok: true, label: 'Catalog loaded.' }); + catalog = loaded.catalog; + spinner.stop({ + ok: true, + label: loaded.fromBuiltIn + ? 'Catalog loaded from built-in snapshot (models.dev unreachable).' + : 'Catalog loaded.', + }); } catch (error) { if (controller.signal.aborted) { spinner.stop({ ok: false, label: 'Aborted.' }); diff --git a/apps/kimi-code/src/utils/catalog-fetch.ts b/apps/kimi-code/src/utils/catalog-fetch.ts new file mode 100644 index 00000000000..0f3ffdddfb8 --- /dev/null +++ b/apps/kimi-code/src/utils/catalog-fetch.ts @@ -0,0 +1,57 @@ +import { + DEFAULT_CATALOG_URL, + fetchCatalog, + loadBuiltInCatalog, + type Catalog, + type FetchCatalogOptions, +} from '@moonshot-ai/kimi-code-sdk'; + +import { BUILT_IN_CATALOG_JSON } from '#/built-in-catalog'; + +export interface FetchCatalogOrBuiltInResult { + readonly catalog: Catalog; + /** True when the network fetch failed and the release-build snapshot was used. */ + readonly fromBuiltIn: boolean; +} + +export interface FetchCatalogOrBuiltInOptions extends FetchCatalogOptions { + /** + * Override the built-in snapshot JSON (tests). Defaults to the tsdown-injected + * `__KIMI_CODE_BUILT_IN_CATALOG__` constant. + */ + readonly builtInJson?: string; +} + +/** + * Fetches a models.dev-style catalog, falling back to the release-build + * snapshot when the public default URL is unreachable. + * + * Custom `--url` overrides never fall back — a private registry must fail + * loudly rather than silently substitute models.dev. User abort + * (`signal.aborted`) also skips the fallback so Cancel stays Cancel. + */ +export async function fetchCatalogOrBuiltIn( + url: string, + options: FetchCatalogOrBuiltInOptions = {}, +): Promise { + try { + const catalog = await fetchCatalog(url, options); + return { catalog, fromBuiltIn: false }; + } catch (error) { + if (options.signal?.aborted) throw error; + if (isAbortError(error)) throw error; + if (url !== DEFAULT_CATALOG_URL) throw error; + const builtIn = loadBuiltInCatalog(options.builtInJson ?? BUILT_IN_CATALOG_JSON); + if (builtIn === undefined) throw error; + return { catalog: builtIn, fromBuiltIn: true }; + } +} + +function isAbortError(error: unknown): boolean { + return ( + (typeof DOMException !== 'undefined' && + error instanceof DOMException && + error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + ); +} diff --git a/apps/kimi-code/test/utils/catalog-fetch.test.ts b/apps/kimi-code/test/utils/catalog-fetch.test.ts new file mode 100644 index 00000000000..e609df88722 --- /dev/null +++ b/apps/kimi-code/test/utils/catalog-fetch.test.ts @@ -0,0 +1,84 @@ +import { DEFAULT_CATALOG_URL, CatalogFetchError } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { fetchCatalogOrBuiltIn } from '#/utils/catalog-fetch'; + +const BUILT_IN = JSON.stringify({ + anthropic: { + id: 'anthropic', + name: 'Anthropic', + models: { 'claude-test': { id: 'claude-test', limit: { context: 200000 } } }, + }, +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('fetchCatalogOrBuiltIn', () => { + it('returns the network catalog when models.dev is reachable', async () => { + const network = { openai: { id: 'openai', models: {} } }; + const fetchImpl = vi.fn(async () => jsonResponse(network)); + + const result = await fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: BUILT_IN, + }); + + expect(result.fromBuiltIn).toBe(false); + expect(result.catalog).toEqual(network); + }); + + it('falls back to the built-in snapshot when the default URL fetch fails', async () => { + const fetchImpl = vi.fn(async () => jsonResponse('no', 503)); + + const result = await fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: BUILT_IN, + }); + + expect(result.fromBuiltIn).toBe(true); + expect(result.catalog).toEqual(JSON.parse(BUILT_IN)); + }); + + it('does not fall back for a custom catalog URL', async () => { + const fetchImpl = vi.fn(async () => jsonResponse('no', 500)); + + await expect( + fetchCatalogOrBuiltIn('https://example.test/private.json', { + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: BUILT_IN, + }), + ).rejects.toBeInstanceOf(CatalogFetchError); + }); + + it('does not fall back when the caller aborted the request', async () => { + const controller = new AbortController(); + controller.abort(); + const fetchImpl = vi.fn(async () => { + throw new DOMException('Aborted', 'AbortError'); + }); + + await expect( + fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + signal: controller.signal, + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: BUILT_IN, + }), + ).rejects.toThrow(); + }); + + it('rethrows when fetch fails and no built-in snapshot is available', async () => { + const fetchImpl = vi.fn(async () => jsonResponse('no', 500)); + + await expect( + fetchCatalogOrBuiltIn(DEFAULT_CATALOG_URL, { + fetchImpl: fetchImpl as unknown as typeof fetch, + builtInJson: '', + }), + ).rejects.toBeInstanceOf(CatalogFetchError); + }); +}); diff --git a/packages/node-sdk/test/catalog.test.ts b/packages/node-sdk/test/catalog.test.ts index d1bc5a6ea9a..30bb7d01e61 100644 --- a/packages/node-sdk/test/catalog.test.ts +++ b/packages/node-sdk/test/catalog.test.ts @@ -7,6 +7,7 @@ import { catalogProviderModels, CatalogFetchError, fetchCatalog, + loadBuiltInCatalog, type CatalogModel, } from '../src/catalog'; @@ -79,6 +80,19 @@ describe('fetchCatalog', () => { }); }); +describe('loadBuiltInCatalog', () => { + it('parses a valid catalog JSON string', () => { + const catalog = { anthropic: { id: 'anthropic', models: {} } }; + expect(loadBuiltInCatalog(JSON.stringify(catalog))).toEqual(catalog); + }); + + it('returns undefined for missing or invalid input', () => { + expect(loadBuiltInCatalog(undefined)).toBeUndefined(); + expect(loadBuiltInCatalog('')).toBeUndefined(); + expect(loadBuiltInCatalog('not-json')).toBeUndefined(); + }); +}); + describe('catalogModelToAlias', () => { it('flattens a catalog model capability into alias fields', () => { expect(catalogModelToAlias('anthropic', model)).toEqual({