diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index b93222d6ac..3ce0e9214d 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -14,11 +14,14 @@ import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalo import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing"; import { canForwardForeignServiceTierForChatModel, + fastPolicyForModel, supportsServiceTierForModel, } from "../providers/service-tier"; import { canonicalFastTierMarker, createAdapterTierMetadata, + decideTier, + type ResolvedFastPolicy, } from "../providers/fastwire"; import { openaiChatCompletionsUrl } from "./openai-chat-url"; import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema"; @@ -95,6 +98,8 @@ export function buildOpenAIChatPassthroughRequest( rawBody: Record, modelId: string, stream: boolean, + fastPolicy: ResolvedFastPolicy = fastPolicyForModel(provider, modelId, undefined, "chat"), + fastMode?: boolean, ): AdapterRequest { const { url, headers, hasCredential } = openAIChatTransport(provider); @@ -122,7 +127,16 @@ export function buildOpenAIChatPassthroughRequest( // `:` siblings the operator never opted out, silently returning prose. if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format; - if (provider.chatServiceTier && rawBody.service_tier !== undefined) { + // Run the same complete Fast policy as the translated Chat path, including explicit + // fastMode and foreign-tier handling. On inherited canonical Fast, the passthrough still + // retains the caller's exact spelling; forced Fast uses the policy-owned wire value. + const callerTier = typeof rawBody.service_tier === "string" ? rawBody.service_tier : undefined; + const tierDecision = decideTier(fastPolicy, fastMode, callerTier); + if (tierDecision.kind === "set") { + body.service_tier = fastMode === undefined && canonicalFastTierMarker(callerTier) !== undefined + ? callerTier + : tierDecision.value; + } else if (tierDecision.kind === "forward-caller" && rawBody.service_tier !== undefined) { body.service_tier = rawBody.service_tier; } if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) { @@ -1284,6 +1298,23 @@ function thinkingBudgetForEffort(parsed: OcxParsedRequest, reasoningEffort: stri return fraction === undefined ? undefined : Math.max(1, Math.floor(maxBudget * fraction)); } +function canSerializeOpenAIChatServiceTier( + provider: OcxProviderConfig, + modelId: string, + serviceTier: unknown, + tierDecision?: OcxParsedRequest["options"]["tierDecision"], +): boolean { + if (serviceTier === undefined) return false; + if (tierDecision !== undefined) { + return tierDecision.kind === "set" || tierDecision.kind === "forward-caller"; + } + const callerTier = typeof serviceTier === "string" ? serviceTier : undefined; + const callerCanonicalFast = canonicalFastTierMarker(callerTier) !== undefined; + const capability = supportsServiceTierForModel(provider, modelId); + const callerTierForwardAllowed = canForwardForeignServiceTierForChatModel(provider, modelId); + return callerTierForwardAllowed || (callerCanonicalFast && capability === true); +} + export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter { return { name: "openai-chat", @@ -1306,13 +1337,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // unclassified Chat routes remain behind the caller-forwarding opt-in. const serviceTier = parsed.options.serviceTier; const tierDecision = parsed.options.tierDecision; - const callerCanonicalFast = canonicalFastTierMarker(serviceTier) !== undefined; - const callerTierForwardAllowed = canForwardForeignServiceTierForChatModel(provider, parsed.modelId); - const canonicalFastCapability = callerCanonicalFast - && supportsServiceTierForModel(provider, parsed.modelId) === true; - const canSerializeServiceTier = tierDecision?.kind === "set" - || tierDecision?.kind === "forward-caller" - || (tierDecision === undefined && (callerTierForwardAllowed || canonicalFastCapability)); + const canSerializeServiceTier = canSerializeOpenAIChatServiceTier( + provider, + parsed.modelId, + serviceTier, + tierDecision, + ); if (canSerializeServiceTier && serviceTier !== undefined) { body.service_tier = serviceTier; } diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index cc6738b313..b27f29c962 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -27,6 +27,7 @@ import { rateLimitRetryPolicyFor, rotateProviderTransportOn429, } from "../providers/key-failover"; +import { fastPolicyForModel } from "../providers/service-tier"; import type { RouteResult } from "../router"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./responses/fetch-helpers"; @@ -154,8 +155,16 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio translatorBudget.chargeRetained(bytes, { kind: "request_copies" }); retainedRequestBytes = bytes; }; + const buildActiveRequest = () => buildOpenAIChatPassthroughRequest( + activeProvider, + options.chatBody, + route.modelId, + requestedStream, + fastPolicyForModel(activeProvider, route.modelId, route.providerName, "chat"), + config.fastMode, + ); try { - activeRequest = buildOpenAIChatPassthroughRequest(activeProvider, options.chatBody, route.modelId, requestedStream); + activeRequest = buildActiveRequest(); retainRequest(activeRequest); } catch (error) { releaseRetainedRequest(); @@ -222,7 +231,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio activeProvider = rotated; activeAdapter = createOpenAIChatAdapter(activeProvider); releaseRetainedRequest(); - activeRequest = buildOpenAIChatPassthroughRequest(activeProvider, options.chatBody, route.modelId, requestedStream); + activeRequest = buildActiveRequest(); retainRequest(activeRequest); response = await send(activeRequest, "key-429"); } diff --git a/tests/fastwire-characterization-wire.test.ts b/tests/fastwire-characterization-wire.test.ts index 0b4e89fbf3..4ae9e38242 100644 --- a/tests/fastwire-characterization-wire.test.ts +++ b/tests/fastwire-characterization-wire.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { buildOpenAIChatPassthroughRequest } from "../src/adapters/openai-chat"; import { chatCompletionsToResponsesBody } from "../src/chat/inbound"; +import { fastPolicyForModel } from "../src/providers/service-tier"; import * as adapterResolveModule from "../src/server/adapter-resolve"; import type { RequestLogContext } from "../src/server/request-log"; import { handleResponses } from "../src/server/responses/core"; @@ -364,17 +365,19 @@ describe("FastWire characterization: rawBody observation point", () => { }); describe("FastWire characterization: known bugs", () => { - test("characterization (known bug): native chat passthrough ignores exact-model false", () => { + test("characterization: native chat passthrough honors exact-model false", () => { + // FastWire #1886 native-chat policy fix: exact-model false now strips the caller tier. + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://native-chat.example.test/v1", + authMode: "key", + apiKey: "sk-test", + supportsServiceTier: true, + chatServiceTier: true, + modelSupportsServiceTier: { model: false }, + }; const request = buildOpenAIChatPassthroughRequest( - { - adapter: "openai-chat", - baseUrl: "https://native-chat.example.test/v1", - authMode: "key", - apiKey: "sk-test", - supportsServiceTier: true, - chatServiceTier: true, - modelSupportsServiceTier: { model: false }, - }, + provider, { model: "model", messages: [{ role: "user", content: "ping" }], @@ -382,9 +385,10 @@ describe("FastWire characterization: known bugs", () => { }, "model", false, + fastPolicyForModel(provider, "model", "native-chat", "chat"), ); const body = JSON.parse(request.body) as Record; - expect(body.service_tier).toBe("flex"); + expect(body).not.toHaveProperty("service_tier"); }); test("characterization: chat-to-responses conversion preserves service_tier", () => { diff --git a/tests/openai-chat-native-policy.test.ts b/tests/openai-chat-native-policy.test.ts new file mode 100644 index 0000000000..f9657551f5 --- /dev/null +++ b/tests/openai-chat-native-policy.test.ts @@ -0,0 +1,341 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildOpenAIChatPassthroughRequest, + createOpenAIChatAdapter, +} from "../src/adapters/openai-chat"; +import { + decideTier, + tierValueAfterDecision, +} from "../src/providers/fastwire"; +import { clearKeyCooldowns } from "../src/providers/key-failover"; +import { fastPolicyForModel } from "../src/providers/service-tier"; +import { handleChatCompletions } from "../src/server/chat-completions"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const PROVIDER_NAME = "native-tier-fixture"; +const MODEL_ID = "model"; +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; + clearKeyCooldowns(PROVIDER_NAME); +}); + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "openai-chat", + baseUrl: "https://native-tier.example.test/v1", + authMode: "key", + apiKey: "sk-test", + ...overrides, + }; +} + +function nativeBody( + target: OcxProviderConfig, + callerTier: string | undefined, + modelId = MODEL_ID, + fastMode?: boolean, +): Record { + const policy = fastPolicyForModel(target, modelId, PROVIDER_NAME, "chat"); + const request = buildOpenAIChatPassthroughRequest( + target, + { + model: modelId, + messages: [{ role: "user", content: "ping" }], + ...(callerTier === undefined ? {} : { service_tier: callerTier }), + }, + modelId, + false, + policy, + fastMode, + ); + return JSON.parse(request.body) as Record; +} + +function mainPathBody( + target: OcxProviderConfig, + callerTier: string | undefined, + modelId = MODEL_ID, + fastMode?: boolean, +): Record { + const policy = fastPolicyForModel(target, modelId, PROVIDER_NAME, "chat"); + const tierDecision = decideTier(policy, fastMode, callerTier); + const serviceTier = tierValueAfterDecision(tierDecision, callerTier); + const parsed: OcxParsedRequest = { + modelId, + stream: false, + context: { messages: [{ role: "user", content: "ping" }], tools: [] }, + options: { + ...(serviceTier === undefined ? {} : { serviceTier }), + tierDecision, + }, + }; + const request = createOpenAIChatAdapter(target).buildRequest(parsed); + return JSON.parse(request.body) as Record; +} + +function forwardsTier(body: Record): boolean { + return Object.hasOwn(body, "service_tier"); +} + +describe("native Chat passthrough service-tier policy", () => { + test.each([ + { + name: "provider false stays fail-closed even with CallerTierForward", + config: { supportsServiceTier: false, chatServiceTier: true }, + callerTier: "priority", + expectedTier: undefined, + }, + { + name: "exact-model false narrows provider support", + config: { + supportsServiceTier: true, + chatServiceTier: true, + modelSupportsServiceTier: { [MODEL_ID]: false }, + }, + callerTier: "priority", + expectedTier: undefined, + }, + { + name: "exact-model true authorizes canonical Fast without CallerTierForward", + config: { modelSupportsServiceTier: { [MODEL_ID]: true } }, + callerTier: "FAST", + expectedTier: "FAST", + }, + { + name: "exact-model true does not authorize a foreign tier", + config: { modelSupportsServiceTier: { [MODEL_ID]: true } }, + callerTier: "flex", + expectedTier: undefined, + }, + { + name: "unclassified support drops a caller tier without CallerTierForward", + config: {}, + callerTier: "flex", + expectedTier: undefined, + }, + { + name: "unclassified support forwards a caller tier with CallerTierForward", + config: { chatServiceTier: true }, + callerTier: "flex", + expectedTier: "flex", + }, + { + name: "classified foreign-tier drop overrides CallerTierForward", + config: { + supportsServiceTier: true, + chatServiceTier: true, + fastWire: { + kind: "service-tier", + canonicalToWire: { priority: "priority" }, + foreignCallerTiers: "drop", + }, + }, + callerTier: "flex", + expectedTier: undefined, + }, + ] as const)("$name", ({ config, callerTier, expectedTier }) => { + const body = nativeBody(provider(config), callerTier); + if (expectedTier === undefined) expect(body).not.toHaveProperty("service_tier"); + else expect(body.service_tier).toBe(expectedTier); + }); + + test("the native handler passes its resolved fail-closed policy to the builder", async () => { + const captured: Record[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return Response.json({ + id: "chatcmpl_native_tier", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + }); + }) as typeof fetch; + const target = provider({ supportsServiceTier: false, chatServiceTier: true }); + const config = { + port: 0, + defaultProvider: PROVIDER_NAME, + providers: { [PROVIDER_NAME]: target }, + } as OcxConfig; + + const response = await handleChatCompletions( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: `${PROVIDER_NAME}/${MODEL_ID}`, + messages: [{ role: "user", content: "ping" }], + service_tier: "priority", + }), + }), + config, + { model: "", provider: "" }, + ); + + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + expect(captured[0]).not.toHaveProperty("service_tier"); + }); + + test("forced Fast injects the policy wire value and forced default drops the caller tier", () => { + const target = provider({ supportsServiceTier: true, chatServiceTier: true }); + + expect(nativeBody(target, "flex", MODEL_ID, true).service_tier).toBe("priority"); + expect(nativeBody(target, undefined, MODEL_ID, true).service_tier).toBe("priority"); + expect(nativeBody(target, "priority", MODEL_ID, false)).not.toHaveProperty("service_tier"); + }); + + test("key failover rebuilds the request without reintroducing a dropped foreign tier", async () => { + const previousHome = process.env.OPENCODEX_HOME; + const home = mkdtempSync(join(tmpdir(), "ocx-native-tier-failover-")); + process.env.OPENCODEX_HOME = home; + const captured: Array<{ authorization: string | null; body: Record }> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + captured.push({ + authorization: new Headers(init?.headers).get("authorization"), + body: JSON.parse(String(init?.body ?? "{}")) as Record, + }); + if (captured.length === 1) { + return Response.json({ error: { message: "rate limited" } }, { + status: 429, + headers: { "retry-after": "0" }, + }); + } + return Response.json({ + id: "chatcmpl_native_tier_failover", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + }); + }) as typeof fetch; + const target = provider({ + supportsServiceTier: true, + chatServiceTier: true, + fastWire: { + kind: "service-tier", + canonicalToWire: { priority: "priority" }, + foreignCallerTiers: "drop", + }, + apiKey: "key-one", + apiKeyPool: [{ id: "one", key: "key-one" }, { id: "two", key: "key-two" }], + }); + const config = { + port: 0, + defaultProvider: PROVIDER_NAME, + providers: { [PROVIDER_NAME]: target }, + } as OcxConfig; + + try { + const response = await handleChatCompletions( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: `${PROVIDER_NAME}/${MODEL_ID}`, + messages: [{ role: "user", content: "ping" }], + service_tier: "flex", + }), + }), + config, + { model: "", provider: "" }, + ); + + expect(response.status).toBe(200); + expect(captured.map(entry => entry.authorization)).toEqual(["Bearer key-one", "Bearer key-two"]); + expect(captured).toHaveLength(2); + for (const entry of captured) expect(entry.body).not.toHaveProperty("service_tier"); + } finally { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(home, { recursive: true, force: true }); + } + }); +}); + +describe("main and native Chat tier authorization parity", () => { + test.each([ + { + name: "provider fail-closed", + config: { supportsServiceTier: false, chatServiceTier: true }, + callerTier: "priority", + forwarded: false, + }, + { + name: "exact-model fail-closed", + config: { + supportsServiceTier: true, + chatServiceTier: true, + modelSupportsServiceTier: { [MODEL_ID]: false }, + }, + callerTier: "priority", + forwarded: false, + }, + { + name: "exact-model canonical Fast", + config: { modelSupportsServiceTier: { [MODEL_ID]: true } }, + callerTier: "fast", + forwarded: true, + mainTier: "priority", + nativeTier: "fast", + }, + { + name: "exact-model foreign tier", + config: { modelSupportsServiceTier: { [MODEL_ID]: true } }, + callerTier: "flex", + forwarded: false, + }, + { + name: "unclassified without CallerTierForward", + config: {}, + callerTier: "priority", + forwarded: false, + }, + { + name: "unclassified with CallerTierForward", + config: { chatServiceTier: true }, + callerTier: "flex", + forwarded: true, + mainTier: "flex", + nativeTier: "flex", + }, + { + name: "classified foreign-tier drop with CallerTierForward", + config: { + supportsServiceTier: true, + chatServiceTier: true, + fastWire: { + kind: "service-tier", + canonicalToWire: { priority: "priority" }, + foreignCallerTiers: "drop", + }, + }, + callerTier: "flex", + forwarded: false, + }, + ] as const)("$name makes the same forward/drop decision", row => { + const target = provider(row.config); + const main = mainPathBody(target, row.callerTier); + const native = nativeBody(target, row.callerTier); + + expect(forwardsTier(main)).toBe(row.forwarded); + expect(forwardsTier(native)).toBe(row.forwarded); + expect(forwardsTier(native)).toBe(forwardsTier(main)); + if (row.forwarded) { + expect(main.service_tier).toBe(row.mainTier); + expect(native.service_tier).toBe(row.nativeTier); + } + }); + + test("forced Fast and forced default make the same decision on both Chat paths", () => { + const target = provider({ supportsServiceTier: true, chatServiceTier: true }); + + for (const fastMode of [true, false] as const) { + const main = mainPathBody(target, "flex", MODEL_ID, fastMode); + const native = nativeBody(target, "flex", MODEL_ID, fastMode); + expect(forwardsTier(native)).toBe(forwardsTier(main)); + expect(native.service_tier).toBe(main.service_tier); + } + }); +}); diff --git a/tests/openrouter-provider-routing.test.ts b/tests/openrouter-provider-routing.test.ts index b6ee368f3f..7a833a5b7a 100644 --- a/tests/openrouter-provider-routing.test.ts +++ b/tests/openrouter-provider-routing.test.ts @@ -7,6 +7,7 @@ import { openRouterRoutingConfigError, openRouterProviderPayload, } from "../src/providers/openrouter-routing"; +import { fastPolicyForModel } from "../src/providers/service-tier"; import { clearKeyCooldowns, rotateProviderTransportOn429 } from "../src/providers/key-failover"; import { routeModel } from "../src/router"; import { providerManagementConfigError, safeConfigDTO } from "../src/server/auth-cors"; @@ -39,7 +40,7 @@ function passthroughBody( const request = buildOpenAIChatPassthroughRequest(providerConfig, { messages: [{ role: "user", content: "hello" }], ...rawBody, - }, modelId, false); + }, modelId, false, fastPolicyForModel(providerConfig, modelId, undefined, "chat")); return JSON.parse(request.body as string) as Record; }