diff --git a/src/adapters/openclaw.test.ts b/src/adapters/openclaw.test.ts index 9d64876..bf3e244 100644 --- a/src/adapters/openclaw.test.ts +++ b/src/adapters/openclaw.test.ts @@ -71,6 +71,27 @@ describe("openclawAdapter.write", () => { expect(notes.join("\n")).toContain(configPath(home)); }); + test("known models carry contextWindow/maxTokens/reasoning; unknown ids stay minimal", () => { + const home = tempHome(); + openclawAdapter.write(home, { + baseUrl: BASE_URL, + key: KEY, + availableModels: ["gpt-4o", "deepseek-v4-pro", "totally-new-model"], + }); + const models = readJson(home).models.providers.apiflux.models; + const byId = Object.fromEntries(models.map((m: any) => [m.id, m])); + // provider-model-helpers.ts otherwise fills a generic default context window. + expect(byId["gpt-4o"]).toEqual({ + id: "gpt-4o", + name: "gpt-4o", + reasoning: false, + contextWindow: 128_000, + maxTokens: 16_384, + }); + expect(byId["deepseek-v4-pro"].reasoning).toBe(true); + expect(byId["totally-new-model"]).toEqual({ id: "totally-new-model", name: "totally-new-model" }); + }); + test("chosen model sets agents.defaults.model.primary", () => { const home = tempHome(); openclawAdapter.write(home, { diff --git a/src/adapters/openclaw.ts b/src/adapters/openclaw.ts index 7720b60..0a2110b 100644 --- a/src/adapters/openclaw.ts +++ b/src/adapters/openclaw.ts @@ -2,9 +2,25 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import JSON5 from "json5"; import { withV1 } from "../endpoint"; +import { getModelCapabilities } from "../model-catalog"; import type { Adapter, AdapterInput } from "./types"; import { backupOnce } from "./backup"; +// types.models.ts accepts reasoning/contextWindow/maxTokens per model; +// provider-model-helpers.ts otherwise fills a generic default context window. +// Pi-specific compat/thinkingLevelMap are not part of OpenClaw's schema. +function openclawModel(id: string): Record { + const caps = getModelCapabilities(id); + if (!caps) return { id, name: id }; + return { + id, + name: id, + reasoning: caps.reasoning, + contextWindow: caps.contextWindow, + maxTokens: caps.maxTokens, + }; +} + // OpenClaw resolves its state dir and config path with env overrides // (src/config/paths.ts); the adapter must honor the same ones. function stateDir(home: string): string { @@ -73,8 +89,7 @@ function apifluxProvider(input: AdapterInput): Record { // ${VAR} references abort config load when the var is missing. apiKey: input.key, api: "openai-completions", - // Official docs' minimal shape; the runtime fills remaining metadata. - models: modelIds.map((id) => ({ id, name: id })), + models: modelIds.map(openclawModel), }; } diff --git a/src/adapters/opencode.test.ts b/src/adapters/opencode.test.ts index f9fcced..cf69bd1 100644 --- a/src/adapters/opencode.test.ts +++ b/src/adapters/opencode.test.ts @@ -83,6 +83,25 @@ describe("opencodeAdapter.write", () => { expect(notes.join("\n")).toContain(configPath(home)); }); + test("known models carry limits and reasoning; unknown ids stay minimal", () => { + const home = tempHome(); + opencodeAdapter.write(home, { + baseUrl: BASE_URL, + key: KEY, + availableModels: ["gpt-4o", "deepseek-v4-pro", "totally-new-model"], + }); + const models = readJson(configPath(home)).provider.apiflux.models; + // Without limit.context opencode assumes a 128K-ish default for every model. + expect(models["gpt-4o"]).toEqual({ + name: "gpt-4o", + reasoning: false, + limit: { context: 128_000, output: 16_384 }, + }); + expect(models["deepseek-v4-pro"].reasoning).toBe(true); + expect(models["deepseek-v4-pro"].limit.context).toBe(1_000_000); + expect(models["totally-new-model"]).toEqual({ name: "totally-new-model" }); + }); + test("chosen model sets root model as apiflux/", () => { const home = tempHome(); opencodeAdapter.write(home, { diff --git a/src/adapters/opencode.ts b/src/adapters/opencode.ts index 38076b2..8750a97 100644 --- a/src/adapters/opencode.ts +++ b/src/adapters/opencode.ts @@ -1,9 +1,22 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { withV1 } from "../endpoint"; +import { getModelCapabilities } from "../model-catalog"; import type { Adapter, AdapterInput } from "./types"; import { backupOnce } from "./backup"; +// models.dev schema: without limit.context opencode assumes a generic ~128K +// window for every model; unknown gateway ids keep the minimal shape. +function opencodeModel(id: string): Record { + const caps = getModelCapabilities(id); + if (!caps) return { name: id }; + return { + name: id, + reasoning: caps.reasoning, + limit: { context: caps.contextWindow, output: caps.maxTokens }, + }; +} + // opencode resolves both dirs via xdg-basedir (packages/core/src/global.ts), // so the adapter must honor the same env overrides. function configDir(home: string): string { @@ -83,7 +96,7 @@ export const opencodeAdapter: Adapter = { name: "ApiFlux", // The key never goes into opencode.json; it lives in auth.json below. options: { baseURL: withV1(input.baseUrl) }, - models: Object.fromEntries(modelIds.map((id) => [id, { name: id }])), + models: Object.fromEntries(modelIds.map((id) => [id, opencodeModel(id)])), }, }; if (input.model !== undefined) { diff --git a/src/adapters/pi.test.ts b/src/adapters/pi.test.ts index 45b6d5f..118bc89 100644 --- a/src/adapters/pi.test.ts +++ b/src/adapters/pi.test.ts @@ -93,6 +93,27 @@ describe("piAdapter.write", () => { expect(settings.theme).toBe("dark"); }); + test("known models carry capability metadata; unknown ids stay bare", () => { + const home = tempHome(); + piAdapter.write(home, { + baseUrl: BASE_URL, + key: KEY, + availableModels: ["qwen3.8-max", "gpt-4o", "totally-new-model"], + }); + const entries = readJson(join(agentDir(home), "models.json")).providers.apiflux.models; + const byId = Object.fromEntries(entries.map((m: any) => [m.id, m])); + // Reasoning metadata is what makes Pi's thinking-level selector work (issue #7). + expect(byId["qwen3.8-max"].reasoning).toBe(true); + expect(byId["qwen3.8-max"].compat.thinkingFormat).toBe("qwen"); + expect(byId["qwen3.8-max"].thinkingLevelMap.high).toBe("high"); + // Real limits replace Pi's 128K-style fallbacks. + expect(byId["gpt-4o"].reasoning).toBe(false); + expect(byId["gpt-4o"].contextWindow).toBe(128_000); + expect(byId["gpt-4o"].maxTokens).toBe(16_384); + // Gateway models we don't know yet must still be selectable. + expect(byId["totally-new-model"]).toEqual({ id: "totally-new-model" }); + }); + test("without availableModels falls back to the chosen model only", () => { const home = tempHome(); piAdapter.write(home, { baseUrl: BASE_URL, key: KEY, model: "kimi-k2.6" }); diff --git a/src/adapters/pi.ts b/src/adapters/pi.ts index 0cff388..703d887 100644 --- a/src/adapters/pi.ts +++ b/src/adapters/pi.ts @@ -1,6 +1,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { withV1 } from "../endpoint"; +import { getModelCapabilities } from "../model-catalog"; import type { Adapter, AdapterInput } from "./types"; import { backupOnce } from "./backup"; @@ -38,8 +39,9 @@ function apifluxProvider(input: AdapterInput): Record { name: "ApiFlux", baseUrl: withV1(input.baseUrl), api: "openai-completions", - // Only `id` is required; Pi treats all model metadata as optional. - models: modelIds.map((id) => ({ id })), + // Bare ids make Pi treat every model as non-reasoning and silently drop + // thinking-level changes (issue #7); unknown ids still fall back to bare. + models: modelIds.map((id) => ({ id, ...getModelCapabilities(id) })), }; } diff --git a/src/model-catalog.test.ts b/src/model-catalog.test.ts new file mode 100644 index 0000000..06be8ad --- /dev/null +++ b/src/model-catalog.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { getModelCapabilities, modelCatalog } from "./model-catalog"; + +// The current production model list (gateway /v1/models, 2026-08-03). The +// catalog must cover every model we sell; new gateway models without an entry +// fall back to bare ids, which silently disables thinking control in Pi. +const LIVE_MODEL_IDS = [ + "claude-fable-5", + "claude-haiku-4-5", + "claude-opus-4-6", + "claude-opus-4-7", + "claude-opus-4-8", + "claude-opus-5", + "claude-sonnet-4-6", + "claude-sonnet-5", + "deepseek-v4-flash", + "deepseek-v4-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + "gemini-2.5-pro", + "gemini-3-flash-preview", + "gemini-3.1-flash-lite", + "gemini-3.1-pro-preview", + "gemini-3.5-flash", + "gemini-3.5-flash-lite", + "glm-5.2", + "gpt-4o", + "gpt-4o-mini", + "gpt-5", + "gpt-5-mini", + "gpt-5.2", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.5", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "kimi-k2.6", + "kimi-k2.7-code", + "kimi-k3", + "qwen3.8-max", +]; + +const PI_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); + +describe("modelCatalog coverage", () => { + test("covers every model currently sold on the gateway", () => { + const missing = LIVE_MODEL_IDS.filter((id) => getModelCapabilities(id) === undefined); + expect(missing).toEqual([]); + }); + + test("unknown ids return undefined", () => { + expect(getModelCapabilities("totally-new-model")).toBeUndefined(); + }); +}); + +describe("modelCatalog invariants", () => { + test("every entry has plausible context/output limits", () => { + for (const [id, caps] of Object.entries(modelCatalog)) { + expect(caps.contextWindow, id).toBeGreaterThanOrEqual(8192); + expect(caps.maxTokens, id).toBeGreaterThanOrEqual(4096); + expect(caps.maxTokens, id).toBeLessThanOrEqual(caps.contextWindow); + } + }); + + test("thinking metadata only appears on reasoning models", () => { + for (const [id, caps] of Object.entries(modelCatalog)) { + if (!caps.reasoning) { + expect(caps.thinkingLevelMap, id).toBeUndefined(); + expect(caps.compat?.thinkingFormat, id).toBeUndefined(); + } + } + }); + + test("thinkingLevelMap keys are valid Pi levels", () => { + for (const [id, caps] of Object.entries(modelCatalog)) { + for (const level of Object.keys(caps.thinkingLevelMap ?? {})) { + expect(PI_LEVELS.has(level), `${id}: ${level}`).toBe(true); + } + } + }); +}); + +describe("family-specific entries", () => { + test("qwen3.8-max matches the locally verified fix from issue #7", () => { + expect(getModelCapabilities("qwen3.8-max")).toMatchObject({ + reasoning: true, + compat: { supportsReasoningEffort: true, thinkingFormat: "qwen" }, + thinkingLevelMap: { + minimal: null, + low: "low", + medium: "medium", + high: "high", + xhigh: "high", + max: "high", + }, + }); + }); + + test("claude models clamp levels to the gateway's low/medium/high mapping", () => { + const caps = getModelCapabilities("claude-sonnet-5"); + expect(caps?.reasoning).toBe(true); + expect(caps?.contextWindow).toBe(1_000_000); + // new-api's claude channel only maps low/medium/high to thinking budgets; + // anything else must collapse onto those levels or thinking silently stays off. + expect(caps?.thinkingLevelMap).toMatchObject({ xhigh: "high", max: "high" }); + expect(caps?.compat?.thinkingFormat).toBeUndefined(); + }); + + test("gpt-4o is a non-reasoning model with its real 128K window", () => { + expect(getModelCapabilities("gpt-4o")).toEqual({ + reasoning: false, + contextWindow: 128_000, + maxTokens: 16_384, + }); + }); + + test("deepseek v4 uses the deepseek thinking format", () => { + const caps = getModelCapabilities("deepseek-v4-pro"); + expect(caps?.compat?.thinkingFormat).toBe("deepseek"); + expect(caps?.maxTokens).toBe(384_000); + }); + + test("kimi-k3 supports reasoning effort, kimi-k2.6 does not", () => { + expect(getModelCapabilities("kimi-k3")?.compat?.supportsReasoningEffort).toBe(true); + expect(getModelCapabilities("kimi-k2.6")?.compat?.supportsReasoningEffort).toBe(false); + }); + + test("glm-5.2 uses the zai thinking format", () => { + expect(getModelCapabilities("glm-5.2")?.compat?.thinkingFormat).toBe("zai"); + }); +}); diff --git a/src/model-catalog.ts b/src/model-catalog.ts new file mode 100644 index 0000000..23be13d --- /dev/null +++ b/src/model-catalog.ts @@ -0,0 +1,213 @@ +/** + * Capability metadata for every model sold on the ApiFlux gateway, keyed by + * gateway model id. Harnesses that only get bare ids assume "non-reasoning, + * ~128K context" defaults, which silently breaks thinking-level control and + * misreports context windows (apiflux-cli issue #7). + * + * Values mirror Pi's official models catalog (pi.dev) for each family, then + * adjusted for how the ApiFlux gateway actually relays thinking controls: + * - Claude channel maps `reasoning_effort` low/medium/high to thinking + * budgets and ignores every other level, so higher levels collapse to high. + * - Gemini channel maps `reasoning_effort` minimal/low/medium/high to a + * thinking-budget percentage; higher levels collapse to high. + * - OpenAI/DeepSeek/Kimi/GLM/Qwen bodies pass through, so their entries keep + * the upstream-native format and level maps. + * + * `compat` and `thinkingLevelMap` follow Pi's schema (the richest consumer); + * other adapters only read the neutral fields. + */ + +export interface ModelCapabilities { + reasoning: boolean; + contextWindow: number; + maxTokens: number; + compat?: Record; + thinkingLevelMap?: Record; +} + +type LevelMap = Record; + +// Gateway collapses anything beyond low/medium/high onto high (budget-mapped). +const GATEWAY_EFFORT_LEVELS: LevelMap = { + minimal: null, + low: "low", + medium: "medium", + high: "high", + xhigh: "high", + max: "high", +}; + +const GEMINI_EFFORT_LEVELS: LevelMap = { + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + xhigh: "high", + max: "high", +}; + +function claude(contextWindow: number, maxTokens: number): ModelCapabilities { + return { reasoning: true, contextWindow, maxTokens, thinkingLevelMap: GATEWAY_EFFORT_LEVELS }; +} + +function gemini(): ModelCapabilities { + return { + reasoning: true, + contextWindow: 1_048_576, + maxTokens: 65_536, + thinkingLevelMap: GEMINI_EFFORT_LEVELS, + }; +} + +function gpt(contextWindow: number, thinkingLevelMap: LevelMap): ModelCapabilities { + return { reasoning: true, contextWindow, maxTokens: 128_000, thinkingLevelMap }; +} + +const GPT5_LEVELS: LevelMap = { + off: null, + minimal: "minimal", + low: "low", + medium: "medium", + high: "high", + xhigh: null, + max: null, +}; + +const GPT54_LEVELS: LevelMap = { + off: "none", + minimal: null, + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: null, +}; + +const GPT56_LEVELS: LevelMap = { ...GPT54_LEVELS, max: "max" }; + +function deepseekV4(): ModelCapabilities { + return { + reasoning: true, + contextWindow: 1_000_000, + maxTokens: 384_000, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: true, + requiresReasoningContentOnAssistantMessages: true, + thinkingFormat: "deepseek", + }, + thinkingLevelMap: { minimal: null, low: null, medium: null, high: "high", max: "max" }, + }; +} + +function kimi(overrides: Partial): ModelCapabilities { + return { + reasoning: true, + contextWindow: 262_144, + maxTokens: 262_144, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: false, + supportsStrictMode: false, + maxTokensField: "max_tokens", + thinkingFormat: "deepseek", + }, + ...overrides, + }; +} + +export const modelCatalog: Record = { + // Anthropic + "claude-fable-5": claude(1_000_000, 128_000), + "claude-haiku-4-5": claude(200_000, 64_000), + "claude-opus-4-6": claude(1_000_000, 128_000), + "claude-opus-4-7": claude(1_000_000, 128_000), + "claude-opus-4-8": claude(1_000_000, 128_000), + "claude-opus-5": claude(1_000_000, 128_000), + "claude-sonnet-4-6": claude(1_000_000, 128_000), + "claude-sonnet-5": claude(1_000_000, 128_000), + + // Google + "gemini-2.5-flash": gemini(), + "gemini-2.5-flash-lite": gemini(), + "gemini-2.5-pro": gemini(), + "gemini-3-flash-preview": gemini(), + "gemini-3.1-flash-lite": gemini(), + "gemini-3.1-pro-preview": gemini(), + "gemini-3.5-flash": gemini(), + "gemini-3.5-flash-lite": gemini(), + + // OpenAI + "gpt-4o": { reasoning: false, contextWindow: 128_000, maxTokens: 16_384 }, + "gpt-4o-mini": { reasoning: false, contextWindow: 128_000, maxTokens: 16_384 }, + "gpt-5": gpt(400_000, GPT5_LEVELS), + "gpt-5-mini": gpt(400_000, GPT5_LEVELS), + "gpt-5.2": gpt(400_000, GPT54_LEVELS), + "gpt-5.4": gpt(272_000, GPT54_LEVELS), + "gpt-5.4-mini": gpt(400_000, GPT54_LEVELS), + "gpt-5.4-nano": gpt(400_000, GPT54_LEVELS), + "gpt-5.5": gpt(272_000, GPT54_LEVELS), + "gpt-5.6-luna": gpt(272_000, GPT56_LEVELS), + "gpt-5.6-sol": gpt(272_000, GPT56_LEVELS), + "gpt-5.6-terra": gpt(272_000, GPT56_LEVELS), + + // DeepSeek + "deepseek-v4-flash": deepseekV4(), + "deepseek-v4-pro": deepseekV4(), + + // MoonshotAI + "kimi-k2.6": kimi({}), + "kimi-k2.7-code": kimi({ thinkingLevelMap: { off: null } }), + "kimi-k3": kimi({ + contextWindow: 1_048_576, + maxTokens: 131_072, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: true, + supportsStrictMode: false, + maxTokensField: "max_tokens", + requiresReasoningContentOnAssistantMessages: true, + thinkingFormat: "openai", + }, + thinkingLevelMap: { + off: null, + minimal: null, + low: "low", + medium: null, + high: "high", + xhigh: null, + max: "max", + }, + }), + + // Z.ai + "glm-5.2": { + reasoning: true, + contextWindow: 1_000_000, + maxTokens: 131_072, + compat: { + supportsStore: false, + supportsDeveloperRole: false, + supportsReasoningEffort: true, + maxTokensField: "max_tokens", + thinkingFormat: "zai", + }, + thinkingLevelMap: { minimal: null, low: "high", medium: "high", high: "high", max: "max" }, + }, + + // Alibaba Qwen — matches the fix verified against production in issue #7. + "qwen3.8-max": { + reasoning: true, + contextWindow: 1_000_000, + maxTokens: 131_072, + compat: { supportsReasoningEffort: true, thinkingFormat: "qwen" }, + thinkingLevelMap: GATEWAY_EFFORT_LEVELS, + }, +}; + +export function getModelCapabilities(id: string): ModelCapabilities | undefined { + return modelCatalog[id]; +}