diff --git a/src/routing/compatibility/behavior.ts b/src/routing/compatibility/behavior.ts index 5abf08f17d..58e02f62fb 100644 --- a/src/routing/compatibility/behavior.ts +++ b/src/routing/compatibility/behavior.ts @@ -1,3 +1,4 @@ +import { modelRecordValue } from "../../reasoning-effort"; import { modelInList } from "../../types"; import type { OcxConfig, OcxProviderConfig } from "../../types"; import { PROVIDER_REGISTRY } from "../../providers/registry"; @@ -51,8 +52,45 @@ function includesModel(list: string[] | undefined, modelId: string): boolean { return modelInList(list, modelId); } +/** + * Per-model override lookup for the nine family-aware report rows. + * + * Delegates to modelRecordValue so the report reads these maps the way the + * runtime does -- own properties only, then the pre-colon family, then a + * case-folded key. A bare index disagreed on all three: it missed the + * `gpt-oss` entry ollama-cloud's `gpt-oss:120b` actually resolves, missed a + * differently-cased key, and walked the prototype chain, so a routed model id + * of `constructor`/`toString` yielded an Object.prototype function. That last + * one made buildBehaviorFingerprintV1 throw ("unsupported value type + * function"); the caller catches it (`src/routing/compatibility/subject.ts:125`) + * and returns no route, so the subject is silently dropped -- and the linker + * contract says implementations do not throw. + * + * Not every override map belongs here. `modelPreferHostedTools` and + * `modelOpenRouterRouting` are exact-own at runtime and go through + * `exactOwnValue` below; widening those to the family would be this same bug + * with the sign flipped. + */ function modelValue(map: Record | undefined, modelId: string): T | undefined { - return map?.[modelId]; + return modelRecordValue(map, modelId); +} + +/** + * Exact, own-property lookup for the two maps the runtime resolves that way. + * + * `modelPreferHostedTools` and `modelOpenRouterRouting` are deliberately exact: the + * adapter reads the first through `hasOwnProperty` + * (`src/adapters/openai-responses.ts:1001`) and the second through `Object.hasOwn` + * (`src/providers/openrouter-routing.ts:89`), and the type documents the first as + * "Exact-model hosted tools" (`src/types.ts:1584`). Sending them through + * `modelRecordValue` would make the report say a `gpt-oss` entry applies to + * `gpt-oss:120b` when the adapter will never apply it -- the same divergence this + * file exists to remove, pointed the other way. A bare index is not the answer + * either: it walks the prototype chain, which is the bug `modelValue` just fixed. + * Neither existing primitive is right for these two, so this is the third one. + */ +function exactOwnValue(map: Record | undefined, modelId: string): T | undefined { + return map !== undefined && Object.hasOwn(map, modelId) ? map[modelId] : undefined; } const CREDENTIAL_HEADER = /(authorization|api[-_]?key|token|secret|credential|cookie)/i; @@ -69,7 +107,7 @@ function nonCredentialHeaderDigest( } function effectiveOpenRouterRouting(effective: OcxProviderConfig, modelId: string) { - return effective.modelOpenRouterRouting?.[modelId] ?? effective.openRouterRouting; + return exactOwnValue(effective.modelOpenRouterRouting, modelId) ?? effective.openRouterRouting; } /** @@ -184,7 +222,7 @@ export function resolveProductionBehaviorValues( effective.parallelToolCalls ?? (upstreamProtocol === "openai-chat"), ), "tools.hostedPreference": behaviorRow("provider_config", { - tools: modelValue(effective.modelPreferHostedTools, modelId) ?? [], + tools: exactOwnValue(effective.modelPreferHostedTools, modelId) ?? [], }), "tools.builtinNameEscaping": behaviorRow("provider_config", effective.escapeBuiltinToolNames === true), "cache.forwarding": behaviorRow("provider_config", effective.promptCacheKey === true), diff --git a/tests/routing-compatibility-model-matching.test.ts b/tests/routing-compatibility-model-matching.test.ts index 1dee27be25..f3a26f39dd 100644 --- a/tests/routing-compatibility-model-matching.test.ts +++ b/tests/routing-compatibility-model-matching.test.ts @@ -7,6 +7,9 @@ import { describe, expect, test } from "bun:test"; import { resolveProductionBehaviorValues } from "../src/routing/compatibility/behavior"; import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import { buildBehaviorFingerprintV1 } from "../src/lab/subject/behavior-fingerprint"; +import { resolveOpenRouterRouting } from "../src/providers/openrouter-routing"; +import { modelRecordValue } from "../src/reasoning-effort"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; // ollama-cloud ships `gpt-oss:120b` verbatim (src/providers/registry.ts) and the same @@ -72,3 +75,126 @@ describe("behavior report must agree with the wire the adapter actually builds", expect(v["reasoning.budgetMode"]!.value).toBe(false); }); }); + +// The list-shaped options above are one half of the report. The other half is the +// per-model override maps, which the runtime reads through modelRecordValue: own +// properties, then the pre-colon family, then a case-folded key. + +const OVERRIDES: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://ollama.com/v1", + apiKey: "sk-test", + authMode: "key", + modelMaxOutputTokens: { "gpt-oss": 1234 }, + modelContextWindows: { "GPT-OSS": 55_555 }, +}; + +const overrideConfig = { providers: { "ollama-cloud": OVERRIDES } } as unknown as OcxConfig; + +const overrideValues = (modelId: string) => + resolveProductionBehaviorValues(overrideConfig, "ollama-cloud", modelId, OVERRIDES, "salt")!; + +describe("behavior report reads per-model overrides the way the runtime does", () => { + test("the adapter really applies the bare-family override to the :tag model (ground truth)", () => { + const parsed: OcxParsedRequest = { + modelId: MODEL, + context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] }, + stream: false, + options: {}, + }; + const body = JSON.parse(createOpenAIChatAdapter(OVERRIDES).buildRequest(parsed).body as string); + expect(body.max_tokens).toBe(1234); + }); + + test("report agrees: limits.maxOutputTokens for the :tag model", () => { + expect(overrideValues(MODEL)["limits.maxOutputTokens"]!.value).toBe(1234); + }); + + test("a case-folded key still resolves", () => { + expect(overrideValues("gpt-oss")["limits.contextWindow"]!.value).toBe(55_555); + }); + + test("an unrelated model gets no override (control)", () => { + expect(overrideValues("glm-5.3")["limits.maxOutputTokens"]!.value).toBeNull(); + }); +}); + +// Model ids are operator-controlled, so one can collide with Object.prototype. +// openai-responses.ts already guards modelPreferHostedTools for exactly this. +describe("a prototype-shaped model id resolves to no override", () => { + test.each(["constructor", "toString", "valueOf", "hasOwnProperty"])( + "%s yields null rather than an inherited function", + (modelId) => { + const v = overrideValues(modelId); + expect(v["limits.contextWindow"]!.value).toBeNull(); + expect(v["limits.maxOutputTokens"]!.value).toBeNull(); + expect(typeof v["modalities.input"]!.value).not.toBe("function"); + }, + ); + + test("so the behavior fingerprint stays computable", () => { + // jcsStringify rejects a function, and resolvePassiveRouteSubjectId swallows the + // throw -- the subject would silently never link. + expect(() => buildBehaviorFingerprintV1(overrideValues("constructor"))).not.toThrow(); + expect(buildBehaviorFingerprintV1(overrideValues("constructor"))) + .toBe(buildBehaviorFingerprintV1(overrideValues("toString"))); + }); +}); + +// Not every override map is family-aware, and the two that are not must stay that way. +// The adapter reads `modelPreferHostedTools` through `hasOwnProperty` +// (`src/adapters/openai-responses.ts:1001`) and `resolveOpenRouterRouting` reads +// `modelOpenRouterRouting` through `Object.hasOwn` (`src/providers/openrouter-routing.ts:89`); +// the type calls the first "Exact-model hosted tools" (`src/types.ts:1584`). Sending +// these through modelRecordValue would be the divergence above with the sign flipped: +// the report would claim an override applies that the adapter will never apply. +const EXACT_ONLY: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://openrouter.ai/api/v1", + apiKey: "sk-test", + authMode: "key", + modelPreferHostedTools: { "gpt-oss": ["image_generation"] }, + modelOpenRouterRouting: { "gpt-oss": { order: ["fireworks"] } }, +} as unknown as OcxProviderConfig; + +const exactConfig = { providers: { "ollama-cloud": EXACT_ONLY } } as unknown as OcxConfig; + +const exactValues = (modelId: string) => + resolveProductionBehaviorValues(exactConfig, "ollama-cloud", modelId, EXACT_ONLY, "salt")!; + +describe("exact-own override maps must not spread to the family", () => { + test("the runtime really does not apply the bare-family entry to the :tag model (ground truth)", () => { + // openRouter routing is resolvable directly, so this half is executable rather than cited. + expect(resolveOpenRouterRouting(EXACT_ONLY, "gpt-oss")).toEqual({ order: ["fireworks"] }); + expect(resolveOpenRouterRouting(EXACT_ONLY, MODEL)).toBeUndefined(); + + // And the divergence is real rather than theoretical: the family-aware primitive + // resolves the entry that the adapter's own-property guard does not see. + expect(modelRecordValue(EXACT_ONLY.modelPreferHostedTools, MODEL)).toEqual(["image_generation"]); + expect(Object.hasOwn(EXACT_ONLY.modelPreferHostedTools!, MODEL)).toBe(false); + }); + + test("report agrees: the :tag model gets no hosted-tool preference", () => { + expect(exactValues(MODEL)["tools.hostedPreference"]!.value).toEqual({ tools: [] }); + }); + + test("report agrees: the :tag model gets no openrouter routing", () => { + expect(exactValues(MODEL)["openrouter.order"]!.value).toEqual([]); + }); + + test("an exact key still resolves on both maps (control)", () => { + const v = exactValues("gpt-oss"); + expect(v["tools.hostedPreference"]!.value).toEqual({ tools: ["image_generation"] }); + expect(v["openrouter.order"]!.value).toEqual(["fireworks"]); + }); + + test("a prototype-shaped id resolves neither map", () => { + // The bare index this replaced walked the prototype chain here too, so these two + // maps had the original bug and must not simply inherit the family-aware fix. + for (const modelId of ["constructor", "toString", "valueOf"]) { + const v = exactValues(modelId); + expect(v["tools.hostedPreference"]!.value).toEqual({ tools: [] }); + expect(v["openrouter.order"]!.value).toEqual([]); + } + }); +});