diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 92d5c2644..8c1ab13c6 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed Synthetic models losing their thinking selector, vision input, output cap and pricing: the discovery mapper read `supports_reasoning`, `supports_vision` and `max_tokens`, none of which Synthetic sends. It advertises `supported_features`, `reasoning_parameters.efforts`, `input_modalities`, `max_output_length` and `$`-prefixed `pricing`, so any route without a bundled reference (the `syn:*` router aliases, newly added routes such as `hf:moonshotai/Kimi-K3`) resolved to `reasoning: false` — hiding the effort dial and dropping `reasoning_effort` from every request — plus text-only input, zero cost, and an 8k output cap low enough to end turns on `length` and trigger recovery compaction. Effort ladders now come from the per-model wire vocabulary, with the router's `none` tier mapped onto `minimal`; a route whose wire vocabulary contains no known tier reports non-reasoning rather than fabricating an unadvertised ladder, the wire vocabulary overrides stale bundled reference ladders, and a populated `supported_features` list without `tools` no longer downgrades routes a bundled reference already marked tool-capable. +- Fixed Synthetic models losing their thinking selector, vision input, output cap and pricing: the discovery mapper read `supports_reasoning`, `supports_vision` and `max_tokens`, none of which Synthetic sends. It advertises `supported_features`, `reasoning_parameters.efforts`, `input_modalities`, `max_output_length` and `$`-prefixed `pricing`, so any route without a bundled reference (the `syn:*` router aliases, newly added routes such as `hf:moonshotai/Kimi-K3`) resolved to `reasoning: false` — hiding the effort dial and dropping `reasoning_effort` from every request — plus text-only input, zero cost, and an 8k output cap low enough to end turns on `length` and trigger recovery compaction. Effort ladders now come from the per-model wire vocabulary, with the router's `none` tier mapped onto `minimal`; a route whose wire vocabulary contains no known tier reports non-reasoning rather than fabricating an unadvertised ladder, the wire vocabulary overrides stale bundled reference ladders, and a populated `supported_features` list without `tools` no longer downgrades routes a bundled reference already marked tool-capable. An advertised effort vocabulary is authoritative over the bundled reference's reasoning flag, and a present-but-empty `supported_features` list now marks the route tool-less rather than falling back to the reference default. ## [17.2.1] - 2026-07-30 diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 5a5d41e73..cbd90e6ac 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -3219,14 +3219,20 @@ export function syntheticModelManagerOptions( : []; const wireReasoning = features.includes("reasoning") || wireEfforts.length > 0; const thinking = resolveSyntheticThinking(wireEfforts); - // A route whose only thinking surface is the router's off state is - // not a reasoning model from the user's side — reporting + // An advertised effort vocabulary is authoritative over the bundled + // reference: when the wire names tiers (even only `none`), the + // reference's reasoning flag must not re-add a dial the route + // doesn't expose. A route whose only wire surface is `none` is not + // a reasoning model from the user's side — reporting // `reasoning: true` with a minimal-only ladder would light up the - // effort dial for a dial with one stop. + // effort dial for a dial with one stop. Only when the wire is + // silent on reasoning does the reference get a vote. const reasoning = - (wireReasoning && (thinking?.efforts.length ?? 0) > 1) || - entry.supports_reasoning === true || - (reference?.reasoning ?? false); + wireReasoning && (thinking?.efforts.length ?? 0) > 1 + ? true + : wireEfforts.length > 0 + ? false + : entry.supports_reasoning === true || (reference?.reasoning ?? false); // The router aliases (`syn:*`) and newly added routes carry no // bundled reference, so these advertised capabilities are the only // truth available. Without them such a model lands non-reasoning @@ -3244,12 +3250,18 @@ export function syntheticModelManagerOptions( modalities.includes("image") || entry.supports_vision === true || referenceSupportsImage ? ["text", "image"] : ["text"], - // Wire proves absence only when the route advertises OTHER - // features (a populated but tool-less list); a reference - // that already ruled tools out stays ruled out either way. - ...((features.length > 0 && !features.includes("tools")) || reference?.supportsTools === false + // A present `supported_features` list (even empty) is the route's + // whole advertised surface: no `tools` entry means no tool + // support. The reference still wins when it already vouched for + // tools, since a populated wire list can be incomplete; an + // explicit reference `false` stays `false` either way. + ...(record.supported_features !== undefined && + !features.includes("tools") && + reference?.supportsTools !== true ? { supportsTools: false } - : {}), + : reference?.supportsTools === false + ? { supportsTools: false } + : {}), cost: resolveSyntheticCost(record.pricing, base.cost), contextWindow: toPositiveNumber( entry.context_length, diff --git a/packages/catalog/test/synthetic-provider.test.ts b/packages/catalog/test/synthetic-provider.test.ts index 98eef1b31..4d0bfc7d7 100644 --- a/packages/catalog/test/synthetic-provider.test.ts +++ b/packages/catalog/test/synthetic-provider.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { Effort } from "@oh-my-pi/pi-catalog/effort"; import { syntheticModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; import type { FetchImpl } from "@oh-my-pi/pi-catalog/types"; @@ -169,8 +170,10 @@ describe("Synthetic provider discovery", () => { }); test("overrides a stale bundled reference with the wire's effort vocabulary", async () => { - // `hf:zai-org/GLM-5.2` ships a bundled reference with a baked effort - // ladder; the wire saying the route only accepts `none` must win. + // `hf:zai-org/GLM-5.2` ships a bundled reference that is `reasoning: true` + // with a baked multi-tier ladder. The wire saying the route only accepts + // `none` must win all the way through `buildModel` — identity tables would + // otherwise re-expand the off-state into an unadvertised ladder. const { fetch } = syntheticModelsFetch([ { id: "hf:zai-org/GLM-5.2", @@ -185,12 +188,40 @@ describe("Synthetic provider discovery", () => { ]); const models = await syntheticModelManagerOptions({ apiKey: "syn-test-key", fetch }).fetchDynamicModels?.(); - const glm = models?.find(model => model.id === "hf:zai-org/GLM-5.2"); - expect(glm?.thinking).toEqual({ + const glmSpec = models?.find(model => model.id === "hf:zai-org/GLM-5.2"); + expect(glmSpec?.reasoning).toBe(false); + expect(glmSpec?.thinking).toEqual({ mode: "effort", efforts: [Effort.Minimal], effortMap: { minimal: "none" }, }); + + // Production consumes models through `buildModel`; a non-reasoning model + // must surface no thinking metadata there either. + const built = buildModel(glmSpec!); + expect(built.reasoning).toBe(false); + expect(built.thinking).toBeUndefined(); + }); + + test("treats an explicitly empty supported_features list as authoritative no-tools", async () => { + // A present-but-empty array is the route advertising zero features, not a + // missing field: the model must come out tool-less so the request layer + // does not offer tools to a route that rejects them. + const { fetch } = syntheticModelsFetch([ + { + id: "hf:example/no-features", + object: "model", + name: "example/no-features", + input_modalities: ["text"], + context_length: 131072, + supported_features: [], + }, + ]); + const models = await syntheticModelManagerOptions({ apiKey: "syn-test-key", fetch }).fetchDynamicModels?.(); + + const bare = models?.find(model => model.id === "hf:example/no-features"); + expect(bare?.supportsTools).toBe(false); + expect(bare?.reasoning).toBe(false); }); test("serves no dynamic models without an API key", () => {