Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions src/routing/capability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* how that affects eligibility.
*/

import type { OcxConfig } from "../types";
import { modelInList, type OcxConfig } from "../types";
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
import { serviceTierSupportForModel } from "../providers/service-tier";
import { PROVIDER_REGISTRY } from "../providers/registry";
Expand All @@ -21,6 +21,7 @@ import {
nativeReasoningEfforts,
} from "../codex/catalog/metadata";
import { readCatalog, readCodexCatalogPath } from "../codex/catalog/parsing";
import { modelRecordValue } from "../reasoning-effort";
import { statSync } from "node:fs";
import type { RouteCapabilityEvidence } from "./trace";

Expand Down Expand Up @@ -159,9 +160,14 @@ export function candidateCapabilityEvidence(
const catalogRow = cachedCatalogModels().find(model => model.provider === providerName && model.id === modelId);
const isNative = providerName === OPENAI_CODEX_PROVIDER_ID && !modelId.includes("/");

const rawContextWindow = provider?.modelContextWindows?.[modelId]
// `modelRecordValue`, not a bare lookup: every runtime reader of these three maps
// resolves them that way, so a `gpt-oss` entry covers `gpt-oss:120b`. Reading raw
// made the evidence disagree with the resolver it claims to describe — and for the
// window it did not even degrade to unknown, it fell through to the provider-wide
// value, which is a definite wrong answer rather than an absent one.
const rawContextWindow = modelRecordValue(provider?.modelContextWindows, modelId)
?? provider?.contextWindow
?? registryEntry?.modelContextWindows?.[modelId]
?? modelRecordValue(registryEntry?.modelContextWindows, modelId)
?? catalogRow?.contextWindow
?? (isNative ? nativeOpenAiContextWindow(modelId, nativeContextLimits(config)) : undefined);
// Native rows go through the accessor (raise-to-ceiling + opt-in). Routed rows keep
Expand All @@ -170,10 +176,21 @@ export function candidateCapabilityEvidence(
? (nativeOpenAiContextWindow(modelId, nativeContextLimits(config)) ?? rawContextWindow)
: rawContextWindow;

const modalities = provider?.modelInputModalities?.[modelId]
?? registryEntry?.modelInputModalities?.[modelId]
?? catalogRow?.inputModalities
?? (isNative ? nativeInputModalities(modelId) : undefined);
// `noVisionModels` is checked before the modality chain because that is the order
// `isModelTextOnly` uses: it matches the no-vision list and returns true before it
// ever reads `modelInputModalities` (`src/vision/index.ts:32`). So a `gpt-oss`
// no-vision entry beats an exact `gpt-oss:120b` entry that lists "image", and
// deriving `image` from the modality chain alone reported vision on a model the
// runtime refuses it for. That matters more here than on the CLI surface fixed in
// #2086: routing *acts* on this evidence, so it would select the candidate for image
// work that execution then rejects.
const noVision = modelInList(provider?.noVisionModels, modelId);
const modalities = noVision
? ["text"]
: (modelRecordValue(provider?.modelInputModalities, modelId)
?? modelRecordValue(registryEntry?.modelInputModalities, modelId)
?? catalogRow?.inputModalities
?? (isNative ? nativeInputModalities(modelId) : undefined));
const image = Array.isArray(modalities)
? modalities.includes("image")
: undefined;
Expand All @@ -196,8 +213,8 @@ export function candidateCapabilityEvidence(
|| provider?.parallelToolCalls === true
|| undefined;

const reasoningEfforts = provider?.modelReasoningEfforts?.[modelId]
?? registryEntry?.modelReasoningEfforts?.[modelId]
const reasoningEfforts = modelRecordValue(provider?.modelReasoningEfforts, modelId)
?? modelRecordValue(registryEntry?.modelReasoningEfforts, modelId)
?? (isNative ? nativeReasoningEfforts(modelId) : undefined);

const tierSupport = provider
Expand Down
152 changes: 152 additions & 0 deletions tests/routing-capability-model-matching.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, expect, test } from "bun:test";
import { candidateCapabilityEvidence } from "../src/routing/capability";
import { PROVIDER_REGISTRY } from "../src/providers/registry";
import { modelRecordValue } from "../src/reasoning-effort";
import { isModelTextOnly } from "../src/vision";
import type { OcxConfig, OcxProviderConfig } from "../src/types";

/**
* `candidateCapabilityEvidence` describes what the resolver will do with a candidate,
* so it has to match the resolver. Every runtime reader of `modelContextWindows`,
* `modelInputModalities` and `modelReasoningEfforts` goes through `modelRecordValue`
* (`src/reasoning-effort.ts:108`, `src/server/effort-policy.ts:122`,
* `src/vision/index.ts:34`, `src/codex/catalog/provider-fetch.ts:612`), which accepts a
* family entry for a tagged id. This file pins the evidence to that same rule.
*
* The window matters most: a bare lookup did not degrade to unknown there, it fell
* through to the provider-wide `contextWindow` — a definite wrong answer, which the
* module's own "unknown is not zero" contract is written to avoid.
*/

function providerWithFamilyEntries(): OcxProviderConfig {
return {
adapter: "openai-chat",
baseUrl: "https://example.test/v1",
contextWindow: 8_000,
models: ["gpt-oss:120b"],
modelContextWindows: { "gpt-oss": 131_072 },
modelInputModalities: { "gpt-oss": ["text"] },
modelReasoningEfforts: { "gpt-oss": ["low", "high"] },
} as unknown as OcxProviderConfig;
}

function configFor(provider: OcxProviderConfig): OcxConfig {
return { providers: { custom: provider } } as unknown as OcxConfig;
}

describe("candidateCapabilityEvidence model matching", () => {
test("a family entry covers its tagged siblings, as the resolver does", () => {
const provider = providerWithFamilyEntries();

// Ground truth first: what the runtime itself resolves off this config.
expect(modelRecordValue(provider.modelContextWindows, "gpt-oss:120b")).toBe(131_072);
expect(isModelTextOnly(provider, "gpt-oss:120b")).toBe(true);

const evidence = candidateCapabilityEvidence(configFor(provider), "custom", "gpt-oss:120b");
expect(evidence.contextWindow).toBe(131_072);
expect(evidence.image).toBe(false);
expect(evidence.reasoningEfforts).toEqual(["low", "high"]);
});

test("the window does not fall through to the provider-wide value", () => {
// The specific regression: the provider-wide 8_000 is not "unknown", it is a
// definite answer belonging to a different model, and routing would act on it.
// Asserted as the exact expected number rather than `not.toBe(8_000)`, which
// would also pass for `undefined` or any other wrong value.
const evidence = candidateCapabilityEvidence(
configFor(providerWithFamilyEntries()),
"custom",
"gpt-oss:120b",
);
expect(evidence.contextWindow).toBe(131_072);
});

test("an exact entry still wins over the family entry", () => {
const provider = {
...providerWithFamilyEntries(),
modelContextWindows: { "gpt-oss": 131_072, "gpt-oss:20b": 32_000 },
} as unknown as OcxProviderConfig;
expect(candidateCapabilityEvidence(configFor(provider), "custom", "gpt-oss:20b").contextWindow)
.toBe(32_000);
});

test("an unrelated model still falls back to the provider-wide window", () => {
const evidence = candidateCapabilityEvidence(
configFor(providerWithFamilyEntries()),
"custom",
"some-other-model",
);
expect(evidence.contextWindow).toBe(8_000);
expect(evidence.reasoningEfforts).toBeUndefined();
});

test("a registry entry covers its tagged siblings with no provider configured", () => {
// The three registry lookups (capability.ts lines 170/180/206) are a separate branch
// from the configured-provider ones above: they are only reached when the provider is
// absent from the config, which every other case here supplies.
const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === "xai");
if (!registryEntry) throw new Error("fixture drift: no `xai` entry in PROVIDER_REGISTRY");

// Pin the fixture's shape rather than its values, so registry churn does not turn
// into a false failure here while real drift still does.
const family = "grok-4.6";
expect(registryEntry.modelContextWindows?.[family]).toBeNumber();
expect(registryEntry.modelInputModalities?.[family]).toBeArray();
expect(registryEntry.modelReasoningEfforts?.[family]).toBeArray();

const emptyConfig = { providers: {} } as unknown as OcxConfig;
const evidence = candidateCapabilityEvidence(emptyConfig, "xai", `${family}:latest`);

expect(evidence.contextWindow).toBe(registryEntry.modelContextWindows![family]);
expect(evidence.image).toBe(registryEntry.modelInputModalities![family].includes("image"));
expect(evidence.reasoningEfforts).toEqual(registryEntry.modelReasoningEfforts![family]);
});

test("noVisionModels beats an exact modality entry, as isModelTextOnly does", () => {
// `isModelTextOnly` matches the no-vision list and returns true before it ever
// reads `modelInputModalities`, so the `gpt-oss` no-vision entry wins over an
// exact `gpt-oss:120b` entry listing "image". Evidence that disagrees here is
// worse than a wrong window: routing selects the candidate for image work and
// execution then refuses it.
const provider = {
...providerWithFamilyEntries(),
noVisionModels: ["gpt-oss"],
modelInputModalities: { "gpt-oss:120b": ["text", "image"] },
} as unknown as OcxProviderConfig;

// Ground truth first: the resolver this evidence claims to describe says text-only.
expect(isModelTextOnly(provider, "gpt-oss:120b")).toBe(true);

const evidence = candidateCapabilityEvidence(configFor(provider), "custom", "gpt-oss:120b");
expect(evidence.image).toBe(false);
});

test("a model outside noVisionModels keeps its declared image modality", () => {
// The negative half: the no-vision check must not spread to models the list does
// not cover, or the fix would trade a false positive for a false negative.
const provider = {
...providerWithFamilyEntries(),
models: ["gpt-oss:120b", "llava:13b"],
noVisionModels: ["gpt-oss"],
modelInputModalities: { "llava:13b": ["text", "image"] },
} as unknown as OcxProviderConfig;

expect(isModelTextOnly(provider, "llava:13b")).toBe(false);
expect(candidateCapabilityEvidence(configFor(provider), "custom", "llava:13b").image).toBe(true);
});

test("a prototype-shaped model id resolves nothing", () => {
// modelRecordValue uses hasOwnProperty; a bare lookup would return Object.prototype
// members here and hand routing a function as evidence.
for (const modelId of ["constructor", "toString", "valueOf", "hasOwnProperty"]) {
const evidence = candidateCapabilityEvidence(
configFor(providerWithFamilyEntries()),
"custom",
modelId,
);
expect(evidence.contextWindow).toBe(8_000);
expect(evidence.reasoningEfforts).toBeUndefined();
expect(evidence.image).toBeUndefined();
}
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading