File size: 5,548 Bytes
4e23b01 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 | import type { KimiConfig, ModelAlias } from '#/config/index';
import {
catalogBaseUrl,
catalogProviderModels,
inferWireType,
resolveCatalogImport,
type Catalog,
type CatalogImportInvalidReason,
type CatalogImportResolution,
type CatalogModel,
type CatalogProviderEntry,
type ModelCapability,
type ProviderType,
} from '@moonshot-ai/kosong';
export { catalogBaseUrl, catalogProviderModels, inferWireType, resolveCatalogImport };
export type { CatalogImportInvalidReason, CatalogImportResolution };
export type { Catalog, CatalogModel, CatalogProviderEntry };
export const DEFAULT_CATALOG_URL = 'https://models.dev/api.json';
export class CatalogFetchError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
export interface FetchCatalogOptions {
readonly signal?: AbortSignal;
readonly fetchImpl?: typeof fetch;
readonly userAgent?: string;
}
/**
* Fetches a models.dev-style catalog. Public endpoint, no credentials needed.
* `userAgent` identifies the host product (e.g. `kimi-code-cli/1.2.3`); when
* omitted the request falls back to the runtime default (`User-Agent: node`).
*/
export async function fetchCatalog(
url: string,
options: FetchCatalogOptions = {},
): Promise<Catalog> {
const { signal, fetchImpl = fetch, userAgent } = options;
const headers: Record<string, string> = { Accept: 'application/json' };
if (userAgent !== undefined) headers['User-Agent'] = userAgent;
const res = await fetchImpl(url, { headers, signal });
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)) {
throw new Error(`Unexpected catalog response from ${url}.`);
}
return payload as Catalog;
}
function capabilityToStrings(capability: ModelCapability): string[] | undefined {
const caps: string[] = [];
if (capability.image_in) caps.push('image_in');
if (capability.video_in) caps.push('video_in');
if (capability.audio_in) caps.push('audio_in');
if (capability.thinking) caps.push('thinking');
if (capability.tool_use) caps.push('tool_use');
if (capability.dynamically_loaded_tools === true) caps.push('dynamically_loaded_tools');
return caps.length > 0 ? caps : undefined;
}
/** Builds a kimi-code model alias from a normalized catalog model. */
export function catalogModelToAlias(providerId: string, model: CatalogModel): ModelAlias {
const caps = capabilityToStrings(model.capability);
return {
provider: providerId,
model: model.id,
maxContextSize: model.capability.max_context_tokens,
maxInputSize: model.capability.max_input_tokens,
maxOutputSize: model.maxOutputSize,
// A model that always reasons advertises `always_thinking` instead of
// `thinking`, so the UI locks thinking on and offers no off option.
capabilities:
model.alwaysThinking === true
? caps?.map((cap) => (cap === 'thinking' ? 'always_thinking' : cap))
: caps,
displayName: model.name,
reasoningKey: model.reasoningKey,
supportEfforts: model.supportEfforts === undefined ? undefined : [...model.supportEfforts],
offEffort: model.offEffort,
protocol: model.protocol,
baseUrl: model.baseUrl,
};
}
export interface ApplyCatalogProviderOptions {
readonly providerId: string;
readonly wire: ProviderType;
readonly baseUrl?: string;
readonly apiKey: string;
readonly models: readonly CatalogModel[];
readonly selectedModelId: string;
readonly thinking: boolean;
}
/**
* Parses an optional pruned models.dev catalog string — typically the
* `__KIMI_CODE_BUILT_IN_CATALOG__` constant injected by tsdown at build
* time. Returns `undefined` when the argument is missing or invalid.
*/
export function loadBuiltInCatalog(text?: string): Catalog | undefined {
if (typeof text !== 'string' || text.length === 0) return undefined;
try {
return JSON.parse(text) as Catalog;
} catch {
return undefined;
}
}
/**
* Writes a catalog-selected provider and its model aliases into `config` and
* marks it the default. Model metadata (context, output limit, capabilities)
* comes from the catalog, so the user does not hand-write it. Returns the
* default model key.
*
* NOTE: the same-provider cleanup below mutates the passed-in `config` only.
* It clears stale aliases on disk solely when the caller overwrites the whole
* config. Callers persisting via `setConfig` — a deep-merge patch that cannot
* delete keys — must call `removeProvider` first, or removed aliases reappear
* after the merge.
*/
export function applyCatalogProvider(
config: KimiConfig,
options: ApplyCatalogProviderOptions,
): { defaultModel: string } {
config.providers[options.providerId] = {
type: options.wire,
baseUrl: options.baseUrl,
apiKey: options.apiKey,
};
const models = config.models ?? {};
for (const [key, alias] of Object.entries(models)) {
if (alias.provider === options.providerId) delete models[key];
}
for (const model of options.models) {
models[`${options.providerId}/${model.id}`] = catalogModelToAlias(options.providerId, model);
}
config.models = models;
const defaultModel = `${options.providerId}/${options.selectedModelId}`;
config.defaultModel = defaultModel;
config.thinking = { ...config.thinking, enabled: options.thinking };
return { defaultModel };
}
|