fix(catalog): let Synthetic's wire vocabulary override reference metadata

Addresses two review items on the Synthetic capability fix:

- Codex P2: an advertised `reasoning_parameters.efforts` list is now
  authoritative over the bundled reference's `reasoning` flag. Previously a
  `hf:*` reference with `reasoning: true` re-armed the effort dial even when
  the wire advertised only the `none` off-state, so the raw spec carried a
  misleading `reasoning: true` plus a one-stop minimal ladder. When the wire
  names tiers, the reference no longer gets a vote.
- roboomp should-fix: a present-but-empty `supported_features: []` was
  treated like absent metadata and left `supportsTools` unset, which the
  request layer reads as tool-capable. A present array (empty included) with
  no `tools` entry now marks the route tool-less, while a reference that
  already vouched for tools still wins over a merely-incomplete wire list.

Tests: stale-reference override is asserted through the built-model path
(`buildModel`), and a new fixture covers the empty-features contract.
7 pass; full packages/catalog suite 525 pass, 0 fail.
This commit is contained in:
Gareth-Rouse
2026-07-31 11:19:38 +01:00
parent 306245cb78
commit b88869dc64
3 changed files with 59 additions and 16 deletions
+1 -1
View File
@@ -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
@@ -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,
@@ -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", () => {