Skip to content

Commit 0cee110

Browse files
authored
Merge pull request #224 from MadAppGang/jack-rudenko/route-reasoning-mode-client-20260828
fix: consume per-route reasoning mode capability
2 parents 5ac86aa + dba92f7 commit 0cee110

5 files changed

Lines changed: 200 additions & 28 deletions

File tree

packages/cli/src/adapters/model-catalog.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* constraints, not model metadata.
1111
*/
1212

13+
import type { ReasoningModeCapabilities } from "../model-loader.js";
1314
import {
1415
type ModelEndpoint,
1516
type ReasoningCapability,
@@ -24,6 +25,7 @@ export type {
2425
ReasoningControl,
2526
RouteVariant,
2627
} from "../providers/all-models-cache.js";
28+
export type { ReasoningModeCapabilities } from "../model-loader.js";
2729

2830
export interface ModelEntry {
2931
/** Model ID as stored in the slim catalog (not lowercased) */
@@ -117,6 +119,23 @@ export function lookupModelRouteVariant(
117119
return findCacheEntry(modelId, cachePath)?.routeVariant;
118120
}
119121

122+
/**
123+
* `reasoning.mode` support for the exact route selected by `provider`.
124+
*
125+
* The same base model can support the parameter on one host, reject it on a
126+
* second, and remain unverified on a third. Never fall back to model-level
127+
* reasoning metadata or another aggregator row.
128+
*/
129+
export function lookupRouteReasoningMode(
130+
modelId: string,
131+
provider: string,
132+
cachePath?: string
133+
): ReasoningModeCapabilities | undefined {
134+
return findCacheEntry(modelId, cachePath)?.aggregators?.find(
135+
(aggregator) => aggregator.provider === provider
136+
)?.reasoning?.mode;
137+
}
138+
120139
/**
121140
* The default preset variant for a family on a given provider, if the catalog
122141
* knows one.

packages/cli/src/adapters/variant-presets.test.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { writeAllModelsCache } from "../providers/all-models-cache.js";
6-
import { lookupVariantPresets } from "./model-catalog.js";
6+
import { lookupRouteReasoningMode, lookupVariantPresets } from "./model-catalog.js";
77

88
const BASE_MODEL_ID = "gpt-5.6-sol";
99
const VARIANT_MODEL_ID = "gpt-5.6-sol-pro";
@@ -19,6 +19,37 @@ beforeEach(() => {
1919
writeAllModelsCache(
2020
{
2121
entries: [
22+
{
23+
modelId: BASE_MODEL_ID,
24+
aliases: ["openai/gpt-5.6-sol"],
25+
sources: {},
26+
aggregators: [
27+
{
28+
provider: "openai",
29+
externalId: BASE_MODEL_ID,
30+
confidence: "api_official",
31+
reasoning: {
32+
mode: {
33+
status: "supported",
34+
values: ["standard", "pro"],
35+
default: "standard",
36+
},
37+
},
38+
},
39+
{
40+
provider: "openai-codex",
41+
externalId: BASE_MODEL_ID,
42+
confidence: "gateway_official",
43+
reasoning: { mode: { status: "rejected" } },
44+
},
45+
{
46+
provider: "opencode-zen",
47+
externalId: BASE_MODEL_ID,
48+
confidence: "gateway_official",
49+
reasoning: { mode: { status: "unknown" } },
50+
},
51+
],
52+
},
2253
{
2354
modelId: VARIANT_MODEL_ID,
2455
aliases: [],
@@ -59,3 +90,20 @@ describe("lookupVariantPresets", () => {
5990
expect(lookupVariantPresets("openai/gpt-5.6-terra", PROVIDER, cachePath)).toEqual([]);
6091
});
6192
});
93+
94+
describe("lookupRouteReasoningMode", () => {
95+
test("returns the selected route's typed mode fact only", () => {
96+
expect(lookupRouteReasoningMode("openai/gpt-5.6-sol", "openai", cachePath)).toEqual({
97+
status: "supported",
98+
values: ["standard", "pro"],
99+
default: "standard",
100+
});
101+
expect(lookupRouteReasoningMode(BASE_MODEL_ID, "openai-codex", cachePath)).toEqual({
102+
status: "rejected",
103+
});
104+
expect(lookupRouteReasoningMode(BASE_MODEL_ID, "opencode-zen", cachePath)).toEqual({
105+
status: "unknown",
106+
});
107+
expect(lookupRouteReasoningMode(BASE_MODEL_ID, "openrouter", cachePath)).toBeUndefined();
108+
});
109+
});

packages/cli/src/model-loader.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,20 @@ export type ConfidenceTier =
6969
| "gateway_official"
7070
| "api_official";
7171

72+
export type ReasoningModeCapabilities =
73+
| {
74+
status: "supported";
75+
values: string[];
76+
default?: string;
77+
}
78+
| {
79+
status: "rejected" | "unknown";
80+
};
81+
82+
export interface RouteReasoningCapabilities {
83+
mode?: ReasoningModeCapabilities;
84+
}
85+
7286
/**
7387
* CLI-friendly aggregator entry — flattened view of `sources` keyed by the
7488
* canonical CLI provider name. Mirrors `AggregatorEntry` in
@@ -105,6 +119,8 @@ export interface AggregatorEntry {
105119
* `contextWindow`.
106120
*/
107121
contextWindow?: number;
122+
/** Reasoning behavior verified for this exact serving provider/model route. */
123+
reasoning?: RouteReasoningCapabilities;
108124
}
109125

110126
/**

packages/cli/src/session-events/pro-injection.test.ts

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import type { Context } from "hono";
66
import { ComposedHandler } from "../handlers/composed-handler.js";
7+
import type { ReasoningModeCapabilities } from "../model-loader.js";
78
import { type SlimModelEntry, writeAllModelsCache } from "../providers/all-models-cache.js";
89
import type { ProviderTransport } from "../providers/transport/types.js";
910
import { SessionEventRegistry } from "./index.js";
@@ -61,6 +62,22 @@ function variantEntry(
6162
};
6263
}
6364

65+
function routeCapabilityEntry(provider: string, mode: ReasoningModeCapabilities): SlimModelEntry {
66+
return {
67+
modelId: BASE_MODEL_ID,
68+
aliases: [`openai/${BASE_MODEL_ID}`],
69+
sources: {},
70+
aggregators: [
71+
{
72+
provider,
73+
externalId: BASE_MODEL_ID,
74+
confidence: provider === "openai" ? "api_official" : "gateway_official",
75+
reasoning: { mode },
76+
},
77+
],
78+
};
79+
}
80+
6481
function writeCatalog(entries: SlimModelEntry[] = [variantEntry()]): void {
6582
writeAllModelsCache({ entries }, cachePath);
6683
}
@@ -99,12 +116,46 @@ describe("resolveVariantPreset", () => {
99116

100117
expect(resolveVariantPreset(BASE_MODEL_ID, PROVIDER, cachePath)).toEqual({
101118
params: { reasoning: { mode: "pro" } },
102-
variantModelId: VARIANT_MODEL_ID,
103119
provider: PROVIDER,
104120
preset: PRESET,
121+
sourceLabel: `variant ${VARIANT_MODEL_ID} @ ${PROVIDER}`,
122+
});
123+
});
124+
125+
test("prefers supported typed route capability over the legacy variant fallback", () => {
126+
makeHome();
127+
writeCatalog([
128+
routeCapabilityEntry("openai", {
129+
status: "supported",
130+
values: ["standard", "pro"],
131+
default: "standard",
132+
}),
133+
variantEntry("openai"),
134+
]);
135+
136+
expect(resolveVariantPreset(BASE_MODEL_ID, "openai", cachePath)).toEqual({
137+
params: { reasoning: { mode: "pro" } },
138+
provider: "openai",
139+
preset: PRESET,
140+
sourceLabel: "route capability @ openai",
105141
});
106142
});
107143

144+
test("does not weaken explicit rejected or unknown route facts with a legacy variant", () => {
145+
makeHome();
146+
for (const status of ["rejected", "unknown"] as const) {
147+
writeCatalog([routeCapabilityEntry("openai", { status }), variantEntry("openai")]);
148+
expect(resolveVariantPreset(BASE_MODEL_ID, "openai", cachePath)).toBeUndefined();
149+
}
150+
});
151+
152+
test("requires pro to be an exact supported native value", () => {
153+
makeHome();
154+
writeCatalog([routeCapabilityEntry("openai", { status: "supported", values: ["standard"] })]);
155+
156+
expect(resolveVariantPreset(BASE_MODEL_ID, "openai", cachePath)).toBeUndefined();
157+
});
158+
108159
test("returns undefined when the variant belongs to a different provider", () => {
109160
makeHome();
110161
writeCatalog([variantEntry("openrouter")]);
@@ -248,10 +299,10 @@ describe("applyProInjection", () => {
248299
// ─── ComposedHandler wiring (step 5a-pre) ────────────────────────────────────
249300

250301
/** Fake transport; the stubbed global fetch captures the final wire payload. */
251-
function makeTransport(): ProviderTransport {
302+
function makeTransport(provider = PROVIDER): ProviderTransport {
252303
return {
253-
name: PROVIDER,
254-
displayName: "OpenRouter",
304+
name: provider,
305+
displayName: provider,
255306
streamFormat: "openai-sse",
256307
getEndpoint: () => "http://localhost/v1/chat/completions",
257308
getHeaders: async () => ({}),
@@ -295,12 +346,20 @@ function makeClaudePayload(): Record<string, unknown> {
295346
function makeHandler(options: {
296347
proOnUltracode?: boolean;
297348
modelParams?: Record<string, unknown>;
349+
provider?: string;
298350
}): ComposedHandler {
299-
return new ComposedHandler(makeTransport(), `${PROVIDER}@${BASE_MODEL_ID}`, BASE_MODEL_ID, 8080, {
300-
...options,
301-
sessionEventRegistry: registry,
302-
catalogCachePath: cachePath,
303-
});
351+
const { provider = PROVIDER, ...handlerOptions } = options;
352+
return new ComposedHandler(
353+
makeTransport(provider),
354+
`${provider}@${BASE_MODEL_ID}`,
355+
BASE_MODEL_ID,
356+
8080,
357+
{
358+
...handlerOptions,
359+
sessionEventRegistry: registry,
360+
catalogCachePath: cachePath,
361+
}
362+
);
304363
}
305364

306365
describe("ComposedHandler step 5a-pre wiring", () => {
@@ -314,6 +373,25 @@ describe("ComposedHandler step 5a-pre wiring", () => {
314373
expect(wire.body().reasoning?.mode).toBe("pro");
315374
});
316375

376+
test("typed OpenAI route support puts reasoning.mode=pro on the wire", async () => {
377+
makeHome(FIXTURE_EFFORT_ULTRACODE_STDOUT, FIXTURE_ULTRA_EFFORT_ENTER);
378+
writeCatalog([
379+
routeCapabilityEntry("openai", {
380+
status: "supported",
381+
values: ["standard", "pro"],
382+
default: "standard",
383+
}),
384+
]);
385+
const wire = stubFetchCapture();
386+
387+
await makeHandler({ proOnUltracode: true, provider: "openai" }).handle(
388+
makeContext(),
389+
makeClaudePayload()
390+
);
391+
392+
expect(wire.body().reasoning?.mode).toBe("pro");
393+
});
394+
317395
test("ORDERING INVARIANT: swapping 5a-pre after 5a would override explicit --model-params", async () => {
318396
makeHome(FIXTURE_EFFORT_ULTRACODE_STDOUT, FIXTURE_ULTRA_EFFORT_ENTER);
319397
writeCatalog();

packages/cli/src/session-events/pro-injection.ts

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@
66
* merge — precedence is positional, so an explicit user parameter always wins
77
* over the injected one.
88
*
9-
* WHAT IS INJECTED IS NOT HARDCODED. Both halves of the fact come from the slim
10-
* catalog's `routeVariant`: which models a preset applies to (`baseModelId`)
11-
* and what that preset sets (`preset`, e.g. `reasoning.mode=pro`). A name regex
12-
* would assert a fact the catalog already knows, and would go stale the moment
13-
* a vendor ships another pro SKU.
9+
* WHAT IS INJECTED IS NOT HARDCODED. The selected slim-catalog aggregator route
10+
* says whether `reasoning.mode` is supported and which values it accepts. The
11+
* older `routeVariant` lookup remains a compatibility fallback for caches that
12+
* predate route-level capability metadata.
1413
*/
1514

16-
import { lookupVariantPresets } from "../adapters/model-catalog.js";
15+
import { lookupRouteReasoningMode, lookupVariantPresets } from "../adapters/model-catalog.js";
1716
import { log } from "../logger.js";
1817
import { deepMergeParams, parseModelParams } from "../model-params.js";
1918
import { type SessionEventRegistry, sessionEvents } from "./index.js";
@@ -22,41 +21,53 @@ import { type SessionEventRegistry, sessionEvents } from "./index.js";
2221
export interface ResolvedPreset {
2322
/** The params the preset expands to, ready to deep-merge. */
2423
params: Record<string, unknown>;
25-
/** The variant model id the preset was read from (e.g. `gpt-5.6-sol-pro`). */
26-
variantModelId: string;
2724
/** The serving provider the catalog recorded the preset on. */
2825
provider?: string;
2926
/** The raw preset string, for the log line. */
3027
preset: string;
28+
/** Human-readable catalog evidence used by the diagnostic log. */
29+
sourceLabel: string;
3130
}
3231

3332
/**
34-
* The provider-preset this model has on `provider`, if the catalog knows one.
33+
* The pro-mode preset this model has on `provider`, if the catalog knows one.
3534
*
36-
* Replaces the v1 `/gpt-5.6/` name gate. Returns undefined for a cold cache, a
37-
* model with no variants, a variant recorded on a DIFFERENT provider, or a
38-
* preset string that is not parseable `k=v` — every one of which means "no
39-
* information", which the caller must treat as "do not inject".
35+
* Typed route metadata is authoritative when present: only `supported` plus a
36+
* literal `pro` value enables injection. `rejected` and `unknown` both stop;
37+
* neither may be weakened by a sibling provider's variant row. If the cache is
38+
* old and has no typed route fact, the legacy same-provider `routeVariant`
39+
* lookup remains available for one compatibility release.
4040
*
4141
* `provider` is required rather than optional on purpose: a preset is an
42-
* observation about ONE provider's roster. `reasoning.mode=pro` is recorded
43-
* against OpenRouter; whether the same parameter reaches the model on another
44-
* host is unverified, and injecting it there would be a guess.
42+
* observation about ONE provider's route, not a portable model fact.
4543
*/
4644
export function resolveVariantPreset(
4745
bareModelName: string,
4846
provider: string,
4947
cachePath?: string
5048
): ResolvedPreset | undefined {
49+
const routeMode = lookupRouteReasoningMode(bareModelName, provider, cachePath);
50+
if (routeMode) {
51+
if (routeMode.status !== "supported" || !routeMode.values.includes("pro")) {
52+
return undefined;
53+
}
54+
return {
55+
params: { reasoning: { mode: "pro" } },
56+
provider,
57+
preset: "reasoning.mode=pro",
58+
sourceLabel: `route capability @ ${provider}`,
59+
};
60+
}
61+
5162
for (const variant of lookupVariantPresets(bareModelName, provider, cachePath)) {
5263
try {
5364
const params = parseModelParams(variant.preset);
5465
if (Object.keys(params).length === 0) continue;
5566
return {
5667
params,
57-
variantModelId: variant.modelId,
5868
provider: variant.provider,
5969
preset: variant.preset,
70+
sourceLabel: `variant ${variant.modelId} @ ${variant.provider}`,
6071
};
6172
} catch {
6273
// Unparseable preset vocabulary (not `k=v`) → no information, try the next.
@@ -127,7 +138,7 @@ export function applyProInjection(
127138
deepMergeParams(requestPayload, resolved.params);
128139
log(
129140
`[SessionEvents] ultracode active → preset ${resolved.preset} for ${opts.targetModel} ` +
130-
`(catalog variant ${resolved.variantModelId} @ ${resolved.provider}, session ${opts.sessionId})`
141+
`(catalog ${resolved.sourceLabel}, session ${opts.sessionId})`
131142
);
132143
return true;
133144
} catch {

0 commit comments

Comments
 (0)