From 15907360b409ef822d9f9d8c7bd8f902563f0f54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A1bio=20Vital?= Date: Tue, 8 Sep 2026 07:32:02 -0400 Subject: [PATCH 1/8] Pre-fill custom-provider model specs from an open-weight catalog in setup (#1) * Add built-in model spec catalog to custom API-key provider setup feynman setup / feynman model login for custom API-key providers now pre-fills editable defaults (context length, max completion tokens, supported reasoning efforts) from a typed, reviewable static catalog covering OpenAI GPT, Anthropic Claude, Google Gemini, DeepSeek, Kimi, GLM, Qwen, MiMo, Hunyuan 3/4, MiniMax, and Nemotron. Unknown model ids prompt with safe fallbacks; every catalog value stays user-overridable, and pre-existing saved configs that omit the fields keep Pi's fallbacks. Runtime semantics verified against Pi's getSupportedThinkingLevels: null mappings are honored as unsupported, so unselected efforts are pinned to null. Token input accepts 128000 as well as 128k/1m forms. * Restrict spec catalog to open-weight families Per latest captain word: closed-weight models (GPT, Claude, Gemini, Grok) no longer appear in the built-in catalog at all - their providers ship their own runtime model registries, so their ids simply miss the catalog and prompt at setup with safe fallbacks. The catalog now carries the open-weight families only: DeepSeek, Kimi, GLM, Qwen, MiMo, Hunyuan, MiniMax, and Nemotron. Generic thinking-level-map and effort-derivation semantics are unchanged. * Forward reasoning effort at first-party endpoints via compat overrides * Document new setup per-model limit prompts in setup.md * Correct GLM aliases and add qwen3.8-flash-next with doc-verified specs Remove the aliases glm-5.3-highspeed and glm-5.2-highspeed: the captain verified those model ids do not exist, so setup would have mis-recognized them as the base rows. glm-5.3-flash and all other real aliases stay. Give qwen3.8-flash its own row with official Model Studio values (1,000,000 context, 131,072 max output) instead of nesting it under qwen3.8-max, and add qwen3.8-flash-next as its own entry per the official Qwen3.8-Flash-Next card: 262,144 native context (1,000,000 only via self-hosted YaRN scaling), 131,072 recommended final-response output, reasoning efforts xhigh (default) / medium / low. * Scope per-model limit prompts to custom API-key provider flow only * Make modelDefinitions optional to restore build * Fill catalog limits from cited hosted catalogs Fill contextWindow and maxTokens for every KNOWN_MODEL_SPECS row the vendor docs leave silent, citing the public hosted catalogs per the captain's verified readout (DeepInfra /v1/openai/models and Novita model metadata, both read 2026-09-08): DeepSeek 1048576/1048576, Kimi K3 1048576/1048576, K2.6/K2.7-Code 262144/262144, K2-0905 and K2-0711 100352 output (Novita), GLM-5.2 1048576/131072, GLM-5.1 202752/202752 (captain-cited DeepInfra values), Qwen3.8-Max 1000000/131072 (Novita, consistent with the official flash family), MiMo 131072 output, Hunyuan Hy3 262144 output, MiniMax M3/M2.7 131072 output, Nemotron Super/Ultra 262144 output. Hosted deployments disagree in places (Novita vs DeepInfra on DeepSeek/GLM-5.1/Qwen-Max output caps); the row comments name which source backs each value and where a conflicting deployment differs. Rows with no traceable source anywhere (Hunyuan Hy4, Nemotron Nano Omni) keep the prompt fallback and say so. * Refine setup.md sourcing wording to include hosted catalogs * Update spec-catalog.ts * Align spec-catalog tests with 33b19b3 values --- src/model/commands.ts | 98 +++- src/model/models-json.ts | 19 +- src/model/spec-catalog.ts | 520 ++++++++++++++++++ tests/spec-catalog.test.ts | 281 ++++++++++ .../src/content/docs/getting-started/setup.md | 2 + 5 files changed, 915 insertions(+), 5 deletions(-) create mode 100644 src/model/spec-catalog.ts create mode 100644 tests/spec-catalog.test.ts diff --git a/src/model/commands.ts b/src/model/commands.ts index fcce1342..b9676499 100644 --- a/src/model/commands.ts +++ b/src/model/commands.ts @@ -4,7 +4,7 @@ import { exec as execCallback } from "node:child_process"; import { promisify } from "node:util"; import { readJson } from "../pi/settings.js"; -import { promptChoice, promptSelect, promptText, type PromptSelectOption } from "../setup/prompts.js"; +import { isInteractiveTerminal, promptChoice, promptConfirm, promptMultiSelect, promptSelect, promptText, type PromptSelectOption } from "../setup/prompts.js"; import { openUrl } from "../system/open-url.js"; import { printInfo, printSection, printSuccess, printWarning } from "../ui/terminal.js"; import { @@ -17,7 +17,15 @@ import { } from "./catalog.js"; import { MODEL_API_KEY_PROVIDERS, type ApiKeyProviderInfo } from "./api-key-providers.js"; import { createModelRegistry, createModelRuntime, getModelsJsonPath } from "./registry.js"; -import { upsertProviderBaseUrl, upsertProviderConfig } from "./models-json.js"; +import { upsertProviderBaseUrl, upsertProviderConfig, type ProviderModelDefinition } from "./models-json.js"; +import { + buildThinkingLevelMap, + lookupKnownModelSpec, + parseTokenCountInput, + UNKNOWN_MODEL_FALLBACK, + type ModelThinkingLevel, + specReasoningLevels, +} from "./spec-catalog.js"; const exec = promisify(execCallback); @@ -139,6 +147,7 @@ async function selectApiKeyProvider(): Promise { type CustomProviderSetup = { providerId: string; modelIds: string[]; + modelDefinitions?: ProviderModelDefinition[]; baseUrl: string; api: "openai-completions" | "openai-responses" | "anthropic-messages" | "google-generative-ai"; apiKeyConfig: string; @@ -262,6 +271,86 @@ async function bestEffortFetchOpenAiModelIds( } } +async function parseTokenCount( + input: string, + fallback: number, + label: string, +): Promise { + const parsed = parseTokenCountInput(input); + if (parsed !== undefined) { + return parsed; + } + printWarning(`${label} must be a positive integer (e.g. 128000 or 128k); using ${fallback}.`); + return fallback; +} + +const REASONING_LEVEL_OPTIONS: ModelThinkingLevel[] = ["minimal", "low", "medium", "high", "xhigh", "max"]; +const REASONING_LEVEL_LABELS: Record = { + off: "off (no reasoning)", + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: "max", +}; + +/** + * Prompt for the per-model limits Pi otherwise hardcodes (128k context, + * 16384 max output tokens, thinking disabled). A recognized model id + * pre-fills official specs as editable defaults; an unknown id prompts with + * safe fallbacks. Every value stays user-overridable. + */ +export async function promptModelSpecDefinitions(modelIds: string[]): Promise { + if (!isInteractiveTerminal()) { + return modelIds.map((id) => ({ id })); + } + + const definitions: ProviderModelDefinition[] = []; + for (const modelId of modelIds) { + const spec = lookupKnownModelSpec(modelId); + if (spec) { + printInfo(`${modelId}: recognized as ${spec.label} — pre-filled from official specs, edit any value.`); + for (const limitation of spec.limitations ?? []) { + printWarning(`${modelId}: ${limitation}`); + } + } else { + printInfo(`${modelId}: not in the built-in catalog — showing safe defaults, edit any value.`); + } + + const contextWindow = await parseTokenCount( + await promptText("Context length (tokens)", String(spec?.contextWindow ?? UNKNOWN_MODEL_FALLBACK.contextWindow)), + spec?.contextWindow ?? UNKNOWN_MODEL_FALLBACK.contextWindow, + "Context length", + ); + const maxTokens = await parseTokenCount( + await promptText("Max completion tokens", String(spec?.maxTokens ?? UNKNOWN_MODEL_FALLBACK.maxTokens)), + spec?.maxTokens ?? UNKNOWN_MODEL_FALLBACK.maxTokens, + "Max completion tokens", + ); + const reasoning = await promptConfirm("Does the model support reasoning (thinking)?", spec?.reasoning ?? UNKNOWN_MODEL_FALLBACK.reasoning); + + const definition: ProviderModelDefinition = { id: modelId, contextWindow, maxTokens, reasoning }; + if (reasoning) { + const selectedLevels = await promptMultiSelect( + `Reasoning efforts ${modelId} accepts:`, + REASONING_LEVEL_OPTIONS.map((level) => ({ value: level, label: REASONING_LEVEL_LABELS[level] })), + specReasoningLevels(spec), + ); + // Always write an explicit map from the selection: unselected levels + // are pinned to null so the runtime cannot send an effort the user + // excluded, and selected levels reuse documented provider-specific + // values (e.g. GLM, Hunyuan chat-template kwargs) where present. + definition.thinkingLevelMap = buildThinkingLevelMap(selectedLevels, spec?.thinkingLevelMap); + if (spec?.compat) { + definition.compat = structuredClone(spec.compat); + } + } + definitions.push(definition); + } + return definitions; +} + async function promptCustomProviderSetup(): Promise { printSection("Custom Provider"); const providerIdInput = await promptText("Provider id (e.g. my-proxy)", "custom"); @@ -371,7 +460,8 @@ async function promptCustomProviderSetup(): Promise { @@ -723,7 +813,7 @@ async function configureApiKeyProvider(authPath: string, providerId?: string): P apiKey: setup.apiKeyConfig, api: setup.api, authHeader: setup.authHeader, - models: setup.modelIds.map((id) => ({ id })), + models: setup.modelDefinitions ?? setup.modelIds.map((id) => ({ id })), }); if (!result.ok) { printWarning(result.error); diff --git a/src/model/models-json.ts b/src/model/models-json.ts index 72c7e239..10bf5fd3 100644 --- a/src/model/models-json.ts +++ b/src/model/models-json.ts @@ -48,13 +48,30 @@ export function upsertProviderBaseUrl( return upsertProviderConfig(modelsJsonPath, providerId, { baseUrl }); } +/** + * Per-model definition accepted by Pi's models.json loader. Besides `id`, the + * setup flow fills limits it prompted for (or copied from the built-in spec + * catalog); pre-existing configs that omit them keep Pi's safe fallbacks. + */ +export type ProviderModelDefinition = { + id: string; + name?: string; + api?: string; + baseUrl?: string; + contextWindow?: number; + maxTokens?: number; + reasoning?: boolean; + thinkingLevelMap?: Record; + compat?: Record; +}; + export type ProviderConfigPatch = { baseUrl?: string; apiKey?: string; api?: string; authHeader?: boolean; headers?: Record; - models?: Array<{ id: string }>; + models?: ProviderModelDefinition[]; }; export function upsertProviderConfig( diff --git a/src/model/spec-catalog.ts b/src/model/spec-catalog.ts new file mode 100644 index 00000000..21e374cf --- /dev/null +++ b/src/model/spec-catalog.ts @@ -0,0 +1,520 @@ +/** + * Built-in catalog of well-known model specs for the custom API-key provider + * setup flow (`feynman setup` / `feynman model login`). + * + * Pi hardcodes safe fallbacks for custom models.json providers that omit + * limits: 128k context window, 16384 max output tokens, thinking disabled. + * This catalog lets setup pre-fill real values (context length, max + * completion tokens, supported reasoning efforts) as editable defaults when + * it recognizes the model id, instead of silently degrading to those + * fallbacks. Unknown values stay undefined here so setup prompts the user + * instead of guessing a number into the catalog. + * + * This is static, reviewable data a maintainer extends by PR; setup makes no + * network calls against it. Values carry their source so a reviewer can audit + * each row: vendor docs for first-party caps, and the cited public hosted + * catalogs (DeepInfra, Novita) where vendor docs stay silent. Only traceable + * values are stored; a value without any traceable source stays undefined so + * setup prompts the user for it instead of pre-filling a guess. Hosted-catalog + * caps are read dates-stamped in the row comments because hosted deployments + * can differ per provider. + * + * Scope: open-weight model families only (DeepSeek, Kimi, GLM, Qwen, MiMo, + * Hunyuan, MiniMax, Nemotron). Closed-weight models (GPT, Claude, Gemini, + * Grok) never appear here — their providers ship their own runtime model + * registries, so a closed-weight id is simply unknown to this catalog and + * prompts at setup with safe fallbacks. + */ + +export type ModelThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; + +/** + * Per-model definition accepted by Pi's models.json loader, carrying the + * setup-derived limits. Written verbatim into `models.json` next to `id`. + */ +export type ModelSpecDefinition = { + id: string; + contextWindow?: number; + maxTokens?: number; + reasoning?: boolean; + thinkingLevelMap?: Partial>; + compat?: Record; +}; + +export type KnownModelSpec = { + /** Canonical model id this entry documents. */ + id: string; + /** Human-readable family label shown during setup. */ + label: string; + /** Official context window in tokens. Undefined = not documented, setup prompts. */ + contextWindow?: number; + /** Official max completion (output) tokens. Undefined = setup prompts. */ + maxTokens?: number; + /** Whether the model is a reasoning (thinking) model. */ + reasoning: boolean; + /** + * Pi thinking-level map when the official API needs one beyond provider + * defaults; copied verbatim into the models.json entry. + */ + thinkingLevelMap?: Partial>; + /** Pi compat overrides the official API requires (e.g. a special thinking + * format); copied verbatim into the models.json entry. */ + compat?: Record; + /** + * Known caveats for this model id's reasoning-effort selection at its + * first-party endpoint, shown to the user during setup so the catalog + * never over-promises that a selected effort reaches the API. + */ + limitations?: string[]; + /** Alternative model ids (case-insensitive) that resolve to this spec. */ + matches?: string[]; + /** Regex source also accepted for this spec (matched against the bare id). */ + pattern?: string; + /** Official documentation the values come from. */ + sources: string[]; +}; + +const DEEPSEEK_DOCS = "https://api-docs.deepseek.com"; +const MOONSHOT_DOCS = "https://platform.moonshot.ai/docs"; +const ZAI_DOCS = "https://docs.z.ai/guides/llm/glm-5.3"; +const QWEN_DOCS = "https://www.alibabacloud.com/help/en/model-studio/models"; +const XIAOMI_MIMO_DOCS = "https://mimo.xiaomi.com"; +const HUNYUAN_HY3_REPO = "https://github.com/Tencent-Hunyuan/Hy3"; +const HUNYUAN_HY4_REPO = "https://github.com/Tencent-Hunyuan/Hy4-preview"; +const MINIMAX_DOCS = "https://platform.minimax.io/docs/guides/models-intro"; +const NVIDIA_NIM_DOCS = "https://build.nvidia.com/nvidia/nemotron-3-super-120b-a12b"; +// Public hosted catalogs carrying per-variant context and output caps; used +// as cited sources for hosted deployments where vendor docs stay silent. +const DEEPINFRA_CATALOG = "https://api.deepinfra.com/v1/openai/models"; +const NOVITA_MODELS = "https://api.novita.ai/v3/openai/models"; + +// --- Tencent Hunyuan --- +// Verified from the official Tencent-Hunyuan repos: reasoning effort is set +// through `chat_template_kwargs.reasoning_effort` ("no_think" for direct +// responses, "low"/"high" for thinking). Hy4 defaults to "high" (deep CoT). +function hunyuanThinkingLevelMap(levels: Array<"low" | "high">): { + thinkingLevelMap: Partial>; + compat: Record; +} { + return { + thinkingLevelMap: { + off: "no_think", + minimal: null, + low: levels.includes("low") ? "low" : null, + medium: null, + high: "high", + xhigh: null, + max: null, + }, + compat: { + thinkingFormat: "chat-template", + chatTemplateKwargs: { reasoning_effort: {} }, + }, + }; +} + +export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ + // --- DeepSeek --- + { + id: "deepseek-v4-pro", + label: "DeepSeek V4 Pro", + // Thinking mode and reasoning_effort are officially documented, but + // first-party caps are not. Hosted-catalog values (read 2026-09-08): + // DeepInfra deepseek-ai/DeepSeek-V4-Pro 1048576/1048576; Novita hosts + // the same model with a 393216 output cap - deployment configs differ. + contextWindow: 1048576, + maxTokens: 393216, + reasoning: true, + matches: ["deepseek-v4-pro-0813"], + sources: [DEEPSEEK_DOCS, DEEPINFRA_CATALOG], + }, + { + id: "deepseek-v4-flash", + label: "DeepSeek V4 Flash", + // Hosted-catalog values (read 2026-09-08): DeepInfra 1048576/1048576 + // for deepseek-ai/DeepSeek-V4-Flash; Novita hosts 393216 output. + contextWindow: 1048576, + maxTokens: 393216, + reasoning: true, + matches: ["deepseek-v4-flash-0731", "deepseek-v4-flash-vision-exp"], + sources: [DEEPSEEK_DOCS, DEEPINFRA_CATALOG], + }, + + // --- Kimi (Moonshot) --- + { + id: "kimi-k3", + label: "Kimi K3 (Moonshot)", + // Official docs: 1M-token context window; reasoning_effort supports + // "low" / "high" / "max" (default "max"). Hosted catalogs agree on the + // caps (read 2026-09-08): DeepInfra and Novita both host K3 at + // 1048576 context / 1048576 output. + contextWindow: 1048576, + maxTokens: 1048576, + reasoning: true, + thinkingLevelMap: { off: null, minimal: null, low: "low", medium: null, high: "high", xhigh: null, max: "max" }, + // Pi's openai-completions runtime drops reasoning_effort for Moonshot + // base URLs unless told the endpoint supports it; the official Kimi + // thinking guide confirms a top-level reasoning_effort for K3. + compat: { supportsReasoningEffort: true }, + matches: ["k3"], + sources: [MOONSHOT_DOCS], + }, + { + id: "kimi-k2.6", + label: "Kimi K2.6 (Moonshot)", + // Official docs: 256K context window, thinking and non-thinking modes; + // per-model effort parameter values differ from K3, so the map is + // prompted, not assumed. Hosted catalogs agree on the caps (read + // 2026-09-08): DeepInfra and Novita both host K2.6 at 262144/262144. + contextWindow: 262144, + maxTokens: 262144, + reasoning: true, + matches: ["kimi-k2.5", "kimi-k2-thinking", "kimi-k2-thinking-turbo"], + // The native Moonshot API drives thinking through a `thinking` + // parameter, not an OpenAI-style reasoning_effort; a selected effort + // may not bind at the first-party endpoint. + limitations: ["Reasoning-effort selection may not bind on the first-party Moonshot endpoint; its native API uses a separate thinking parameter."], + sources: [MOONSHOT_DOCS], + }, + { + id: "kimi-k2.7-code", + label: "Kimi K2.7 Code (Moonshot)", + // Official docs: 256K context window with thinking mode. Hosted + // catalogs agree (read 2026-09-08): DeepInfra and Novita both host + // K2.7-Code at 262144/262144. + contextWindow: 262144, + maxTokens: 262144, + reasoning: true, + matches: ["kimi-k2.7-code-highspeed", "kimi-for-coding", "kimi-for-coding-highspeed", "k3-256k"], + limitations: ["Reasoning-effort selection may not bind on the first-party Moonshot endpoint; kimi-k2.7-code needs no thinking parameter."], + sources: [MOONSHOT_DOCS], + }, + { + id: "kimi-k2-0905-preview", + label: "Kimi K2 0905 (Moonshot)", + // Novita hosts moonshotai/kimi-k2-0905 at 262144/100352 (read + // 2026-09-08); DeepInfra does not carry this variant. + contextWindow: 262144, + maxTokens: 100352, + reasoning: false, + matches: ["kimi-k2-turbo-preview"], + sources: [MOONSHOT_DOCS, NOVITA_MODELS], + }, + { + id: "kimi-k2-0711-preview", + label: "Kimi K2 0711 (Moonshot)", + // Novita hosts moonshotai/kimi-k2-instruct (the K2 131072-context + // variant) at 131072/100352 (read 2026-09-08); no deepinfra variant. + contextWindow: 131072, + maxTokens: 100352, + reasoning: false, + sources: [MOONSHOT_DOCS, NOVITA_MODELS], + }, + + // --- GLM (Zhipu / Z.AI) --- + { + id: "glm-5.3", + label: "GLM 5.3 (Z.AI)", + // Official docs: 1M context, 128K max output; reasoning is always on + // (disabling no longer supported) with effort levels low / high / max. + contextWindow: 1048576, + maxTokens: 131072, + reasoning: true, + thinkingLevelMap: { off: null, minimal: null, low: "low", medium: null, high: "high", xhigh: null, max: "max" }, + // Pi's openai-completions runtime marks Z.AI endpoints as not + // supporting reasoning effort and would send an undocumented thinking + // toggle; the official GLM-5.3 docs confirm a top-level + // reasoning_effort instead (disabling reasoning is no longer supported). + compat: { supportsReasoningEffort: true, thinkingFormat: "openai" }, + // "glm-5.3-highspeed" removed: the id does not exist (captain-verified). + matches: ["glm-5.3-flash"], + sources: [ZAI_DOCS], + }, + { + id: "glm-5.2", + label: "GLM 5.2 (Z.AI)", + // Hosted catalogs (read 2026-09-08): Novita zai-org/glm-5.2 at + // 1048576 context / 131072 output (consistent with the GLM-5.3 + // vendor output cap); DeepInfra hosts a 1048576/1048576 deployment. + contextWindow: 1048576, + maxTokens: 131072, + reasoning: true, + // "glm-5.2-highspeed" removed: the id does not exist (captain-verified). + // No readable official doc confirms an OpenAI-style reasoning_effort + // for 5.2 on the first-party endpoint, so a selected effort may not bind. + limitations: ["Reasoning-effort selection may not bind on the first-party Z.AI endpoint for this model."], + sources: [ZAI_DOCS, NOVITA_MODELS, DEEPINFRA_CATALOG], + }, + { + id: "glm-5.1", + label: "GLM 5.1 (Z.AI)", + // Hosted-catalog values (read 2026-09-08, captain-verified): DeepInfra + // zai-org/GLM-5.1 at 202752/202752; Novita hosts 204800/131072 for + // the same id - hosted deployments differ. + contextWindow: 202752, + maxTokens: 202752, + reasoning: true, + matches: ["glm-5", "glm-5-turbo", "glm-4.7"], + limitations: ["Reasoning-effort selection may not bind on the first-party Z.AI endpoint for this model."], + sources: [ZAI_DOCS, DEEPINFRA_CATALOG, NOVITA_MODELS], + }, + + // --- Qwen (Alibaba) --- + { + id: "qwen3.8-max", + label: "Qwen 3.8 Max (Alibaba)", + // Hosted-catalog values (read 2026-09-08): Novita qwen/qwen3.8-max at + // 1000000/131072 (consistent with the official qwen3.8-flash family + // values); DeepInfra hosts a 256000/256000 deployment - differs. + contextWindow: 1000000, + maxTokens: 131072, + reasoning: true, + sources: [QWEN_DOCS, NOVITA_MODELS, DEEPINFRA_CATALOG], + }, + { + id: "qwen3.8-flash", + label: "Qwen 3.8 Flash (Alibaba)", + // Official Model Studio page (alibabacloud.com/help/en/model-studio/ + // qwen3-8-flash.md): context window 1,000,000; max output 131,072 + // (thinking and direct modes); multimodal reasoning model. + contextWindow: 1000000, + maxTokens: 131072, + reasoning: true, + sources: [QWEN_DOCS], + }, + { + id: "qwen3.8-flash-next", + label: "Qwen 3.8 Flash Next (Alibaba)", + // Official Qwen3.8-Flash-Next card (huggingface.co/Qwen/Qwen3.8-Flash-Next): + // context 262,144 natively (extensible to 1,000,000 only via self-hosted + // YaRN RoPE scaling); recommended final-response output 131,072 + // (reasoning content 262,144); thinking controlled via enable_thinking / + // preserve_thinking, reasoning_effort levels xhigh (default), medium, low. + contextWindow: 262144, + maxTokens: 131072, + reasoning: true, + thinkingLevelMap: { minimal: null, low: "low", medium: "medium", high: null, xhigh: "xhigh", max: null }, + sources: ["https://huggingface.co/Qwen/Qwen3.8-Flash-Next"], + }, + { + id: "qwen3.7-max", + label: "Qwen 3.7 Max (Alibaba)", + reasoning: true, + matches: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash"], + sources: [QWEN_DOCS], + }, + + // --- MiMo (Xiaomi) --- + { + id: "mimo-v2.5", + label: "MiMo v2.5 (Xiaomi)", + // Official HF config.json: max_position_embeddings 1048576 for both + // variants. Hosted-catalog output cap (read 2026-09-08): Novita hosts + // both mimo-v2.5 and mimo-v2.5-pro at 1048576/131072 (consistent with + // the vendor context); DeepInfra deployments differ (262144/1048576). + contextWindow: 1048576, + maxTokens: 131072, + reasoning: true, + matches: ["mimo-v2.5-pro", "mimo-v2.5-pro-ultraspeed"], + sources: [XIAOMI_MIMO_DOCS, NOVITA_MODELS], + }, + + // --- Hunyuan 3 / Hunyuan 4 (Tencent) --- + { + id: "hy3", + label: "Hunyuan 3 (Tencent)", + contextWindow: 262144, + // Tencent documents the context length and reasoning efforts but no + // official max-completion cap; DeepInfra hosts tencent/Hy3 at + // 262144/262144 (read 2026-09-08). + maxTokens: 262144, + reasoning: true, + ...hunyuanThinkingLevelMap(["low", "high"]), + pattern: "^hy3(?:-preview)?(?:-fp8)?$", + matches: ["hunyuan-t3"], + sources: [HUNYUAN_HY3_REPO, DEEPINFRA_CATALOG], + }, + { + id: "hy4-preview", + label: "Hunyuan 4 (Tencent)", + contextWindow: 1048576, + maxTokens: 64000, + // No official max-completion cap is documented and neither hosted + // catalog carries a Hy4 variant (read 2026-09-08), so setup prompts + // for the output cap. + reasoning: true, + ...hunyuanThinkingLevelMap(["high"]), + matches: ["hy4", "hy4-preview-fp8", "hunyuan-t4"], + sources: [HUNYUAN_HY4_REPO], + }, + + // --- MiniMax --- + { + id: "MiniMax-M3", + label: "MiniMax M3", + // Official docs: 1,000,000-token context window. Hosted-catalog output + // cap (read 2026-09-08): Novita minimax/minimax-m3 at 1000000/131072 + // (context matches the vendor doc); DeepInfra hosts 524288/524288. + contextWindow: 1048576, + maxTokens: 131072, + reasoning: true, + matches: ["minimax-m3"], + sources: [MINIMAX_DOCS, NOVITA_MODELS, DEEPINFRA_CATALOG], + }, + { + id: "MiniMax-M2.7", + label: "MiniMax M2.7", + // Official docs: 204,800 context window; hosted-catalog output cap + // (read 2026-09-08): Novita hosts minimax-m2.7 and + // minimax-m2.7-highspeed at 204800/131072. + contextWindow: 204800, + maxTokens: 131072, + reasoning: true, + matches: ["minimax-m2.7", "MiniMax-M2.7-highspeed", "minimax-m2.7-highspeed"], + sources: [MINIMAX_DOCS, NOVITA_MODELS], + }, + + // --- Nemotron (NVIDIA) --- + { + id: "nvidia/nemotron-3-super-120b-a12b", + label: "NVIDIA Nemotron 3 Super", + // Official build.nvidia.com specification: contextLength 1048576 + // (serving defaults to 256k; the model spec is 1M). Hosted-catalog + // output cap (read 2026-09-08): DeepInfra hosts the model at + // 262144/262144. + contextWindow: 1048576, + maxTokens: 262144, + reasoning: true, + matches: ["nemotron-3-super-120b-a12b"], + // The hosted build.nvidia.com API converts a requested effort client-side + // into chat_template_kwargs; a top-level reasoning_effort is not documented. + limitations: ["The hosted NVIDIA endpoint applies effort client-side via chat_template_kwargs; a top-level reasoning_effort may not be forwarded as-is."], + sources: [NVIDIA_NIM_DOCS, DEEPINFRA_CATALOG], + }, + { + id: "nvidia/nemotron-3-ultra-550b-a55b", + label: "NVIDIA Nemotron 3 Ultra", + // Official build.nvidia.com specification: contextLength 1048576. + // Hosted-catalog output cap (read 2026-09-08): DeepInfra hosts the + // model at 262144/262144. + contextWindow: 1048576, + maxTokens: 262144, + reasoning: true, + matches: ["nemotron-3-ultra-550b-a55b"], + limitations: ["The hosted NVIDIA endpoint applies effort client-side via chat_template_kwargs; a top-level reasoning_effort may not be forwarded as-is."], + sources: [NVIDIA_NIM_DOCS, DEEPINFRA_CATALOG], + }, + { + id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", + label: "NVIDIA Nemotron 3 Nano Omni", + // Official build.nvidia.com specification: contextLength 262144, + // omni-modal input (text, image, audio, video). The hosted catalogs + // carry no matching omni variant (read 2026-09-08), so setup prompts + // for the output cap. + contextWindow: 262144, + reasoning: true, + matches: ["nemotron-3-nano-omni-30b-a3b-reasoning"], + limitations: ["The hosted NVIDIA endpoint applies effort client-side via chat_template_kwargs; a top-level reasoning_effort may not be forwarded as-is."], + sources: [NVIDIA_NIM_DOCS], + }, +]; + +/** Safe fallbacks for a model id the catalog does not recognize. */ +export const UNKNOWN_MODEL_FALLBACK = { + contextWindow: 128000, + maxTokens: 16384, + reasoning: false, + reasoningLevels: [] as ModelThinkingLevel[], +} as const; + +const DATE_SUFFIX_PATTERN = /-\d{8}$/; + +/** + * Resolve a bare model id against the built-in catalog. Matching is + * case-insensitive on the exact id and documented aliases, tolerates a + * trailing `-YYYYMMDD` date suffix, and falls back to an anchored family + * pattern when one is declared. + */ +export function lookupKnownModelSpec(modelId: string): KnownModelSpec | undefined { + const id = modelId.trim().toLowerCase(); + if (!id) return undefined; + + const exact = (candidate: string) => KNOWN_SPEC_INDEX.get(candidate); + return exact(id) + ?? (DATE_SUFFIX_PATTERN.test(id) ? exact(id.replace(DATE_SUFFIX_PATTERN, "")) : undefined) + ?? KNOWN_SPEC_PATTERNS.reduce((hit, pattern) => hit ?? (pattern.pattern.test(id) ? pattern.spec : undefined), undefined); +} + +const KNOWN_SPEC_INDEX = new Map(); +for (const spec of KNOWN_MODEL_SPECS) { + KNOWN_SPEC_INDEX.set(spec.id.toLowerCase(), spec); + for (const alias of spec.matches ?? []) { + KNOWN_SPEC_INDEX.set(alias.toLowerCase(), spec); + } +} + +const KNOWN_SPEC_PATTERNS = KNOWN_MODEL_SPECS.filter((spec) => spec.pattern !== undefined).map((spec) => ({ + spec, + pattern: new RegExp(spec.pattern!, "i"), +})); + +/** + * Reasoning efforts a catalog spec documents, derived from its thinking-level + * map (identity-mapped levels) when present, otherwise the provider defaults + * for a reasoning model. + */ +export function specReasoningLevels(spec: KnownModelSpec | undefined): ModelThinkingLevel[] { + if (!spec) return []; + if (spec.thinkingLevelMap) { + const levels: ModelThinkingLevel[] = []; + for (const level of ["minimal", "low", "medium", "high", "xhigh", "max"] as const) { + const mapped = spec.thinkingLevelMap[level]; + if (typeof mapped === "string") levels.push(level); + } + return levels; + } + return spec.reasoning ? ["low", "medium", "high"] : []; +} + +/** + * Build the thinking-level map a custom models.json entry should carry for + * the selected reasoning levels. Unselected levels are pinned to null so the + * runtime cannot send an effort the model does not accept; selected levels + * reuse any documented provider-specific value and fall back to the identity + * string otherwise. The "off" level stays available and keeps a documented + * disable value (e.g. Hunyuan's "no_think") when the spec carries one. + */ +export function buildThinkingLevelMap( + selectedLevels: ModelThinkingLevel[], + baseMap?: Partial>, +): Partial> { + const map: Partial> = {}; + for (const level of ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const) { + if (level === "off") { + const disabled = baseMap?.off; + if (typeof disabled === "string") map.off = disabled; + continue; + } + if (selectedLevels.includes(level)) { + const mapped = baseMap?.[level]; + map[level] = typeof mapped === "string" ? mapped : level; + } else { + map[level] = null; + } + } + return map; +} + +/** + * Parse a user-entered token count. Accepts plain integers plus `k`/`m` + * suffixes (e.g. "128k", "1m") so a suffixed value is never truncated to its + * leading digits. Returns undefined when the input is not a positive count. + */ +export function parseTokenCountInput(input: string): number | undefined { + const match = /^(\d+)([kKmM])?$/.exec(input.trim()); + if (!match) return undefined; + const value = Number.parseInt(match[1], 10); + if (!Number.isFinite(value) || value <= 0) return undefined; + const multiplier = match[2]?.toLowerCase() === "k" ? 1000 : match[2]?.toLowerCase() === "m" ? 1000000 : 1; + return value * multiplier; +} diff --git a/tests/spec-catalog.test.ts b/tests/spec-catalog.test.ts new file mode 100644 index 00000000..fd7f0370 --- /dev/null +++ b/tests/spec-catalog.test.ts @@ -0,0 +1,281 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { KNOWN_MODEL_SPECS, lookupKnownModelSpec, parseTokenCountInput, UNKNOWN_MODEL_FALLBACK, specReasoningLevels, buildThinkingLevelMap } from "../src/model/spec-catalog.js"; +import { promptModelSpecDefinitions } from "../src/model/commands.js"; +import { upsertProviderConfig } from "../src/model/models-json.js"; + +test("lookupKnownModelSpec resolves every open-weight family the catalog documents", () => { + const expected = [ + // open-weight families the catalog carries + "deepseek-v4-pro", + "kimi-k2.6", + "glm-5.3", + "qwen3.8-max", + "qwen3.8-flash", + "qwen3.8-flash-next", + "mimo-v2.5", + "hy3", + "hy4-preview", + "MiniMax-M3", + "nvidia/nemotron-3-super-120b-a12b", + ]; + for (const id of expected) { + assert.ok(lookupKnownModelSpec(id), `expected catalog hit for ${id}`); + } +}); + +test("closed-weight model ids are absent from the catalog and prompt at setup", () => { + // Closed-weight families never appear in the catalog: their providers + // ship their own runtime registries, so their ids must miss here and be + // handled as unknown (safe fallback prompts) rather than pre-filled. + for (const id of ["gpt-5.6", "gpt-6-astra", "claude-opus-5", "claude-fable-5-1", "gemini-3.8-flash", "grok-5"]) { + assert.equal(lookupKnownModelSpec(id), undefined, `${id} must not be cataloged`); + } + assert.ok(!KNOWN_MODEL_SPECS.some((spec) => /^(gpt-|claude-|gemini-|grok-)/i.test(spec.id)), "no closed-weight rows in the catalog"); +}); + +test("lookupKnownModelSpec matches aliases and dated model ids case-insensitively", () => { + assert.equal(lookupKnownModelSpec("KIMI-K3")?.id, "kimi-k3"); + assert.equal(lookupKnownModelSpec("k3")?.id, "kimi-k3"); + assert.equal(lookupKnownModelSpec("MiniMax-M2.7-highspeed")?.id, "MiniMax-M2.7"); + assert.equal(lookupKnownModelSpec("hy3-preview")?.label, "Hunyuan 3 (Tencent)"); + assert.equal(lookupKnownModelSpec("hy4-preview-fp8")?.label, "Hunyuan 4 (Tencent)"); + assert.equal(lookupKnownModelSpec("hunyuan-t3")?.id, "hy3"); + assert.equal(lookupKnownModelSpec("hunyuan-t4")?.id, "hy4-preview"); +}); + +test("unknown model ids miss the catalog and fall back to safe defaults", () => { + assert.equal(lookupKnownModelSpec("totally-made-up-model"), undefined); + assert.equal(UNKNOWN_MODEL_FALLBACK.contextWindow, 128000); + assert.equal(UNKNOWN_MODEL_FALLBACK.maxTokens, 16384); + assert.equal(UNKNOWN_MODEL_FALLBACK.reasoning, false); + assert.deepEqual(specReasoningLevels(undefined), []); +}); + +test("every catalog row documents its official source and positive limits", () => { + for (const spec of KNOWN_MODEL_SPECS) { + assert.ok(spec.sources.length > 0, `${spec.id} documents its source`); + assert.ok((spec.contextWindow ?? Infinity) > 0, `${spec.id} context window is positive when documented`); + assert.ok((spec.maxTokens ?? Infinity) > 0, `${spec.id} max tokens is positive when documented`); + } +}); + +test("Hunyuan rows carry official context and efforts; Hy3 from the hosted catalog, Hy4 cap 64000", () => { + const hy3 = lookupKnownModelSpec("hy3")!; + assert.equal(hy3.contextWindow, 262144); + // DeepInfra hosts tencent/Hy3 at 262144/262144 (cited source). + assert.equal(hy3.maxTokens, 262144); + assert.equal(hy3.reasoning, true); + assert.equal(hy3.thinkingLevelMap?.off, "no_think"); + assert.equal(hy3.thinkingLevelMap?.high, "high"); + assert.equal(hy3.compat?.thinkingFormat, "chat-template"); + const hy4 = lookupKnownModelSpec("hy4-preview")!; + assert.equal(hy4.contextWindow, 1048576); + // Output cap per the captain's spec-catalog.ts update. + assert.equal(hy4.maxTokens, 64000); +}); + +test("flagship rows keep officially documented effort levels and context caps", () => { + // GLM-5.3 official docs: reasoning always on with effort low / high / max. + const glm = lookupKnownModelSpec("glm-5.3")!; + assert.equal(glm.contextWindow, 1048576); + assert.equal(glm.maxTokens, 131072); + assert.equal(glm.thinkingLevelMap?.max, "max"); + assert.deepEqual(specReasoningLevels(glm), ["low", "high", "max"]); + + // MiniMax-M3 context window per the captain's spec-catalog.ts update. + assert.equal(lookupKnownModelSpec("MiniMax-M3")?.contextWindow, 1048576); + + // NVIDIA official specifications: 1M / 1M / 262K context windows. + assert.equal(lookupKnownModelSpec("nvidia/nemotron-3-super-120b-a12b")?.contextWindow, 1048576); + assert.equal(lookupKnownModelSpec("nvidia/nemotron-3-ultra-550b-a55b")?.contextWindow, 1048576); + assert.equal(lookupKnownModelSpec("nvidia/nemotron-3-nano-omni-30b-a3b-reasoning")?.contextWindow, 262144); +}); + +test("non-existent highspeed aliases are absent and qwen3.8-flash-next carries its own doc-verified specs", () => { + // The captain verified these highspeed ids do not exist. + assert.equal(lookupKnownModelSpec("glm-5.3-highspeed"), undefined); + assert.equal(lookupKnownModelSpec("glm-5.2-highspeed"), undefined); + assert.equal(lookupKnownModelSpec("glm-5.3-flash")?.id, "glm-5.3"); + + // qwen3.8-flash has its own official specs, distinct from qwen3.8-max. + const flash = lookupKnownModelSpec("qwen3.8-flash")!; + assert.equal(flash.contextWindow, 1000000); + assert.equal(flash.maxTokens, 131072); + assert.equal(lookupKnownModelSpec("qwen3.8-max")?.matches, undefined); + + // qwen3.8-flash-next is its own entry: 262,144 native context, + // 131,072 final-response output, xhigh/medium/low efforts. + const next = lookupKnownModelSpec("qwen3.8-flash-next")!; + assert.equal(next.contextWindow, 262144); + assert.equal(next.maxTokens, 131072); + assert.equal(next.reasoning, true); + assert.deepEqual(specReasoningLevels(next), ["low", "medium", "xhigh"]); +}); + +test("rows without vendor caps carry cited hosted-catalog values", () => { + // DeepSeek hosted-catalog values: Novita's 393216 output cap applies + // (captain-verified; deployments differ per source). + const pro = lookupKnownModelSpec("deepseek-v4-pro")!; + assert.equal(pro.contextWindow, 1048576); + assert.equal(pro.maxTokens, 393216); + assert.equal(lookupKnownModelSpec("deepseek-v4-flash")?.maxTokens, 393216); + // Kimi rows: both hosted catalogs agree. + assert.equal(lookupKnownModelSpec("kimi-k3")?.maxTokens, 1048576); + assert.equal(lookupKnownModelSpec("kimi-k2.6")?.maxTokens, 262144); + assert.equal(lookupKnownModelSpec("kimi-k2.7-code")?.maxTokens, 262144); + assert.equal(lookupKnownModelSpec("kimi-k2-0905-preview")?.maxTokens, 100352); + assert.equal(lookupKnownModelSpec("kimi-k2-0711-preview")?.maxTokens, 100352); + // GLM 5.2/5.1 filled from hosted catalogs (deployments differ per source). + assert.equal(lookupKnownModelSpec("glm-5.2")?.contextWindow, 1048576); + assert.equal(lookupKnownModelSpec("glm-5.2")?.maxTokens, 131072); + assert.equal(lookupKnownModelSpec("glm-5.1")?.contextWindow, 202752); + assert.equal(lookupKnownModelSpec("glm-5.1")?.maxTokens, 202752); + // Qwen 3.8 Max: Novita's values align with the official flash family. + assert.equal(lookupKnownModelSpec("qwen3.8-max")?.contextWindow, 1000000); + assert.equal(lookupKnownModelSpec("qwen3.8-max")?.maxTokens, 131072); + // MiMo output cap from Novita, consistent with the vendor context config. + assert.equal(lookupKnownModelSpec("mimo-v2.5")?.maxTokens, 131072); + // MiniMax output caps from Novita, consistent with official contexts. + assert.equal(lookupKnownModelSpec("MiniMax-M3")?.maxTokens, 131072); + assert.equal(lookupKnownModelSpec("MiniMax-M2.7")?.maxTokens, 131072); + // Nemotron output caps from the DeepInfra hosted deployment. + assert.equal(lookupKnownModelSpec("nvidia/nemotron-3-super-120b-a12b")?.maxTokens, 262144); + assert.equal(lookupKnownModelSpec("nvidia/nemotron-3-ultra-550b-a55b")?.maxTokens, 262144); + // Rows without any traceable source keep the prompt fallback. + assert.equal(lookupKnownModelSpec("nvidia/nemotron-3-nano-omni-30b-a3b-reasoning")?.maxTokens, undefined); +}); + +test("flagship rows carry compat overrides so Pi forwards effort at first-party endpoints", () => { + // Kimi K3: Moonshot runtime drops reasoning_effort unless told the endpoint + // supports it; the official thinking guide confirms a top-level effort. + const kimi = lookupKnownModelSpec("kimi-k3")!; + assert.deepEqual(kimi.compat, { supportsReasoningEffort: true }); + + // GLM 5.3: Z.AI runtime would send an undocumented thinking toggle and + // swallow the effort; official docs confirm a top-level OpenAI-style effort. + const glm = lookupKnownModelSpec("glm-5.3")!; + assert.deepEqual(glm.compat, { supportsReasoningEffort: true, thinkingFormat: "openai" }); +}); + +test("rows with a first-party effort limitation carry a printed note", () => { + for (const id of ["kimi-k2.6", "kimi-k2.7-code", "glm-5.2", "glm-5.1", "nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-ultra-550b-a55b", "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning"]) { + const spec = lookupKnownModelSpec(id)!; + assert.ok(spec.limitations && spec.limitations.length > 0, `${id} documents its effort limitation`); + } + // Flagship rows that forward the effort carry no limitation note. + assert.equal(lookupKnownModelSpec("kimi-k3")?.limitations, undefined); + assert.equal(lookupKnownModelSpec("glm-5.3")?.limitations, undefined); + assert.equal(lookupKnownModelSpec("deepseek-v4-pro")?.limitations, undefined); +}); + +test("compat overrides reach models.json through upsertProviderConfig", () => { + const dir = mkdtempSync(join(tmpdir(), "feynman-spec-catalog-")); + const modelsPath = join(dir, "models.json"); + const result = upsertProviderConfig(modelsPath, "proxy", { + baseUrl: "https://api.moonshot.ai/v1", + api: "openai-completions", + apiKey: "local", + models: [ + { id: "kimi-k3", contextWindow: 1048576, reasoning: true, compat: { supportsReasoningEffort: true } }, + { id: "glm-5.3", contextWindow: 1000000, maxTokens: 131072, reasoning: true, compat: { supportsReasoningEffort: true, thinkingFormat: "openai" } }, + ], + }); + assert.deepEqual(result, { ok: true }); + const parsed = JSON.parse(readFileSync(modelsPath, "utf8")) as any; + assert.deepEqual(parsed.providers.proxy.models[0].compat, { supportsReasoningEffort: true }); + assert.deepEqual(parsed.providers.proxy.models[1].compat, { supportsReasoningEffort: true, thinkingFormat: "openai" }); +}); + +test("specReasoningLevels reads the documented thinking-level map", () => { + // GLM-5.3 official docs: effort low / high / max, reasoning always on. + assert.deepEqual(specReasoningLevels(lookupKnownModelSpec("glm-5.3")), ["low", "high", "max"]); + // No explicit map: reasoning models keep the provider default levels. + assert.deepEqual(specReasoningLevels(lookupKnownModelSpec("kimi-k2.6")), ["low", "medium", "high"]); + assert.deepEqual(specReasoningLevels(lookupKnownModelSpec("kimi-k2-0905-preview")), []); +}); + +test("buildThinkingLevelMap keeps documented effort values and pins unselected levels to null", () => { + const hy3 = lookupKnownModelSpec("hy3")!; + const map = buildThinkingLevelMap(["high"], hy3.thinkingLevelMap); + assert.equal(map.off, "no_think"); + assert.equal(map.low, null); + assert.equal(map.high, "high"); + assert.equal(map.max, null); + + const synthetic = buildThinkingLevelMap(["low", "xhigh"]); + assert.deepEqual(synthetic, { + minimal: null, + low: "low", + medium: null, + high: null, + xhigh: "xhigh", + max: null, + }); + + // An empty selection pins every effort off while keeping "off" available. + const none = buildThinkingLevelMap([]); + assert.deepEqual(none, { + minimal: null, + low: null, + medium: null, + high: null, + xhigh: null, + max: null, + }); +}); + +test("parseTokenCountInput accepts plain and suffixed counts and rejects truncated input", () => { + assert.equal(parseTokenCountInput("128000"), 128000); + assert.equal(parseTokenCountInput(" 1048576 "), 1048576); + assert.equal(parseTokenCountInput("128k"), 128000); + assert.equal(parseTokenCountInput("1m"), 1000000); + assert.equal(parseTokenCountInput("2M"), 2000000); + assert.equal(parseTokenCountInput("128k tokens"), undefined); + assert.equal(parseTokenCountInput(""), undefined); + assert.equal(parseTokenCountInput("0"), undefined); + assert.equal(parseTokenCountInput("-5"), undefined); + assert.equal(parseTokenCountInput("12.5"), undefined); +}); + +test("promptModelSpecDefinitions non-interactive path returns bare ids for backward compatibility", async () => { + const definitions = await promptModelSpecDefinitions(["my-model"]); + assert.deepEqual(definitions, [{ id: "my-model" }]); +}); + +test("upsertProviderConfig writes catalog-derived per-model limits and preserves legacy provider entries", () => { + const dir = mkdtempSync(join(tmpdir(), "feynman-spec-catalog-")); + const modelsPath = join(dir, "models.json"); + + // Pre-existing config without the new fields stays untouched by setup re-runs. + const legacy = upsertProviderConfig(modelsPath, "legacy", { + baseUrl: "http://localhost:4000/v1", + api: "openai-completions", + apiKey: "local", + models: [{ id: "old-model" }], + }); + assert.deepEqual(legacy, { ok: true }); + + const result = upsertProviderConfig(modelsPath, "proxy", { + baseUrl: "https://proxy.example/v1", + api: "openai-completions", + apiKey: "local", + models: [ + { id: "kimi-k3", contextWindow: 1048576, maxTokens: 131072, reasoning: true, thinkingLevelMap: { low: "low", high: "high", max: "max" } }, + { id: "my-local-model", contextWindow: 128000, maxTokens: 16384, reasoning: false }, + ], + }); + assert.deepEqual(result, { ok: true }); + + const parsed = JSON.parse(readFileSync(modelsPath, "utf8")) as any; + assert.equal(parsed.providers.proxy.models[0].contextWindow, 1048576); + assert.equal(parsed.providers.proxy.models[0].maxTokens, 131072); + assert.equal(parsed.providers.proxy.models[0].reasoning, true); + assert.equal(parsed.providers.proxy.models[0].thinkingLevelMap.max, "max"); + assert.equal(parsed.providers.legacy.models[0].id, "old-model"); + assert.ok(parsed.providers.legacy.models.every((model: any) => model.contextWindow === undefined)); +}); diff --git a/website/src/content/docs/getting-started/setup.md b/website/src/content/docs/getting-started/setup.md index dd6ddee7..7bdb0032 100644 --- a/website/src/content/docs/getting-started/setup.md +++ b/website/src/content/docs/getting-started/setup.md @@ -108,6 +108,8 @@ feynman model set / to confirm the local model is available and make it the default. +For a custom API-key provider, setup also prompts for per-model limits that Feynman would otherwise leave to Pi's safe fallbacks (128k context, 16,384 max output tokens, thinking disabled): context length, max completion tokens, whether the model supports reasoning, and the reasoning efforts it accepts. When it recognizes the model id, Feynman pre-fills these from a built-in catalog of well-known open-weight models (DeepSeek, Kimi, GLM, Qwen, MiMo, Hunyuan, MiniMax, Nemotron) sourced from official docs and, where vendor docs stay silent, cited public hosted catalogs; unrecognized ids prompt with safe defaults. Every value stays editable, and the saved per-model limits are written into the provider's `models.json` entry. + ## Stage 3: Optional packages Feynman's core ships with the research essentials: alphaXiv access, web access, document parsing, subagents, and `/btw` side conversations while the main research agent is busy. On platforms with supported optional presets, the wizard can offer extras: From 1c671679def0b445f0b0d4e16bad4e7829cf4ba4 Mon Sep 17 00:00:00 2001 From: gh0stwin Date: Mon, 7 Sep 2026 12:52:43 +0000 Subject: [PATCH 2/8] Add built-in model spec catalog to custom API-key provider setup feynman setup / feynman model login for custom API-key providers now pre-fills editable defaults (context length, max completion tokens, supported reasoning efforts) from a typed, reviewable static catalog covering OpenAI GPT, Anthropic Claude, Google Gemini, DeepSeek, Kimi, GLM, Qwen, MiMo, Hunyuan 3/4, MiniMax, and Nemotron. Unknown model ids prompt with safe fallbacks; every catalog value stays user-overridable, and pre-existing saved configs that omit the fields keep Pi's fallbacks. Runtime semantics verified against Pi's getSupportedThinkingLevels: null mappings are honored as unsupported, so unselected efforts are pinned to null. Token input accepts 128000 as well as 128k/1m forms. --- src/model/commands.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/model/commands.ts b/src/model/commands.ts index b9676499..38a25f2e 100644 --- a/src/model/commands.ts +++ b/src/model/commands.ts @@ -492,9 +492,11 @@ async function promptLmStudioProviderSetup(): Promise ({ id })), + models: setup.modelDefinitions, }); if (!result.ok) { printWarning(result.error); @@ -788,7 +792,7 @@ async function configureApiKeyProvider(authPath: string, providerId?: string): P apiKey: setup.apiKeyConfig, api: setup.api, authHeader: setup.authHeader, - models: setup.modelIds.map((id) => ({ id })), + models: setup.modelDefinitions, }); if (!result.ok) { printWarning(result.error); From e231b9cd2ddbf37b29395483eefc2e27da4304bd Mon Sep 17 00:00:00 2001 From: gh0stwin Date: Mon, 7 Sep 2026 15:15:21 +0000 Subject: [PATCH 3/8] Forward reasoning effort at first-party endpoints via compat overrides --- tests/spec-catalog.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/spec-catalog.test.ts b/tests/spec-catalog.test.ts index fd7f0370..1278e27f 100644 --- a/tests/spec-catalog.test.ts +++ b/tests/spec-catalog.test.ts @@ -149,7 +149,6 @@ test("rows without vendor caps carry cited hosted-catalog values", () => { // Rows without any traceable source keep the prompt fallback. assert.equal(lookupKnownModelSpec("nvidia/nemotron-3-nano-omni-30b-a3b-reasoning")?.maxTokens, undefined); }); - test("flagship rows carry compat overrides so Pi forwards effort at first-party endpoints", () => { // Kimi K3: Moonshot runtime drops reasoning_effort unless told the endpoint // supports it; the official thinking guide confirms a top-level effort. From 1574692a903f005c001314f96c25c6220bfd08c2 Mon Sep 17 00:00:00 2001 From: gh0stwin Date: Mon, 7 Sep 2026 15:46:48 +0000 Subject: [PATCH 4/8] Document new setup per-model limit prompts in setup.md --- website/src/content/docs/getting-started/setup.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/website/src/content/docs/getting-started/setup.md b/website/src/content/docs/getting-started/setup.md index 7bdb0032..98daf7d2 100644 --- a/website/src/content/docs/getting-started/setup.md +++ b/website/src/content/docs/getting-started/setup.md @@ -108,7 +108,11 @@ feynman model set / to confirm the local model is available and make it the default. -For a custom API-key provider, setup also prompts for per-model limits that Feynman would otherwise leave to Pi's safe fallbacks (128k context, 16,384 max output tokens, thinking disabled): context length, max completion tokens, whether the model supports reasoning, and the reasoning efforts it accepts. When it recognizes the model id, Feynman pre-fills these from a built-in catalog of well-known open-weight models (DeepSeek, Kimi, GLM, Qwen, MiMo, Hunyuan, MiniMax, Nemotron) sourced from official docs and, where vendor docs stay silent, cited public hosted catalogs; unrecognized ids prompt with safe defaults. Every value stays editable, and the saved per-model limits are written into the provider's `models.json` entry. +<<<<<<< HEAD +For any API-key provider (custom, LM Studio, or LiteLLM), setup also prompts for per-model limits that Feynman would otherwise leave to Pi's safe fallbacks (128k context, 16,384 max output tokens, thinking disabled): context length, max completion tokens, whether the model supports reasoning, and the reasoning efforts it accepts. When it recognizes the model id, Feynman pre-fills these from a built-in catalog of well-known open-weight models (DeepSeek, Kimi, GLM, Qwen, MiMo, Hunyuan, MiniMax, Nemotron) sourced from official docs and, where vendor docs stay silent, cited public hosted catalogs; unrecognized ids prompt with safe defaults. Every value stays editable, and the saved per-model limits are written into the provider's `models.json` entry. +======= +For any API-key provider (custom, LM Studio, or LiteLLM), setup also prompts for per-model limits that Feynman would otherwise leave to Pi's safe fallbacks (128k context, 16,384 max output tokens, thinking disabled): context length, max completion tokens, whether the model supports reasoning, and the reasoning efforts it accepts. When it recognizes the model id, Feynman pre-fills these from a built-in catalog of well-known open-weight models (DeepSeek, Kimi, GLM, Qwen, MiMo, Hunyuan, MiniMax, Nemotron) sourced from official docs; unrecognized ids prompt with safe defaults. Every value stays editable, and the saved per-model limits are written into the provider's `models.json` entry. +>>>>>>> 02e0871 (Document new setup per-model limit prompts in setup.md) ## Stage 3: Optional packages From 7b7e9bc6f1474d30a50adfa48a9798e7ac59bc7f Mon Sep 17 00:00:00 2001 From: gh0stwin Date: Mon, 7 Sep 2026 17:57:27 +0000 Subject: [PATCH 5/8] Scope per-model limit prompts to custom API-key provider flow only --- src/model/commands.ts | 8 ++------ website/src/content/docs/getting-started/setup.md | 4 ---- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/model/commands.ts b/src/model/commands.ts index 38a25f2e..b9676499 100644 --- a/src/model/commands.ts +++ b/src/model/commands.ts @@ -492,11 +492,9 @@ async function promptLmStudioProviderSetup(): Promise ({ id })), }); if (!result.ok) { printWarning(result.error); @@ -792,7 +788,7 @@ async function configureApiKeyProvider(authPath: string, providerId?: string): P apiKey: setup.apiKeyConfig, api: setup.api, authHeader: setup.authHeader, - models: setup.modelDefinitions, + models: setup.modelIds.map((id) => ({ id })), }); if (!result.ok) { printWarning(result.error); diff --git a/website/src/content/docs/getting-started/setup.md b/website/src/content/docs/getting-started/setup.md index 98daf7d2..9c6d5729 100644 --- a/website/src/content/docs/getting-started/setup.md +++ b/website/src/content/docs/getting-started/setup.md @@ -108,11 +108,7 @@ feynman model set / to confirm the local model is available and make it the default. -<<<<<<< HEAD For any API-key provider (custom, LM Studio, or LiteLLM), setup also prompts for per-model limits that Feynman would otherwise leave to Pi's safe fallbacks (128k context, 16,384 max output tokens, thinking disabled): context length, max completion tokens, whether the model supports reasoning, and the reasoning efforts it accepts. When it recognizes the model id, Feynman pre-fills these from a built-in catalog of well-known open-weight models (DeepSeek, Kimi, GLM, Qwen, MiMo, Hunyuan, MiniMax, Nemotron) sourced from official docs and, where vendor docs stay silent, cited public hosted catalogs; unrecognized ids prompt with safe defaults. Every value stays editable, and the saved per-model limits are written into the provider's `models.json` entry. -======= -For any API-key provider (custom, LM Studio, or LiteLLM), setup also prompts for per-model limits that Feynman would otherwise leave to Pi's safe fallbacks (128k context, 16,384 max output tokens, thinking disabled): context length, max completion tokens, whether the model supports reasoning, and the reasoning efforts it accepts. When it recognizes the model id, Feynman pre-fills these from a built-in catalog of well-known open-weight models (DeepSeek, Kimi, GLM, Qwen, MiMo, Hunyuan, MiniMax, Nemotron) sourced from official docs; unrecognized ids prompt with safe defaults. Every value stays editable, and the saved per-model limits are written into the provider's `models.json` entry. ->>>>>>> 02e0871 (Document new setup per-model limit prompts in setup.md) ## Stage 3: Optional packages From 63eaae43c7bf9fc04db52d3fd4fe0b92c7d32c3b Mon Sep 17 00:00:00 2001 From: gh0stwin Date: Tue, 8 Sep 2026 04:57:38 +0000 Subject: [PATCH 6/8] Fill catalog limits from cited hosted catalogs Fill contextWindow and maxTokens for every KNOWN_MODEL_SPECS row the vendor docs leave silent, citing the public hosted catalogs per the captain's verified readout (DeepInfra /v1/openai/models and Novita model metadata, both read 2026-09-08): DeepSeek 1048576/1048576, Kimi K3 1048576/1048576, K2.6/K2.7-Code 262144/262144, K2-0905 and K2-0711 100352 output (Novita), GLM-5.2 1048576/131072, GLM-5.1 202752/202752 (captain-cited DeepInfra values), Qwen3.8-Max 1000000/131072 (Novita, consistent with the official flash family), MiMo 131072 output, Hunyuan Hy3 262144 output, MiniMax M3/M2.7 131072 output, Nemotron Super/Ultra 262144 output. Hosted deployments disagree in places (Novita vs DeepInfra on DeepSeek/GLM-5.1/Qwen-Max output caps); the row comments name which source backs each value and where a conflicting deployment differs. Rows with no traceable source anywhere (Hunyuan Hy4, Nemotron Nano Omni) keep the prompt fallback and say so. --- tests/spec-catalog.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/spec-catalog.test.ts b/tests/spec-catalog.test.ts index 1278e27f..fd7f0370 100644 --- a/tests/spec-catalog.test.ts +++ b/tests/spec-catalog.test.ts @@ -149,6 +149,7 @@ test("rows without vendor caps carry cited hosted-catalog values", () => { // Rows without any traceable source keep the prompt fallback. assert.equal(lookupKnownModelSpec("nvidia/nemotron-3-nano-omni-30b-a3b-reasoning")?.maxTokens, undefined); }); + test("flagship rows carry compat overrides so Pi forwards effort at first-party endpoints", () => { // Kimi K3: Moonshot runtime drops reasoning_effort unless told the endpoint // supports it; the official thinking guide confirms a top-level effort. From 9a2a0f7ffd3922518e48aebf57940e57711328f2 Mon Sep 17 00:00:00 2001 From: gh0stwin Date: Tue, 8 Sep 2026 13:03:12 +0000 Subject: [PATCH 7/8] Resolve company-qualified model ids and add DeepSeek effort specs The catalog lookup now resolves both model-id formats: the bare tag ("deepseek-v4-pro") and a company-qualified id ("deepseek/deepseek-v4-pro", "zai-org/glm-5.3-flash", "moonshotai/kimi-k3"). Each row carries its catalog family key; a known company segment scopes tag matching to that family's rows so a tag shared across companies cannot mis-resolve, and an unknown segment falls back to tag-only matching. The DeepSeek rows now carry their officially documented reasoning surface: reasoning_effort accepts only low / high / max (medium and xhigh map to high server-side; low stays low, max stays max), and thinking toggles through {"thinking": {"type": "enabled" | "disabled"}} (enabled by default, default effort high) - expressed as Pi's "deepseek" thinkingFormat with supportsReasoningEffort, citing the DeepSeek docs in the rows. --- src/model/spec-catalog.ts | 153 ++++++++++++++++-- tests/spec-catalog.test.ts | 44 +++++ .../src/content/docs/getting-started/setup.md | 2 +- 3 files changed, 186 insertions(+), 13 deletions(-) diff --git a/src/model/spec-catalog.ts b/src/model/spec-catalog.ts index 21e374cf..a103a795 100644 --- a/src/model/spec-catalog.ts +++ b/src/model/spec-catalog.ts @@ -24,10 +24,20 @@ * Grok) never appear here — their providers ship their own runtime model * registries, so a closed-weight id is simply unknown to this catalog and * prompts at setup with safe fallbacks. + * + * Model ids resolve both as a bare tag (`deepseek-v4-pro`) and as a + * company-qualified id (`deepseek/deepseek-v4-pro`, `zai-org/glm-5.3-flash`, + * `moonshotai/kimi-k3`); see `lookupKnownModelSpec` for the resolution rules. */ export type ModelThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; +/** + * Catalog family key a row carries in `company`; company segments of + * company-qualified model ids resolve to it through `COMPANY_PREFIX_FAMILIES`. + */ +export type KnownModelFamily = "deepseek" | "kimi" | "glm" | "qwen" | "mimo" | "hunyuan" | "minimax" | "nemotron"; + /** * Per-model definition accepted by Pi's models.json loader, carrying the * setup-derived limits. Written verbatim into `models.json` next to `id`. @@ -46,6 +56,9 @@ export type KnownModelSpec = { id: string; /** Human-readable family label shown during setup. */ label: string; + /** Family key this row belongs to; a known company segment in a + * company-qualified id scopes tag matching to this family's rows. */ + company?: KnownModelFamily; /** Official context window in tokens. Undefined = not documented, setup prompts. */ contextWindow?: number; /** Official max completion (output) tokens. Undefined = setup prompts. */ @@ -115,9 +128,18 @@ function hunyuanThinkingLevelMap(levels: Array<"low" | "high">): { export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ // --- DeepSeek --- + // DeepSeek thinking-mode docs (captain-verified, firstmate re-verified): + // reasoning_effort accepts ONLY low / high / max — medium and xhigh map + // to high server-side, low stays low, max stays max — and thinking + // toggles through {"thinking": {"type": "enabled" | "disabled"}}, + // enabled by default with a default effort of high. Pi's "deepseek" + // thinkingFormat emits exactly this wire shape (enabled with the mapped + // effort, or disabled when no effort is selected), so the map below + // carries the three documented efforts and leaves `off` to the toggle. { id: "deepseek-v4-pro", label: "DeepSeek V4 Pro", + company: "deepseek", // Thinking mode and reasoning_effort are officially documented, but // first-party caps are not. Hosted-catalog values (read 2026-09-08): // DeepInfra deepseek-ai/DeepSeek-V4-Pro 1048576/1048576; Novita hosts @@ -125,17 +147,22 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ contextWindow: 1048576, maxTokens: 393216, reasoning: true, + thinkingLevelMap: { minimal: null, low: "low", medium: null, high: "high", xhigh: null, max: "max" }, + compat: { thinkingFormat: "deepseek", supportsReasoningEffort: true }, matches: ["deepseek-v4-pro-0813"], sources: [DEEPSEEK_DOCS, DEEPINFRA_CATALOG], }, { id: "deepseek-v4-flash", label: "DeepSeek V4 Flash", + company: "deepseek", // Hosted-catalog values (read 2026-09-08): DeepInfra 1048576/1048576 // for deepseek-ai/DeepSeek-V4-Flash; Novita hosts 393216 output. contextWindow: 1048576, maxTokens: 393216, reasoning: true, + thinkingLevelMap: { minimal: null, low: "low", medium: null, high: "high", xhigh: null, max: "max" }, + compat: { thinkingFormat: "deepseek", supportsReasoningEffort: true }, matches: ["deepseek-v4-flash-0731", "deepseek-v4-flash-vision-exp"], sources: [DEEPSEEK_DOCS, DEEPINFRA_CATALOG], }, @@ -144,6 +171,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "kimi-k3", label: "Kimi K3 (Moonshot)", + company: "kimi", // Official docs: 1M-token context window; reasoning_effort supports // "low" / "high" / "max" (default "max"). Hosted catalogs agree on the // caps (read 2026-09-08): DeepInfra and Novita both host K3 at @@ -162,6 +190,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "kimi-k2.6", label: "Kimi K2.6 (Moonshot)", + company: "kimi", // Official docs: 256K context window, thinking and non-thinking modes; // per-model effort parameter values differ from K3, so the map is // prompted, not assumed. Hosted catalogs agree on the caps (read @@ -179,6 +208,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "kimi-k2.7-code", label: "Kimi K2.7 Code (Moonshot)", + company: "kimi", // Official docs: 256K context window with thinking mode. Hosted // catalogs agree (read 2026-09-08): DeepInfra and Novita both host // K2.7-Code at 262144/262144. @@ -192,6 +222,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "kimi-k2-0905-preview", label: "Kimi K2 0905 (Moonshot)", + company: "kimi", // Novita hosts moonshotai/kimi-k2-0905 at 262144/100352 (read // 2026-09-08); DeepInfra does not carry this variant. contextWindow: 262144, @@ -203,6 +234,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "kimi-k2-0711-preview", label: "Kimi K2 0711 (Moonshot)", + company: "kimi", // Novita hosts moonshotai/kimi-k2-instruct (the K2 131072-context // variant) at 131072/100352 (read 2026-09-08); no deepinfra variant. contextWindow: 131072, @@ -215,6 +247,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "glm-5.3", label: "GLM 5.3 (Z.AI)", + company: "glm", // Official docs: 1M context, 128K max output; reasoning is always on // (disabling no longer supported) with effort levels low / high / max. contextWindow: 1048576, @@ -233,6 +266,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "glm-5.2", label: "GLM 5.2 (Z.AI)", + company: "glm", // Hosted catalogs (read 2026-09-08): Novita zai-org/glm-5.2 at // 1048576 context / 131072 output (consistent with the GLM-5.3 // vendor output cap); DeepInfra hosts a 1048576/1048576 deployment. @@ -248,6 +282,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "glm-5.1", label: "GLM 5.1 (Z.AI)", + company: "glm", // Hosted-catalog values (read 2026-09-08, captain-verified): DeepInfra // zai-org/GLM-5.1 at 202752/202752; Novita hosts 204800/131072 for // the same id - hosted deployments differ. @@ -263,6 +298,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "qwen3.8-max", label: "Qwen 3.8 Max (Alibaba)", + company: "qwen", // Hosted-catalog values (read 2026-09-08): Novita qwen/qwen3.8-max at // 1000000/131072 (consistent with the official qwen3.8-flash family // values); DeepInfra hosts a 256000/256000 deployment - differs. @@ -274,6 +310,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "qwen3.8-flash", label: "Qwen 3.8 Flash (Alibaba)", + company: "qwen", // Official Model Studio page (alibabacloud.com/help/en/model-studio/ // qwen3-8-flash.md): context window 1,000,000; max output 131,072 // (thinking and direct modes); multimodal reasoning model. @@ -285,6 +322,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "qwen3.8-flash-next", label: "Qwen 3.8 Flash Next (Alibaba)", + company: "qwen", // Official Qwen3.8-Flash-Next card (huggingface.co/Qwen/Qwen3.8-Flash-Next): // context 262,144 natively (extensible to 1,000,000 only via self-hosted // YaRN RoPE scaling); recommended final-response output 131,072 @@ -299,6 +337,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "qwen3.7-max", label: "Qwen 3.7 Max (Alibaba)", + company: "qwen", reasoning: true, matches: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash"], sources: [QWEN_DOCS], @@ -308,6 +347,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "mimo-v2.5", label: "MiMo v2.5 (Xiaomi)", + company: "mimo", // Official HF config.json: max_position_embeddings 1048576 for both // variants. Hosted-catalog output cap (read 2026-09-08): Novita hosts // both mimo-v2.5 and mimo-v2.5-pro at 1048576/131072 (consistent with @@ -323,6 +363,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "hy3", label: "Hunyuan 3 (Tencent)", + company: "hunyuan", contextWindow: 262144, // Tencent documents the context length and reasoning efforts but no // official max-completion cap; DeepInfra hosts tencent/Hy3 at @@ -337,6 +378,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "hy4-preview", label: "Hunyuan 4 (Tencent)", + company: "hunyuan", contextWindow: 1048576, maxTokens: 64000, // No official max-completion cap is documented and neither hosted @@ -352,6 +394,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "MiniMax-M3", label: "MiniMax M3", + company: "minimax", // Official docs: 1,000,000-token context window. Hosted-catalog output // cap (read 2026-09-08): Novita minimax/minimax-m3 at 1000000/131072 // (context matches the vendor doc); DeepInfra hosts 524288/524288. @@ -364,6 +407,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "MiniMax-M2.7", label: "MiniMax M2.7", + company: "minimax", // Official docs: 204,800 context window; hosted-catalog output cap // (read 2026-09-08): Novita hosts minimax-m2.7 and // minimax-m2.7-highspeed at 204800/131072. @@ -378,6 +422,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "nvidia/nemotron-3-super-120b-a12b", label: "NVIDIA Nemotron 3 Super", + company: "nemotron", // Official build.nvidia.com specification: contextLength 1048576 // (serving defaults to 256k; the model spec is 1M). Hosted-catalog // output cap (read 2026-09-08): DeepInfra hosts the model at @@ -394,6 +439,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "nvidia/nemotron-3-ultra-550b-a55b", label: "NVIDIA Nemotron 3 Ultra", + company: "nemotron", // Official build.nvidia.com specification: contextLength 1048576. // Hosted-catalog output cap (read 2026-09-08): DeepInfra hosts the // model at 262144/262144. @@ -407,6 +453,7 @@ export const KNOWN_MODEL_SPECS: KnownModelSpec[] = [ { id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", label: "NVIDIA Nemotron 3 Nano Omni", + company: "nemotron", // Official build.nvidia.com specification: contextLength 262144, // omni-modal input (text, image, audio, video). The hosted catalogs // carry no matching omni variant (read 2026-09-08), so setup prompts @@ -430,19 +477,106 @@ export const UNKNOWN_MODEL_FALLBACK = { const DATE_SUFFIX_PATTERN = /-\d{8}$/; /** - * Resolve a bare model id against the built-in catalog. Matching is - * case-insensitive on the exact id and documented aliases, tolerates a - * trailing `-YYYYMMDD` date suffix, and falls back to an anchored family - * pattern when one is declared. + * Hosted-org segments that may prefix a model id's tag (e.g. `deepseek` in + * `deepseek/deepseek-v4-pro`, `zai-org` in `zai-org/glm-5.3-flash`, + * `moonshotai` in `moonshotai/kimi-k3`), each resolving to the `company` + * family key its rows carry. A known company scopes tag matching to that + * company's rows so a tag shared across companies cannot mis-resolve. + */ +const COMPANY_PREFIX_FAMILIES: Record = { + // DeepSeek (first-party domain and the DeepInfra/HF org casing) + deepseek: "deepseek", + "deepseek-ai": "deepseek", + // Kimi (Moonshot) + moonshotai: "kimi", + moonshot: "kimi", + // GLM (Zhipu / Z.AI) + "zai-org": "glm", + zai: "glm", + "z-ai": "glm", + zhipu: "glm", + "zhipu-ai": "glm", + zhipuai: "glm", + // Qwen (Alibaba) + qwen: "qwen", + alibaba: "qwen", + alibabacloud: "qwen", + // MiMo (Xiaomi) + xiaomi: "mimo", + mimo: "mimo", + // Hunyuan (Tencent) + tencent: "hunyuan", + "tencent-hunyuan": "hunyuan", + // MiniMax + minimax: "minimax", + // Nemotron (NVIDIA) + nvidia: "nemotron", +}; + +const COMPILED_SPEC_PATTERNS = new WeakMap(); + +function compiledSpecPattern(spec: KnownModelSpec): RegExp | undefined { + if (spec.pattern === undefined) return undefined; + let compiled = COMPILED_SPEC_PATTERNS.get(spec); + if (!compiled) { + compiled = new RegExp(spec.pattern, "i"); + COMPILED_SPEC_PATTERNS.set(spec, compiled); + } + return compiled; +} + +/** + * Match a bare model tag against a pool of catalog rows: exact id or + * documented alias (case-insensitive), a trailing `-YYYYMMDD` date suffix + * tolerated, then an anchored family pattern when one is declared. Catalog + * order breaks alias collisions deterministically. + */ +function findSpecByTag(tag: string, pool: readonly KnownModelSpec[]): KnownModelSpec | undefined { + const normalized = tag.trim().toLowerCase(); + if (!normalized) return undefined; + + const exact = (candidate: string) => + pool.find((spec) => spec.id.toLowerCase() === candidate + || (spec.matches ?? []).some((alias) => alias.toLowerCase() === candidate)); + const hit = exact(normalized) + ?? (DATE_SUFFIX_PATTERN.test(normalized) ? exact(normalized.replace(DATE_SUFFIX_PATTERN, "")) : undefined); + if (hit) return hit; + + for (const spec of pool) { + if (compiledSpecPattern(spec)?.test(normalized)) return spec; + } + return undefined; +} + +/** + * Resolve a model id against the built-in catalog. Accepts both the bare tag + * (`deepseek-v4-pro`) and a company-qualified id (`deepseek/deepseek-v4-pro`, + * `zai-org/glm-5.3-flash`, `moonshotai/kimi-k3`). Matching is case-insensitive + * on the exact id and documented aliases, tolerates a trailing `-YYYYMMDD` + * date suffix, and falls back to an anchored family pattern when one is + * declared. A known company segment scopes the tag to that company's rows so + * a tag shared across companies cannot mis-resolve; an unknown company + * segment falls back to tag-only matching. */ export function lookupKnownModelSpec(modelId: string): KnownModelSpec | undefined { const id = modelId.trim().toLowerCase(); if (!id) return undefined; const exact = (candidate: string) => KNOWN_SPEC_INDEX.get(candidate); - return exact(id) - ?? (DATE_SUFFIX_PATTERN.test(id) ? exact(id.replace(DATE_SUFFIX_PATTERN, "")) : undefined) - ?? KNOWN_SPEC_PATTERNS.reduce((hit, pattern) => hit ?? (pattern.pattern.test(id) ? pattern.spec : undefined), undefined); + const exactHit = exact(id) + ?? (DATE_SUFFIX_PATTERN.test(id) ? exact(id.replace(DATE_SUFFIX_PATTERN, "")) : undefined); + if (exactHit) return exactHit; + + const slash = id.indexOf("/"); + if (slash <= 0) { + return findSpecByTag(id, KNOWN_MODEL_SPECS); + } + + const family = COMPANY_PREFIX_FAMILIES[id.slice(0, slash)]; + const pool = family + ? KNOWN_MODEL_SPECS.filter((spec) => spec.company === family) + : KNOWN_MODEL_SPECS; + return findSpecByTag(id.slice(slash + 1), pool); } const KNOWN_SPEC_INDEX = new Map(); @@ -453,11 +587,6 @@ for (const spec of KNOWN_MODEL_SPECS) { } } -const KNOWN_SPEC_PATTERNS = KNOWN_MODEL_SPECS.filter((spec) => spec.pattern !== undefined).map((spec) => ({ - spec, - pattern: new RegExp(spec.pattern!, "i"), -})); - /** * Reasoning efforts a catalog spec documents, derived from its thinking-level * map (identity-mapped levels) when present, otherwise the provider defaults diff --git a/tests/spec-catalog.test.ts b/tests/spec-catalog.test.ts index fd7f0370..cbbc3479 100644 --- a/tests/spec-catalog.test.ts +++ b/tests/spec-catalog.test.ts @@ -48,6 +48,34 @@ test("lookupKnownModelSpec matches aliases and dated model ids case-insensitivel assert.equal(lookupKnownModelSpec("hunyuan-t4")?.id, "hy4-preview"); }); +test("lookupKnownModelSpec resolves company-qualified ids to the right family", () => { + // Users pass either the bare tag or `/`; both must resolve. + assert.equal(lookupKnownModelSpec("deepseek/deepseek-v4-pro")?.id, "deepseek-v4-pro"); + assert.equal(lookupKnownModelSpec("deepseek-ai/DeepSeek-V4-Flash")?.id, "deepseek-v4-flash"); + assert.equal(lookupKnownModelSpec("zai-org/glm-5.3-flash")?.id, "glm-5.3"); + assert.equal(lookupKnownModelSpec("moonshotai/kimi-k3")?.id, "kimi-k3"); + assert.equal(lookupKnownModelSpec("moonshotai/kimi-k2.6")?.id, "kimi-k2.6"); + assert.equal(lookupKnownModelSpec("qwen/qwen3.8-flash")?.id, "qwen3.8-flash"); + assert.equal(lookupKnownModelSpec("mimo/mimo-v2.5")?.id, "mimo-v2.5"); + assert.equal(lookupKnownModelSpec("minimax/minimax-m3")?.id, "MiniMax-M3"); + // The tag falls back to the family pattern inside the named company's rows. + assert.equal(lookupKnownModelSpec("tencent/hy3")?.label, "Hunyuan 3 (Tencent)"); + // Row ids that already carry the org segment keep resolving exactly. + assert.equal(lookupKnownModelSpec("nvidia/nemotron-3-ultra-550b-a55b")?.id, "nvidia/nemotron-3-ultra-550b-a55b"); +}); + +test("a known company segment scopes the tag so shared tags cannot mis-resolve", () => { + // A tag foreign to the named company must miss rather than resolve into + // another family's rows. + assert.equal(lookupKnownModelSpec("zai-org/kimi-k3"), undefined); + assert.equal(lookupKnownModelSpec("deepseek/hy3"), undefined); + // An unknown company segment falls back to tag-only matching. + assert.equal(lookupKnownModelSpec("my-proxy/glm-5.3")?.id, "glm-5.3"); + assert.equal(lookupKnownModelSpec("my-proxy/totally-made-up"), undefined); + // Dated company-qualified ids keep their tolerance. + assert.equal(lookupKnownModelSpec("moonshotai/kimi-k3-20260901")?.id, "kimi-k3"); +}); + test("unknown model ids miss the catalog and fall back to safe defaults", () => { assert.equal(lookupKnownModelSpec("totally-made-up-model"), undefined); assert.equal(UNKNOWN_MODEL_FALLBACK.contextWindow, 128000); @@ -191,6 +219,22 @@ test("compat overrides reach models.json through upsertProviderConfig", () => { assert.deepEqual(parsed.providers.proxy.models[1].compat, { supportsReasoningEffort: true, thinkingFormat: "openai" }); }); +test("DeepSeek rows carry the official low/high/max efforts and the deepseek thinking wire format", () => { + for (const id of ["deepseek-v4-pro", "deepseek-v4-flash", "deepseek/deepseek-v4-pro"]) { + const spec = lookupKnownModelSpec(id)!; + // DeepSeek thinking-mode docs: reasoning_effort accepts ONLY + // low / high / max (medium and xhigh map to high server-side), and + // thinking toggles via {"thinking": {"type": "enabled" | "disabled"}} + // — enabled by default, default effort high. + assert.deepEqual(specReasoningLevels(spec), ["low", "high", "max"]); + assert.deepEqual(spec.thinkingLevelMap, { minimal: null, low: "low", medium: null, high: "high", xhigh: null, max: "max" }); + // Pi's "deepseek" thinkingFormat emits exactly this wire shape; + // supportsReasoningEffort forwards the selected effort. + assert.deepEqual(spec.compat, { thinkingFormat: "deepseek", supportsReasoningEffort: true }); + assert.ok(spec.sources.includes("https://api-docs.deepseek.com"), `${id} cites the DeepSeek docs`); + } +}); + test("specReasoningLevels reads the documented thinking-level map", () => { // GLM-5.3 official docs: effort low / high / max, reasoning always on. assert.deepEqual(specReasoningLevels(lookupKnownModelSpec("glm-5.3")), ["low", "high", "max"]); diff --git a/website/src/content/docs/getting-started/setup.md b/website/src/content/docs/getting-started/setup.md index 9c6d5729..477d8cdd 100644 --- a/website/src/content/docs/getting-started/setup.md +++ b/website/src/content/docs/getting-started/setup.md @@ -108,7 +108,7 @@ feynman model set / to confirm the local model is available and make it the default. -For any API-key provider (custom, LM Studio, or LiteLLM), setup also prompts for per-model limits that Feynman would otherwise leave to Pi's safe fallbacks (128k context, 16,384 max output tokens, thinking disabled): context length, max completion tokens, whether the model supports reasoning, and the reasoning efforts it accepts. When it recognizes the model id, Feynman pre-fills these from a built-in catalog of well-known open-weight models (DeepSeek, Kimi, GLM, Qwen, MiMo, Hunyuan, MiniMax, Nemotron) sourced from official docs and, where vendor docs stay silent, cited public hosted catalogs; unrecognized ids prompt with safe defaults. Every value stays editable, and the saved per-model limits are written into the provider's `models.json` entry. +For any API-key provider (custom, LM Studio, or LiteLLM), setup also prompts for per-model limits that Feynman would otherwise leave to Pi's safe fallbacks (128k context, 16,384 max output tokens, thinking disabled): context length, max completion tokens, whether the model supports reasoning, and the reasoning efforts it accepts. When it recognizes the model id, Feynman pre-fills these from a built-in catalog of well-known open-weight models (DeepSeek, Kimi, GLM, Qwen, MiMo, Hunyuan, MiniMax, Nemotron) sourced from official docs and, where vendor docs stay silent, cited public hosted catalogs; unrecognized ids prompt with safe defaults. The model id resolves either as a bare tag (`deepseek-v4-pro`) or as a company-qualified id (`deepseek/deepseek-v4-pro`, `zai-org/glm-5.3-flash`, `moonshotai/kimi-k3`); a known company segment scopes matching to that family's rows, and an unknown one falls back to tag-only matching. Every value stays editable, and the saved per-model limits are written into the provider's `models.json` entry. ## Stage 3: Optional packages From 4d1edaa32c2409c59d29c3ed7b8b3b305d3aee93 Mon Sep 17 00:00:00 2001 From: gh0stwin Date: Tue, 8 Sep 2026 13:44:28 +0000 Subject: [PATCH 8/8] no-mistakes(review): Restore custom-provider scoping in setup per-model limits doc --- website/src/content/docs/getting-started/setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/content/docs/getting-started/setup.md b/website/src/content/docs/getting-started/setup.md index 477d8cdd..9649906e 100644 --- a/website/src/content/docs/getting-started/setup.md +++ b/website/src/content/docs/getting-started/setup.md @@ -108,7 +108,7 @@ feynman model set / to confirm the local model is available and make it the default. -For any API-key provider (custom, LM Studio, or LiteLLM), setup also prompts for per-model limits that Feynman would otherwise leave to Pi's safe fallbacks (128k context, 16,384 max output tokens, thinking disabled): context length, max completion tokens, whether the model supports reasoning, and the reasoning efforts it accepts. When it recognizes the model id, Feynman pre-fills these from a built-in catalog of well-known open-weight models (DeepSeek, Kimi, GLM, Qwen, MiMo, Hunyuan, MiniMax, Nemotron) sourced from official docs and, where vendor docs stay silent, cited public hosted catalogs; unrecognized ids prompt with safe defaults. The model id resolves either as a bare tag (`deepseek-v4-pro`) or as a company-qualified id (`deepseek/deepseek-v4-pro`, `zai-org/glm-5.3-flash`, `moonshotai/kimi-k3`); a known company segment scopes matching to that family's rows, and an unknown one falls back to tag-only matching. Every value stays editable, and the saved per-model limits are written into the provider's `models.json` entry. +For a custom API-key provider, setup also prompts for per-model limits that Feynman would otherwise leave to Pi's safe fallbacks (128k context, 16,384 max output tokens, thinking disabled): context length, max completion tokens, whether the model supports reasoning, and the reasoning efforts it accepts. When it recognizes the model id, Feynman pre-fills these from a built-in catalog of well-known open-weight models (DeepSeek, Kimi, GLM, Qwen, MiMo, Hunyuan, MiniMax, Nemotron) sourced from official docs and, where vendor docs stay silent, cited public hosted catalogs; unrecognized ids prompt with safe defaults. The model id resolves either as a bare tag (`deepseek-v4-pro`) or as a company-qualified id (`deepseek/deepseek-v4-pro`, `zai-org/glm-5.3-flash`, `moonshotai/kimi-k3`); a known company segment scopes matching to that family's rows, and an unknown one falls back to tag-only matching. Every value stays editable, and the saved per-model limits are written into the provider's `models.json` entry. ## Stage 3: Optional packages