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
44 changes: 41 additions & 3 deletions src/routing/compatibility/behavior.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<T>(map: Record<string, T> | 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<T>(map: Record<string, T> | 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;
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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),
Expand Down
126 changes: 126 additions & 0 deletions tests/routing-compatibility-model-matching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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([]);
}
});
});
Loading