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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user