Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/adapters/openclaw.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
19 changes: 17 additions & 2 deletions src/adapters/openclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
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 {
Expand Down Expand Up @@ -73,8 +89,7 @@ function apifluxProvider(input: AdapterInput): Record<string, unknown> {
// ${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),
};
}

Expand Down
19 changes: 19 additions & 0 deletions src/adapters/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>", () => {
const home = tempHome();
opencodeAdapter.write(home, {
Expand Down
15 changes: 14 additions & 1 deletion src/adapters/opencode.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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 {
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions src/adapters/pi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down
6 changes: 4 additions & 2 deletions src/adapters/pi.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -38,8 +39,9 @@ function apifluxProvider(input: AdapterInput): Record<string, unknown> {
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) })),
};
}

Expand Down
133 changes: 133 additions & 0 deletions src/model-catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading