feat(models): curate default model picker to current-generation catalogs (#113594)

* feat(models): curate default picker visibility

* chore(models): curate provider catalogs

* test(openai): generalize static-catalog transport guard after row curation (#91710)

* fix(models): propagate catalog lifecycle metadata

* test(models): keep picker suite within line budget
This commit is contained in:
Peter Steinberger
2026-07-25 03:22:42 -07:00
committed by GitHub
parent 1d43d602c5
commit 91b5b43d78
26 changed files with 358 additions and 612 deletions

View File

@@ -80,7 +80,7 @@ Other selection rules:
- Changing `agents.defaults.model.primary` does not rewrite existing session pins. If status reports `This session is pinned to X; config primary Y will apply to new/unpinned sessions.`, run `/model default` to clear the pin.
- CLI default-model and allowlist pickers respect `models.mode: "replace"` by listing only `models.providers.*.models` instead of the full built-in catalog.
- The Control UI model picker asks the Gateway for its configured model view. An explicit `modelPolicy.allow` filters it, including trailing prefix wildcard entries; otherwise it shows configured models plus providers with usable auth. The full built-in catalog is reserved for explicit browse views (`models.list` with `view: "all"`, or `openclaw models list --all`).
- The Control UI model picker asks the Gateway for its configured model view. An explicit `modelPolicy.allow` filters it, including trailing prefix wildcard entries; otherwise it shows configured models plus providers with usable auth. Default and configured picker views hide catalog rows marked `deprecated` or `disabled` unless that exact model is configured as a primary, fallback, utility/tool model, alias/settings key, or exact policy entry. Hidden rows remain selectable by exact `provider/model` ref. The full built-in catalog, including hidden rows, is reserved for explicit browse views (`models.list` with `view: "all"`, or `openclaw models list --all`).
- Provider inventory UIs use `models.list` with `view: "provider-config"` to show source-authored `models.providers.*.models` rows without applying picker allowlists.
Full mechanics: [Model failover](/concepts/model-failover).

View File

@@ -543,7 +543,7 @@ OpenClaw supports Anthropic's prompt caching feature for API-key auth.
"anthropic/claude-opus-5": {},
"anthropic/claude-sonnet-5": {},
"anthropic/claude-mythos-5": {},
"anthropic/claude-opus-4-6": {},
"anthropic/claude-opus-4-8": {},
},
},
},

View File

