Skip to content
Merged
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/catalog-builtin-fallback.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 8 additions & 2 deletions apps/kimi-code/src/cli/sub/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
CatalogFetchError,
createKimiHarness,
DEFAULT_CATALOG_URL,
fetchCatalog,
resolveCatalogImport,
type Catalog,
type CatalogProviderEntry,
Expand All @@ -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;
Expand Down Expand Up @@ -434,7 +434,13 @@ export async function handleCatalogAdd(

async function loadCatalogOrExit(deps: ProviderDeps, url: string): Promise<Catalog> {
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`);
Expand Down
12 changes: 9 additions & 3 deletions apps/kimi-code/src/tui/commands/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -162,11 +162,17 @@ async function handleCatalogProviderAdd(host: SlashCommandHost): Promise<void> {
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.' });
Expand Down
57 changes: 57 additions & 0 deletions apps/kimi-code/src/utils/catalog-fetch.ts
Original file line number Diff line number Diff line change
@@ -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<FetchCatalogOrBuiltInResult> {
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve per-model metadata in the built-in fallback

When the default models.dev fetch fails, this returns the embedded snapshot as if it were the live catalog, but that snapshot is generated by apps/kimi-code/scripts/update-catalog.mjs with KEEP_MODEL omitting fields that catalogProviderModels consumes, including per-model provider overrides, reasoning_options, and status. In blocked/offline networks this can misconfigure imported gateway models that need per-model protocol/baseUrl overrides, or surface models the live catalog path would filter. Please keep the fields used by the normalizer in the snapshot, or normalize the catalog before embedding it.

Useful? React with 👍 / 👎.

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')
);
}
84 changes: 84 additions & 0 deletions apps/kimi-code/test/utils/catalog-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 14 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 Down Expand Up @@ -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({
Expand Down
Loading