import { isRecord, stringValue, numberValue, booleanValue } from "./utils.js"; import { resolveApiBase } from "./env.js"; export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; export type ThinkingLevelMap = Readonly>; export const DEFAULT_THINKING_LEVEL_MAP: ThinkingLevelMap = { off: "none", minimal: null, low: "low", medium: "medium", high: "high", xhigh: null, }; export const NO_THINKING_MAP: ThinkingLevelMap = { off: null, minimal: null, low: null, medium: null, high: null, xhigh: null, }; export const GLM_FLASH_THINKING_LEVEL_MAP: ThinkingLevelMap = { off: null, minimal: null, low: "low", medium: null, high: "high", xhigh: "max", }; export interface ClineOpenAICompat { readonly supportsDeveloperRole: boolean; readonly thinkingFormat?: "zai"; readonly zaiToolStream?: boolean; } export const CLINE_OPENAI_COMPAT = { supportsDeveloperRole: false, thinkingFormat: "zai", zaiToolStream: true, } as const satisfies ClineOpenAICompat; export interface ModelConfig { id: string; name: string; reasoning: boolean; input: readonly ["text"]; cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; contextWindow: number; maxTokens: number; thinkingLevelMap: ThinkingLevelMap; compat: ClineOpenAICompat; } interface ModelConfigBase extends Omit { compat?: Partial; } const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; const MODELS_BASE: readonly ModelConfigBase[] = [ { id: "z-ai/glm-5.3-flash", name: "GLM 5.3 Flash (Cline free)", reasoning: true, input: ["text"], cost: ZERO_COST, contextWindow: 1_048_576, maxTokens: 131_072, thinkingLevelMap: GLM_FLASH_THINKING_LEVEL_MAP, }, { id: "z-ai/glm-5.2:free", name: "GLM 5.2 Free (Cline)", reasoning: true, input: ["text"], cost: ZERO_COST, contextWindow: 1_048_576, maxTokens: 131_072, thinkingLevelMap: DEFAULT_THINKING_LEVEL_MAP, }, { id: "z-ai/glm-4.7-flash", name: "GLM 4.7 Flash (Cline)", reasoning: true, input: ["text"], cost: ZERO_COST, contextWindow: 1_048_576, maxTokens: 131_072, thinkingLevelMap: GLM_FLASH_THINKING_LEVEL_MAP, }, ]; export const MODELS: readonly ModelConfig[] = MODELS_BASE.map((model) => ({ ...model, compat: { ...CLINE_OPENAI_COMPAT, ...model.compat, }, })); export function modelIds(): string[] { return MODELS.map((m) => m.id); } export const MODELS_ENDPOINT = "/api/v1/models"; export const MODELS_FETCH_TIMEOUT_MS = 5_000; interface RawModelEntry { id?: unknown; name?: unknown; context_length?: unknown; max_output_tokens?: unknown; pricing?: unknown; reasoning?: unknown; } function toMicroPerToken(val: unknown, fallbackVal: number): number { const n = numberValue(val); return n != null ? n * 1_000_000 : fallbackVal; } function parseRemoteModel(raw: RawModelEntry, fallback?: ModelConfig): ModelConfig | undefined { const id = stringValue(raw.id); if (!id) return undefined; const name = stringValue(raw.name) ?? fallback?.name ?? id; const contextWindow = numberValue(raw.context_length) ?? fallback?.contextWindow ?? 128_000; const maxTokens = numberValue(raw.max_output_tokens) ?? fallback?.maxTokens ?? 8_192; const reasoning = booleanValue(raw.reasoning) ?? fallback?.reasoning ?? true; const pricing = isRecord(raw.pricing) ? raw.pricing : undefined; const cost = { input: toMicroPerToken(pricing?.prompt, fallback?.cost.input ?? 0), output: toMicroPerToken(pricing?.completion, fallback?.cost.output ?? 0), cacheRead: toMicroPerToken(pricing?.cached_input, fallback?.cost.cacheRead ?? 0), cacheWrite: fallback?.cost.cacheWrite ?? 0, }; return { id, name, reasoning, input: ["text"], cost, contextWindow, maxTokens, thinkingLevelMap: reasoning ? (fallback?.thinkingLevelMap ?? (id.includes("flash") ? GLM_FLASH_THINKING_LEVEL_MAP : DEFAULT_THINKING_LEVEL_MAP)) : NO_THINKING_MAP, compat: { ...CLINE_OPENAI_COMPAT, ...fallback?.compat, }, }; } /** Rejects unusable catalog entries: batch variants and `~` aliases. */ export function isUsableModelId(id: string): boolean { if (id.startsWith("~")) return false; if (id.endsWith(":batch")) return false; return true; } /** Free-tier gateway models (the default catalog). */ export function isClineFreeGatewayModelId(id: string): boolean { if (!id.startsWith("z-ai/")) return false; if (!isUsableModelId(id)) return false; return true; } export interface RemoteModelsOptions { apiBase?: string; apiKey?: string; fetch?: typeof globalThis.fetch; timeoutMs?: number; /** Include Cline's full catalog (all vendors), not just free-tier z-ai models. */ includeAll?: boolean; } export async function fetchRemoteModels( options: RemoteModelsOptions = {}, ): Promise { const apiBase = options.apiBase ?? resolveApiBase(); const apiKey = options.apiKey; const fetchFn = options.fetch ?? globalThis.fetch; const timeoutMs = options.timeoutMs ?? MODELS_FETCH_TIMEOUT_MS; if (!apiKey || !fetchFn) return undefined; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetchFn(`${apiBase}${MODELS_ENDPOINT}`, { headers: { Authorization: `Bearer ${apiKey}` }, signal: controller.signal, }); if (!response.ok) return undefined; const json: unknown = await response.json(); const rawList: RawModelEntry[] = Array.isArray(json) ? json : isRecord(json) && Array.isArray(json.data) ? (json.data as RawModelEntry[]) : []; if (rawList.length === 0) return undefined; const staticById = new Map(MODELS.map((m) => [m.id, m])); const parsed = rawList.reduce((acc, raw) => { const id = stringValue(raw?.id); if (!id) return acc; const keep = options.includeAll ? isUsableModelId(id) : isClineFreeGatewayModelId(id); if (!keep) return acc; const model = parseRemoteModel(raw, staticById.get(id)); if (model) acc.push(model); return acc; }, []); return parsed.length > 0 ? parsed : undefined; } catch { return undefined; } finally { clearTimeout(timer); } } export async function resolveModels( apiKey?: string, options: RemoteModelsOptions = {}, ): Promise { if (apiKey) { const remote = await fetchRemoteModels({ ...options, apiKey }); if (remote) return remote; } return MODELS; }