@@ -76,16 +76,16 @@ available to that process (for example, in `~/.openclaw/.env` or via
## Built-in catalog
| Model ref | Name | Input | Context | Max output | Notes |
| ---------------------------- | ----------------- | ----- | --------- | ---------- | --------------------------------------------------- |
| `deepseek/deepseek-v4-flash` | DeepSeek V4 Flash | text | 1,000,000 | 384,000 | Default model; V4 thinking-capable surface |
| `deepseek/deepseek-v4-pro` | DeepSeek V4 Pro | text | 1,000,000 | 384,000 | V4 thinking-capable surface |
| `deepseek/deepseek-chat` | DeepSeek Chat | text | 1,000,000 | 384,000 | Deprecated V4 Flash non-thinking compatibility name |
| `deepseek/deepseek-reasoner` | DeepSeek Reasoner | text | 1,000,000 | 384,000 | Deprecated V4 Flash thinking compatibility name |
| Model ref | Name | Input | Context | Max output | Notes |
| ---------------------------- | ----------------- | ----- | --------- | ---------- | --------------------------------------------- |
| `deepseek/deepseek-v4-flash` | DeepSeek V4 Flash | text | 1,000,000 | 384,000 | Default model; V4 thinking-capable surface |
| `deepseek/deepseek-v4-pro` | DeepSeek V4 Pro | text | 1,000,000 | 384,000 | V4 thinking-capable surface |
| `deepseek/deepseek-chat` | DeepSeek Chat | text | 1,000,000 | 384,000 | Hidden deprecated V4 Flash compatibility name |
| `deepseek/deepseek-reasoner` | DeepSeek Reasoner | text | 1,000,000 | 384,000 | Hidden deprecated V4 Flash thinking name |
<Warning>
DeepSeek will retire `deepseek-chat` and `deepseek-reasoner` on July 24, 2026
at 15:59 UTC. They currently route to DeepSeek V4 Flash in non-thinking and
DeepSeek retired `deepseek-chat` and `deepseek-reasoner` on July 24, 2026 at
15:59 UTC. They route to DeepSeek V4 Flash in non-thinking and
thinking mode, respectively. Move configured model refs to
`deepseek/deepseek-v4-flash` or `deepseek/deepseek-v4-pro` before the cutoff.
</Warning>

View File

@@ -21,20 +21,16 @@ Moonshot and Kimi Coding are **separate providers**, each shipped as a separate
| Model ref | Name | Reasoning | Input | Context | Max output |
| ----------------------------------- | ------------------------ | ---------- | ----------- | --------- | ---------- |
| `moonshot/kimi-k2.6` | Kimi K2.6 | No | text, image | 262,144 | 262,144 |
| `moonshot/kimi-k3` | Kimi K3 | Always max | text, image | 1,048,576 | 1,048,576 |
| `moonshot/kimi-k2.7-code` | Kimi K2.7 Code | Always on | text, image | 262,144 | 262,144 |
| `moonshot/kimi-k2.7-code-highspeed` | Kimi K2.7 Code HighSpeed | Always on | text, image | 262,144 | 262,144 |
| `moonshot/kimi-k2.5` | Kimi K2.5 | No | text, image | 262,144 | 262,144 |
[//]: # "moonshot-kimi-k2-ids:end"
Catalog cost estimates use Moonshot's published pay-as-you-go rates. Check the
live vendor pages for [Kimi K3](https://platform.kimi.ai/docs/pricing/chat-k3),
[Kimi K2.7 Code](https://platform.kimi.ai/docs/pricing/chat-k27-code),
[Kimi K2.6](https://platform.kimi.ai/docs/pricing/chat-k26), and
[Kimi K2.5](https://platform.kimi.ai/docs/pricing/chat-k25) before making cost
decisions.
live vendor pages for [Kimi K3](https://platform.kimi.ai/docs/pricing/chat-k3)
and [Kimi K2.7 Code](https://platform.kimi.ai/docs/pricing/chat-k27-code)
before making cost decisions.
Kimi K3 always reasons at `reasoning_effort: "max"`. OpenClaw exposes only
`/think max`, omits the K2-only `thinking` field, and removes sampling
@@ -119,14 +115,12 @@ onboarding.
env: { MOONSHOT_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "moonshot/kimi-k2.6" },
model: { primary: "moonshot/kimi-k3" },
models: {
// moonshot-kimi-k2-aliases:start
"moonshot/kimi-k2.6": { alias: "Kimi K2.6" },
"moonshot/kimi-k3": { alias: "Kimi K3" },
"moonshot/kimi-k2.7-code": { alias: "Kimi K2.7 Code" },
"moonshot/kimi-k2.7-code-highspeed": { alias: "Kimi K2.7 Code HighSpeed" },
"moonshot/kimi-k2.5": { alias: "Kimi K2.5" },
// moonshot-kimi-k2-aliases:end
},
},
@@ -140,15 +134,6 @@ onboarding.
api: "openai-completions",
models: [
// moonshot-kimi-k2-models:start
{
id: "kimi-k2.6",
name: "Kimi K2.6",
reasoning: false,
input: ["text", "image"],
cost: { input: 0.95, output: 4, cacheRead: 0.16, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 262144,
},
{
id: "kimi-k3",
name: "Kimi K3",
@@ -185,15 +170,6 @@ onboarding.
contextWindow: 262144,
maxTokens: 262144,
},
{
id: "kimi-k2.5",
name: "Kimi K2.5",
reasoning: false,
input: ["text", "image"],
cost: { input: 0.6, output: 3, cacheRead: 0.1, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 262144,
},
// moonshot-kimi-k2-models:end
],
},

View File

@@ -165,22 +165,10 @@ openclaw models list --all --provider zai
The manifest-backed catalog currently includes:
| Model ref | Notes |
| -------------------- | ------------------------------- |
| `zai/glm-5.2` | Coding Plan default; 1M context |
| `zai/glm-5.1` | General API default |
| `zai/glm-5` | |
| `zai/glm-5-turbo` | |
| `zai/glm-5v-turbo` | |
| `zai/glm-4.7` | |
| `zai/glm-4.7-flash` | |
| `zai/glm-4.7-flashx` | |
| `zai/glm-4.6` | |
| `zai/glm-4.6v` | |
| `zai/glm-4.5` | |
| `zai/glm-4.5-air` | |
| `zai/glm-4.5-flash` | |
| `zai/glm-4.5v` | |
| Model ref | Notes |
| ------------- | ------------------------------------------------- |
| `zai/glm-5.2` | Coding Plan default; 1M context |
| `zai/glm-5.1` | Deprecated; hidden unless configured; use GLM-5.2 |
Catalog token-cost metadata follows Z.AI's current
[pay-as-you-go pricing](https://docs.z.ai/guides/overview/pricing). Coding Plan
@@ -188,7 +176,7 @@ subscriptions use plan quota instead of per-token billing; see the live
[subscription page](https://z.ai/subscribe) for plan pricing and availability.
<Tip>
GLM models are available as `zai/<model>` (example: `zai/glm-5`).
GLM models are available as `zai/<model>` (example: `zai/glm-5.2`).
</Tip>
<Note>

View File

@@ -53,6 +53,8 @@
{
"id": "claude-opus-4-8",
"name": "Claude Opus 4.8 (Claude CLI)",
"status": "deprecated",
"replacedBy": "claude-opus-5",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
@@ -60,39 +62,6 @@
},
"contextWindow": 200000,
"maxTokens": 128000
},
{
"id": "claude-opus-4-7",
"name": "Claude Opus 4.7 (Claude CLI)",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 2576, "preferredSidePx": 2576, "tokenMode": "provider" }
},
"contextWindow": 200000,
"maxTokens": 128000
},
{
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6 (Claude CLI)",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
},
"contextWindow": 200000,
"maxTokens": 128000
},
{
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6 (Claude CLI)",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
},
"contextWindow": 200000,
"maxTokens": 128000
}
]
},
@@ -172,17 +141,8 @@
{
"id": "claude-opus-4-8",
"name": "Claude Opus 4.8",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 2576, "preferredSidePx": 2576, "tokenMode": "provider" }
},
"contextWindow": 1000000,
"maxTokens": 128000
},
{
"id": "claude-opus-4-7",
"name": "Claude Opus 4.7",
"status": "deprecated",
"replacedBy": "claude-opus-5",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
@@ -201,39 +161,6 @@
},
"contextWindow": 200000,
"maxTokens": 64000
},
{
"id": "claude-haiku-4-5-20251001",
"name": "Claude Haiku 4.5",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
},
"contextWindow": 200000,
"maxTokens": 64000
},
{
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
},
"contextWindow": 1000000,
"maxTokens": 128000
},
{
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 1568, "preferredSidePx": 1568, "tokenMode": "provider" }
},
"contextWindow": 1000000,
"maxTokens": 128000
}
]
}

View File

@@ -23,6 +23,8 @@ type AnthropicCatalogModel = {
cacheWrite?: number;
};
thinkingLevelMap?: Record<string, string | null>;
status?: string;
replacedBy?: string;
};
type AnthropicManifest = {
@@ -97,47 +99,42 @@ describe("Anthropic plugin manifest", () => {
});
});
it("declares direct API limits without overstating bare Claude CLI context", () => {
it("declares Opus 4.8 limits without overstating bare Claude CLI context", () => {
const models = manifest.modelCatalog?.providers?.anthropic?.models ?? [];
for (const id of ["claude-opus-4-7", "claude-sonnet-4-6", "claude-opus-4-6"]) {
expect(models.find((model) => model.id === id)).toMatchObject({
contextWindow: 1_000_000,
maxTokens: 128_000,
});
}
expect(models.find((model) => model.id === "claude-opus-4-8")).toMatchObject({
contextWindow: 1_000_000,
maxTokens: 128_000,
status: "deprecated",
replacedBy: "claude-opus-5",
});
const cliModels = manifest.modelCatalog?.providers?.["claude-cli"]?.models ?? [];
for (const id of ["claude-opus-4-7", "claude-sonnet-4-6", "claude-opus-4-6"]) {
expect(cliModels.find((model) => model.id === id)).toMatchObject({
contextWindow: 200_000,
maxTokens: 128_000,
});
}
expect(cliModels.find((model) => model.id === "claude-opus-4-8")).toMatchObject({
contextWindow: 200_000,
maxTokens: 128_000,
status: "deprecated",
replacedBy: "claude-opus-5",
});
});
it("resolves both official Claude Haiku 4.5 API identifiers from the static catalog", () => {
it("keeps only the dateless Claude Haiku 4.5 identifier in the static catalog", () => {
expect(manifest.modelCatalog?.discovery?.anthropic).toBe("refreshable");
const models = manifest.modelCatalog?.providers?.anthropic?.models ?? [];
for (const id of ["claude-haiku-4-5", "claude-haiku-4-5-20251001"]) {
expect(models.find((model) => model.id === id)).toEqual({
id,
name: "Claude Haiku 4.5",
reasoning: true,
input: ["text", "image"],
mediaInput: {
image: {
maxSidePx: 1568,
preferredSidePx: 1568,
tokenMode: "provider",
},
expect(models.find((model) => model.id === "claude-haiku-4-5")).toEqual({
id: "claude-haiku-4-5",
name: "Claude Haiku 4.5",
reasoning: true,
input: ["text", "image"],
mediaInput: {
image: {
maxSidePx: 1568,
preferredSidePx: 1568,
tokenMode: "provider",
},
contextWindow: 200000,
maxTokens: 64000,
});
}
},
contextWindow: 200000,
maxTokens: 64000,
});
expect(models.find((model) => model.id === "claude-haiku-4-5-20251001")).toBeUndefined();
});
});

View File

@@ -70,6 +70,8 @@
{
"id": "deepseek-chat",
"name": "DeepSeek Chat",
"status": "deprecated",
"replacedBy": "deepseek-v4-flash",
"input": ["text"],
"contextWindow": 1000000,
"maxTokens": 384000,
@@ -87,6 +89,8 @@
{
"id": "deepseek-reasoner",
"name": "DeepSeek Reasoner",
"status": "deprecated",
"replacedBy": "deepseek-v4-flash",
"reasoning": true,
"input": ["text"],
"contextWindow": 1000000,

View File

@@ -50,19 +50,6 @@
"baseUrl": "https://api.moonshot.ai/v1",
"api": "openai-completions",
"models": [
{
"id": "kimi-k2.6",
"name": "Kimi K2.6",
"input": ["text", "image"],
"contextWindow": 262144,
"maxTokens": 262144,
"cost": {
"input": 0.95,
"output": 4,
"cacheRead": 0.16,
"cacheWrite": 0
}
},
{
"id": "kimi-k3",
"name": "Kimi K3",
@@ -117,19 +104,6 @@
"cacheRead": 0.38,
"cacheWrite": 0
}
},
{
"id": "kimi-k2.5",
"name": "Kimi K2.5",
"input": ["text", "image"],
"contextWindow": 262144,
"maxTokens": 262144,
"cost": {
"input": 0.6,
"output": 3,
"cacheRead": 0.1,
"cacheWrite": 0
}
}
]
}

View File

@@ -40,18 +40,10 @@ describe("moonshot provider catalog", () => {
expect(provider.baseUrl).toBe(MOONSHOT_BASE_URL);
expect(provider.api).toBe("openai-completions");
expect(provider.models.map((model) => model.id)).toEqual([
"kimi-k2.6",
"kimi-k3",
"kimi-k2.7-code",
"kimi-k2.7-code-highspeed",
"kimi-k2.5",
]);
expect(requireMoonshotModel(provider, "kimi-k2.6").cost).toEqual({
input: 0.95,
output: 4,
cacheRead: 0.16,
cacheWrite: 0,
});
expect(requireMoonshotModel(provider, "kimi-k3")).toMatchObject({
reasoning: true,
thinkingLevelMap: {
@@ -101,12 +93,6 @@ describe("moonshot provider catalog", () => {
cacheWrite: 0,
},
});
expect(requireMoonshotModel(provider, "kimi-k2.5").cost).toEqual({
input: 0.6,
output: 3,
cacheRead: 0.1,
cacheWrite: 0,
});
});
it("opts native Moonshot baseUrls into streaming usage only inside the extension", () => {

View File

@@ -348,14 +348,17 @@ describe("buildOpenAIProvider", () => {
expect(manifest.modelCatalog.discovery.openai).toBe("runtime");
});
it("does not hardcode chatgpt-responses transport on gpt-5.3-codex catalog entry (#91710)", () => {
it("does not hardcode transport routing on static catalog entries (#91710)", () => {
const openaiModels = manifest.modelCatalog.providers.openai.models as Array<
Record<string, unknown>
>;
const codexEntry = openaiModels.find((m) => m.id === "gpt-5.3-codex");
expect(codexEntry).toBeDefined();
expect(codexEntry?.api).toBeUndefined();
expect(codexEntry?.baseUrl).toBeUndefined();
expect(openaiModels.length).toBeGreaterThan(0);
// Transport selection is runtime-owned; a manifest row pinning api/baseUrl
// would bypass route policy (the original #91710 regression).
for (const entry of openaiModels) {
expect(entry.api, `catalog row ${String(entry.id)} must not pin api`).toBeUndefined();
expect(entry.baseUrl, `catalog row ${String(entry.id)} must not pin baseUrl`).toBeUndefined();
}
});
it("keeps a network-free OpenAI static catalog", async () => {

View File

@@ -40,72 +40,6 @@
"api": "openai-responses",
"defaultUtilityModel": "gpt-5.6-luna",
"models": [
{
"id": "gpt-5.3-chat-latest",
"name": "GPT-5.3 Chat (latest)",
"reasoning": false,
"input": ["text", "image"],
"contextWindow": 128000,
"maxTokens": 16384,
"cost": { "input": 1.75, "output": 14, "cacheRead": 0.175, "cacheWrite": 0 }
},
{
"id": "gpt-5.3-codex",
"name": "GPT-5.3 Codex",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 400000,
"maxTokens": 128000,
"cost": { "input": 1.75, "output": 14, "cacheRead": 0.175, "cacheWrite": 0 }
},
{
"id": "gpt-5.4",
"name": "GPT-5.4",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 2048, "preferredSidePx": 2048, "tokenMode": "detail" }
},
"contextWindow": 272000,
"maxTokens": 128000,
"cost": { "input": 2.5, "output": 15, "cacheRead": 0.25, "cacheWrite": 0 }
},
{
"id": "gpt-5.4-mini",
"name": "GPT-5.4 mini",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 2048, "preferredSidePx": 2048, "tokenMode": "detail" }
},
"contextWindow": 400000,
"maxTokens": 128000,
"cost": { "input": 0.75, "output": 4.5, "cacheRead": 0.075, "cacheWrite": 0 }
},
{
"id": "gpt-5.4-nano",
"name": "GPT-5.4 nano",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 2048, "preferredSidePx": 2048, "tokenMode": "detail" }
},
"contextWindow": 400000,
"maxTokens": 128000,
"cost": { "input": 0.2, "output": 1.25, "cacheRead": 0.02, "cacheWrite": 0 }
},
{
"id": "gpt-5.4-pro",
"name": "GPT-5.4 Pro",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
"image": { "maxSidePx": 2048, "preferredSidePx": 2048, "tokenMode": "detail" }
},
"contextWindow": 1050000,
"maxTokens": 128000,
"cost": { "input": 30, "output": 180, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "gpt-5.6",
"name": "GPT-5.6",
@@ -173,6 +107,8 @@
{
"id": "gpt-5.5",
"name": "GPT-5.5",
"status": "deprecated",
"replacedBy": "gpt-5.6",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {
@@ -183,81 +119,11 @@
"maxTokens": 128000,
"cost": { "input": 5, "output": 30, "cacheRead": 0.5, "cacheWrite": 0 }
},
{
"id": "o1",
"name": "o1",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 200000,
"maxTokens": 100000,
"cost": { "input": 15, "output": 60, "cacheRead": 7.5, "cacheWrite": 0 }
},
{
"id": "o1-pro",
"name": "o1-pro",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 200000,
"maxTokens": 100000,
"cost": { "input": 150, "output": 600, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "o3",
"name": "o3",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 200000,
"maxTokens": 100000,
"cost": { "input": 2, "output": 8, "cacheRead": 0.5, "cacheWrite": 0 }
},
{
"id": "o3-deep-research",
"name": "o3-deep-research",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 200000,
"maxTokens": 100000,
"cost": { "input": 10, "output": 40, "cacheRead": 2.5, "cacheWrite": 0 }
},
{
"id": "o3-mini",
"name": "o3-mini",
"reasoning": true,
"input": ["text"],
"contextWindow": 200000,
"maxTokens": 100000,
"cost": { "input": 1.1, "output": 4.4, "cacheRead": 0.55, "cacheWrite": 0 }
},
{
"id": "o3-pro",
"name": "o3-pro",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 200000,
"maxTokens": 100000,
"cost": { "input": 20, "output": 80, "cacheRead": 0, "cacheWrite": 0 }
},
{
"id": "o4-mini",
"name": "o4-mini",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 200000,
"maxTokens": 100000,
"cost": { "input": 1.1, "output": 4.4, "cacheRead": 0.28, "cacheWrite": 0 }
},
{
"id": "o4-mini-deep-research",
"name": "o4-mini-deep-research",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 200000,
"maxTokens": 100000,
"cost": { "input": 2, "output": 8, "cacheRead": 0.5, "cacheWrite": 0 }
},
{
"id": "gpt-5.5-pro",
"name": "gpt-5.5-pro",
"status": "deprecated",
"replacedBy": "gpt-5.6",
"reasoning": true,
"input": ["text", "image"],
"mediaInput": {

View File

@@ -53,77 +53,4 @@ describe("zai model definitions", () => {
cost: { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
});
});
it("uses current OpenClaw metadata for the new GLM-5V Turbo model", () => {
expectZaiModelFields({
id: "glm-5v-turbo",
reasoning: true,
input: ["text", "image"],
contextWindow: 202800,
maxTokens: 131100,
cost: { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 },
});
});
it("uses current OpenClaw metadata for the GLM-5 model", () => {
expectZaiModelFields({
id: "glm-5",
reasoning: true,
input: ["text"],
contextWindow: 202800,
maxTokens: 131100,
cost: { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 },
});
});
it("publishes newer GLM 4.5/4.6 family metadata from OpenClaw", () => {
expectZaiModelFields({
id: "glm-4.6v",
input: ["text", "image"],
contextWindow: 128000,
maxTokens: 32768,
cost: { input: 0.3, output: 0.9, cacheRead: 0.05, cacheWrite: 0 },
});
expectZaiModelFields({
id: "glm-4.5-air",
input: ["text"],
contextWindow: 131072,
maxTokens: 98304,
cost: { input: 0.2, output: 1.1, cacheRead: 0.03, cacheWrite: 0 },
});
expectZaiModelFields({
id: "glm-4.5v",
input: ["text", "image"],
contextWindow: 64000,
maxTokens: 16384,
cost: { input: 0.6, output: 1.8, cacheRead: 0.11, cacheWrite: 0 },
});
});
it("keeps the remaining GLM 4.7/5 pricing and token limits aligned with OpenClaw", () => {
expectZaiModelFields({
id: "glm-4.7",
cost: { input: 0.6, output: 2.2, cacheRead: 0.11, cacheWrite: 0 },
contextWindow: 204800,
maxTokens: 131072,
});
expectZaiModelFields({
id: "glm-4.7-flash",
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 131072,
});
expectZaiModelFields({
id: "glm-4.7-flashx",
cost: { input: 0.07, output: 0.4, cacheRead: 0.01, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 128000,
});
expectZaiModelFields({
id: "glm-5-turbo",
contextWindow: 202800,
maxTokens: 131100,
cost: { input: 1.2, output: 4, cacheRead: 0.24, cacheWrite: 0 },
});
});
});

View File

@@ -28,22 +28,7 @@ describe("zai onboard", () => {
expect(defaultCfg.models?.providers?.zai?.baseUrl).toBe(ZAI_GLOBAL_BASE_URL);
expect(defaultCfg.models?.providers?.zai?.api).toBe("openai-completions");
const ids = defaultCfg.models?.providers?.zai?.models?.map((m) => m.id);
expect(ids).toEqual([
"glm-5.2",
"glm-5.1",
"glm-5",
"glm-5-turbo",
"glm-5v-turbo",
"glm-4.7",
"glm-4.7-flash",
"glm-4.7-flashx",
"glm-4.6",
"glm-4.6v",
"glm-4.5",
"glm-4.5-air",
"glm-4.5-flash",
"glm-4.5v",
]);
expect(ids).toEqual(["glm-5.2", "glm-5.1"]);
expect(
defaultCfg.models?.providers?.zai?.models?.find((model) => model.id === "glm-5.2"),
).toMatchObject({

View File

@@ -50,6 +50,8 @@
{
"id": "glm-5.1",
"name": "GLM-5.1",
"status": "deprecated",
"replacedBy": "glm-5.2",
"reasoning": true,
"input": ["text"],
"contextWindow": 202800,
@@ -60,174 +62,6 @@
"cacheRead": 0.26,
"cacheWrite": 0
}
},
{
"id": "glm-5",
"name": "GLM-5",
"reasoning": true,
"input": ["text"],
"contextWindow": 202800,
"maxTokens": 131100,
"cost": {
"input": 1,
"output": 3.2,
"cacheRead": 0.2,
"cacheWrite": 0
}
},
{
"id": "glm-5-turbo",
"name": "GLM-5 Turbo",
"reasoning": true,
"input": ["text"],
"contextWindow": 202800,
"maxTokens": 131100,
"cost": {
"input": 1.2,
"output": 4,
"cacheRead": 0.24,
"cacheWrite": 0
}
},
{
"id": "glm-5v-turbo",
"name": "GLM-5V Turbo",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 202800,
"maxTokens": 131100,
"cost": {
"input": 1.2,
"output": 4,
"cacheRead": 0.24,
"cacheWrite": 0
}
},
{
"id": "glm-4.7",
"name": "GLM-4.7",
"reasoning": true,
"input": ["text"],
"contextWindow": 204800,
"maxTokens": 131072,
"cost": {
"input": 0.6,
"output": 2.2,
"cacheRead": 0.11,
"cacheWrite": 0
}
},
{
"id": "glm-4.7-flash",
"name": "GLM-4.7 Flash",
"reasoning": true,
"input": ["text"],
"contextWindow": 200000,
"maxTokens": 131072,
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
},
{
"id": "glm-4.7-flashx",
"name": "GLM-4.7 FlashX",
"reasoning": true,
"input": ["text"],
"contextWindow": 200000,
"maxTokens": 128000,
"cost": {
"input": 0.07,
"output": 0.4,
"cacheRead": 0.01,
"cacheWrite": 0
}
},
{
"id": "glm-4.6",
"name": "GLM-4.6",
"reasoning": true,
"input": ["text"],
"contextWindow": 204800,
"maxTokens": 131072,
"cost": {
"input": 0.6,
"output": 2.2,
"cacheRead": 0.11,
"cacheWrite": 0
}
},
{
"id": "glm-4.6v",
"name": "GLM-4.6V",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 128000,
"maxTokens": 32768,
"cost": {
"input": 0.3,
"output": 0.9,
"cacheRead": 0.05,
"cacheWrite": 0
}
},
{
"id": "glm-4.5",
"name": "GLM-4.5",
"reasoning": true,
"input": ["text"],
"contextWindow": 131072,
"maxTokens": 98304,
"cost": {
"input": 0.6,
"output": 2.2,
"cacheRead": 0.11,
"cacheWrite": 0
}
},
{
"id": "glm-4.5-air",
"name": "GLM-4.5 Air",
"reasoning": true,
"input": ["text"],
"contextWindow": 131072,
"maxTokens": 98304,
"cost": {
"input": 0.2,
"output": 1.1,
"cacheRead": 0.03,
"cacheWrite": 0
}
},
{
"id": "glm-4.5-flash",
"name": "GLM-4.5 Flash",
"reasoning": true,
"input": ["text"],
"contextWindow": 131072,
"maxTokens": 98304,
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0
}
},
{
"id": "glm-4.5v",
"name": "GLM-4.5V",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 64000,
"maxTokens": 16384,
"cost": {
"input": 0.6,
"output": 1.8,
"cacheRead": 0.11,
"cacheWrite": 0
}
}
]
}

View File

@@ -86,12 +86,21 @@ function sameLogicalModel(
return policy.resolveIdentity(a)?.key === identity.key;
}
function logicalIdentity(entry: ModelCatalogEntry, id: string, name?: string): ModelCatalogEntry {
function logicalIdentity(
entry: ModelCatalogEntry,
id: string,
name?: string,
lifecycleEntry: ModelCatalogEntry = entry,
): ModelCatalogEntry {
return {
id,
name: name ?? id,
provider: entry.provider,
...(entry.alias ? { alias: entry.alias } : {}),
...(lifecycleEntry.status ? { status: lifecycleEntry.status } : {}),
...(lifecycleEntry.statusReason ? { statusReason: lifecycleEntry.statusReason } : {}),
...(lifecycleEntry.replaces ? { replaces: lifecycleEntry.replaces } : {}),
...(lifecycleEntry.replacedBy ? { replacedBy: lifecycleEntry.replacedBy } : {}),
};
}
@@ -158,7 +167,12 @@ export function projectModelCatalogEntryForRoute(params: {
policy,
catalog: params.catalog,
});
const projected = logicalIdentity(params.entry, identity.id, donor?.name ?? params.entry.name);
const projected = logicalIdentity(
params.entry,
identity.id,
donor?.name ?? params.entry.name,
donor ?? params.entry,
);
return applyLogicalOverrides(
{
...projected,

View File

@@ -39,6 +39,81 @@ describe("resolveLogicalVisibleModelCatalog", () => {
input: ["text"],
};
const evaluateAvailableEntry = async (entry: ModelCatalogEntry) =>
resolveLogicalModelCatalogEntryState({
entry,
evaluation: { availability: true, routeResolution: null },
routePolicy: openAIModelCatalogRoutePolicy,
});
it.each(["default", "configured"] as const)(
"hides deprecated and disabled rows from the %s picker view",
async (view) => {
const catalog: ModelCatalogEntry[] = [
{ provider: "demo", id: "current", name: "Current", status: "available" },
{ provider: "demo", id: "old", name: "Old", status: "deprecated" },
{ provider: "demo", id: "off", name: "Off", status: "disabled" },
];
const result = await resolveLogicalVisibleModelCatalog({
cfg: {} as OpenClawConfig,
catalog,
defaultProvider: "demo",
view,
routePolicy: openAIModelCatalogRoutePolicy,
evaluateEntry: evaluateAvailableEntry,
});
expect(result.map((entry) => entry.id)).toEqual(["current"]);
},
);
it("keeps deprecated and disabled rows in the all inventory", async () => {
const catalog: ModelCatalogEntry[] = [
{ provider: "demo", id: "old", name: "Old", status: "deprecated" },
{ provider: "demo", id: "off", name: "Off", status: "disabled" },
];
const result = await resolveLogicalVisibleModelCatalog({
cfg: {} as OpenClawConfig,
catalog,
defaultProvider: "demo",
view: "all",
routePolicy: openAIModelCatalogRoutePolicy,
evaluateEntry: evaluateAvailableEntry,
});
expect(result.map((entry) => entry.id)).toEqual(["off", "old"]);
});
it("keeps deprecated configured primary and alias-key rows visible", async () => {
const catalog: ModelCatalogEntry[] = [
{ provider: "demo", id: "primary", name: "Primary", status: "deprecated" },
{ provider: "demo", id: "alias-key", name: "Alias Key", status: "deprecated" },
{ provider: "demo", id: "hidden", name: "Hidden", status: "deprecated" },
];
const cfg = {
agents: {
defaults: {
model: { primary: "demo/primary" },
models: { "demo/alias-key": { alias: "legacy" } },
},
},
} as OpenClawConfig;
const result = await resolveLogicalVisibleModelCatalog({
cfg,
catalog,
defaultProvider: "demo",
defaultModel: "primary",
view: "configured",
routePolicy: openAIModelCatalogRoutePolicy,
evaluateEntry: evaluateAvailableEntry,
});
expect(result.map((entry) => entry.id)).toEqual(["alias-key", "primary"]);
});
it("dedupes physical routes after selected-route projection", async () => {
const catalog = [platform, chatGPT];
const result = await resolveLogicalVisibleModelCatalog({
@@ -73,6 +148,34 @@ describe("resolveLogicalVisibleModelCatalog", () => {
]);
});
it.each([
["deprecated", []],
["available", ["gpt-5.5"]],
] as const)("uses the selected route's %s lifecycle status", async (status, expectedIds) => {
const platformAvailable = { ...platform, status: "available" as const };
const chatGPTSelected = { ...chatGPT, status };
const catalog = [platformAvailable, chatGPTSelected];
const result = await resolveLogicalVisibleModelCatalog({
cfg: {} as OpenClawConfig,
catalog,
routeVariants: catalog,
defaultProvider: "openai",
routePolicy: openAIModelCatalogRoutePolicy,
evaluateEntry: async (entry) =>
resolveLogicalModelCatalogEntryState({
entry,
evaluation: {
availability: true,
routeResolution: { kind: "routes", routes: [selectedRoute] },
selectedRoute,
},
routePolicy: openAIModelCatalogRoutePolicy,
}),
});
expect(result.map((entry) => entry.id)).toEqual(expectedIds);
});
it("omits physical capabilities while managed route selection is unresolved", async () => {
const result = await resolveLogicalVisibleModelCatalog({
cfg: {} as OpenClawConfig,

View File

@@ -104,6 +104,19 @@ function dedupeLogicalModelCatalogEntries(
});
}
function isPickerVisibleCatalogEntry(
entry: ModelCatalogEntry,
configuredKeys: ReadonlySet<string>,
routePolicy: ModelCatalogRoutePolicy,
): boolean {
// Deprecated and disabled rows stay selectable but are picker-hidden.
// Exact configured refs always remain visible so pinned models never disappear.
return (
(entry.status !== "deprecated" && entry.status !== "disabled") ||
configuredKeys.has(resolveLogicalKey(entry, routePolicy))
);
}
/**
* Resolve catalog entries visible for one view, honoring explicit visibility
* policy, configured models, and providers with usable auth.
@@ -332,5 +345,8 @@ export async function resolveLogicalVisibleModelCatalog(params: {
}
// Physical route rows can share one logical provider/id. Selected-route rows
// must lead this merge so dedupe cannot retain sibling-route metadata instead.
return await projectEntries([...preferred, ...kept, ...retained, ...routeBacked]);
const projected = await projectEntries([...preferred, ...kept, ...retained, ...routeBacked]);
return projected.filter((entry) =>
isPickerVisibleCatalogEntry(entry, configuredKeys, params.routePolicy),
);
}

View File

@@ -215,6 +215,10 @@ function overlayCatalogMetadata(
...(overlay.input !== undefined ? { input: overlay.input } : {}),
...(params ? { params } : {}),
...(overlay.mediaInput !== undefined ? { mediaInput: overlay.mediaInput } : {}),
...(overlay.status !== undefined ? { status: overlay.status } : {}),
...(overlay.statusReason !== undefined ? { statusReason: overlay.statusReason } : {}),
...(overlay.replaces !== undefined ? { replaces: overlay.replaces } : {}),
...(overlay.replacedBy !== undefined ? { replacedBy: overlay.replacedBy } : {}),
compat: options?.preserveBaseCompat
? resolveCatalogOwnedModelCompat({
catalogRoute: options.catalogCompatRoute ?? base,
@@ -371,6 +375,7 @@ export function loadManifestModelCatalog(params: {
name: row.name,
provider: row.provider,
api: row.api,
status: row.status,
};
if (row.baseUrl) {
entry.baseUrl = row.baseUrl;
@@ -391,6 +396,15 @@ export function loadManifestModelCatalog(params: {
if (row.compat) {
entry.compat = row.compat;
}
if (row.statusReason) {
entry.statusReason = row.statusReason;
}
if (row.replaces?.length) {
entry.replaces = [...row.replaces];
}
if (row.replacedBy) {
entry.replacedBy = row.replacedBy;
}
return entry;
});
manifestModelCatalogCache.set(params.config, { snapshot: resolvedSnapshot, rows });

View File

@@ -3,6 +3,7 @@
* Used by discovery, browsing, visibility, and provider-auth code so renderers
* and filters agree on stable model metadata.
*/
import type { ModelCatalogStatus } from "@openclaw/model-catalog-core/model-catalog-types";
import type { ModelApi, ModelCompatConfig, ModelMediaInputConfig } from "../config/types.models.js";
/** Input modalities a catalog entry can advertise. */
@@ -24,6 +25,10 @@ export type ModelCatalogEntry = {
params?: Record<string, unknown>;
compat?: ModelCompatConfig;
mediaInput?: ModelMediaInputConfig;
status?: ModelCatalogStatus;
statusReason?: string;
replaces?: string[];
replacedBy?: string;
};
/** Logical catalog rows plus the physical variants used for route selection. */

View File

@@ -1603,6 +1603,28 @@ describe("model-selection", () => {
});
describe("resolveAllowedModelRef", () => {
it("keeps deprecated catalog refs selectable", () => {
const result = resolveAllowedModelRef({
cfg: {} as OpenClawConfig,
catalog: [
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
status: "deprecated",
replacedBy: "gpt-5.6",
},
],
raw: "openai/gpt-5.5",
defaultProvider: "openai",
});
expect(result).toEqual({
key: "openai/gpt-5.5",
ref: { provider: "openai", model: "gpt-5.5" },
});
});
it("accepts explicit allowlist refs absent from bundled catalog", () => {
const result = resolveAllowedModelRef({
cfg: EXPLICIT_ALLOWLIST_CONFIG,

View File

@@ -29,6 +29,56 @@ function createPolicy(cfg: OpenClawConfig, agentId?: string) {
}
describe("explicit model visibility policy", () => {
it("tracks every exact configured picker ref independently of the allow policy", () => {
const cfg = {
agents: {
defaults: {
model: {
primary: "demo/default-primary",
fallbacks: ["demo/default-fallback"],
},
models: { "demo/default-alias": { alias: "default" } },
utilityModel: "demo/default-utility",
imageModel: {
primary: "demo/image",
fallbacks: ["demo/image-fallback"],
},
pdfModel: { primary: "demo/pdf", fallbacks: ["demo/pdf-fallback"] },
modelPolicy: { allow: [] },
},
list: [
{
id: "research",
model: { primary: "demo/primary", fallbacks: ["demo/fallback"] },
models: { "demo/agent-alias": { alias: "agent" } },
utilityModel: "demo/agent-utility",
},
],
},
} as OpenClawConfig;
const policy = createModelVisibilityPolicy({
cfg,
catalog: [],
defaultProvider: "openai",
agentId: "research",
});
const configuredRefs = [
"demo/default-alias",
"demo/default-primary",
"demo/default-fallback",
"demo/agent-alias",
"demo/agent-utility",
"demo/image",
"demo/image-fallback",
"demo/pdf",
"demo/pdf-fallback",
"demo/primary",
"demo/fallback",
];
expect(configuredRefs.filter((key) => !policy.configuredKeys.has(key))).toEqual([]);
});
it("keeps overrides open when model entries only configure aliases or params", () => {
const policy = createPolicy({
meta: { migrations: { modelPolicyAllowlist: true } },

View File

@@ -1,7 +1,10 @@
/**
* Builds model visibility policies while retaining configured automatic fallbacks.
*/
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
import {
resolveAgentModelFallbackValues,
resolveAgentModelPrimaryValue,
} from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveAgentConfig, resolveAgentModelFallbacksOverride } from "./agent-scope.js";
import type { ModelCatalogEntry } from "./model-catalog.types.js";
@@ -26,6 +29,27 @@ function resolveAllowedFallbacks(params: { cfg: OpenClawConfig; agentId?: string
return resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model);
}
function resolveAdditionalConfiguredModelRefs(params: {
cfg: OpenClawConfig;
agentId?: string;
}): string[] {
const defaults = params.cfg.agents?.defaults;
const agent = params.agentId ? resolveAgentConfig(params.cfg, params.agentId) : undefined;
return [
resolveAgentModelPrimaryValue(defaults?.model),
...resolveAgentModelFallbackValues(defaults?.model),
resolveAgentModelPrimaryValue(agent?.model),
...resolveAgentModelFallbackValues(agent?.model),
...Object.keys(defaults?.models ?? {}),
...Object.keys(agent?.models ?? {}),
agent?.utilityModel ?? defaults?.utilityModel,
resolveAgentModelPrimaryValue(defaults?.imageModel),
...resolveAgentModelFallbackValues(defaults?.imageModel),
resolveAgentModelPrimaryValue(defaults?.pdfModel),
...resolveAgentModelFallbackValues(defaults?.pdfModel),
].filter((ref): ref is string => typeof ref === "string");
}
export function createModelVisibilityPolicy(
params: {
cfg: OpenClawConfig;
@@ -47,9 +71,7 @@ export function createModelVisibilityPolicy(
cfg: params.cfg,
agentId: params.agentId,
}),
additionalConfiguredModelRefs: params.agentId
? Object.keys(resolveAgentConfig(params.cfg, params.agentId)?.models ?? {})
: [],
additionalConfiguredModelRefs: resolveAdditionalConfiguredModelRefs(params),
// Model visibility is used by lightweight status/list paths. Keep plugin
// manifest normalization opt-in so those paths do not load plugin runtime
// metadata unless a caller explicitly needs it.

View File

@@ -3163,8 +3163,8 @@ describe("image compression policy", () => {
testing.resolveImageCompressionPolicy({
cfg: {},
imageModelConfig: {
primary: "anthropic/claude-opus-4-7",
fallbacks: ["anthropic/claude-sonnet-4-6"],
primary: "anthropic/claude-opus-4-8",
fallbacks: ["anthropic/claude-haiku-4-5"],
},
imageCount: 1,
}),

View File

@@ -259,7 +259,19 @@ describe("models.list", () => {
},
},
} as unknown as OpenClawConfig;
const loadGatewayModelCatalog = vi.fn(() => Promise.resolve([]));
const loadGatewayModelCatalog = vi.fn(() =>
Promise.resolve([
{
id: "source-model",
name: "Source Model",
provider: "vllm",
status: "disabled",
contextWindow: 128_000,
reasoning: true,
input: ["text", "image"],
},
]),
);
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
try {
const { request, respond } = requestModelsList({

View File

@@ -1015,6 +1015,27 @@ describe("gateway sessions patch", () => {
expectModelSelection(entry, "anthropic", ANTHROPIC_SONNET_ID);
});
test("supports uncataloged configured primary and session override refs", async () => {
const primary = "openai/o3";
const override = "openai/o1";
const entry = expectPatchOk(
await runPatch({
cfg: {
agents: {
defaults: {
model: { primary },
modelPolicy: { allow: [] },
},
},
} as OpenClawConfig,
patch: { key: MAIN_SESSION_KEY, model: override },
loadGatewayModelCatalog: async () => [],
}),
);
expectModelSelection(entry, "openai", "o1");
});
test("persists provider-qualified aliases without cross-provider collisions", async () => {
const entry = expectPatchOk(
await runPatch({