diff --git a/src/providers/openai-tiers-destination.ts b/src/providers/openai-tiers-destination.ts new file mode 100644 index 0000000000..5c6124f29d --- /dev/null +++ b/src/providers/openai-tiers-destination.ts @@ -0,0 +1,102 @@ +import type { OcxProviderConfig } from "../types"; +import { openaiResponsesUrl } from "../adapters/openai-responses-url"; + +export const OPENAI_CODEX_PROVIDER_ID = "openai"; +export const LEGACY_OPENAI_MULTI_PROVIDER_ID = "openai-multi"; +export const OPENAI_API_PROVIDER_ID = "openai-apikey"; +export const LEGACY_CHATGPT_PROVIDER_ID = "chatgpt"; + +export const CODEX_FORWARD_BASE_URL = "https://chatgpt.com/backend-api/codex"; + +function normalizedBaseUrl(value: string): string | undefined { + try { + const url = new URL(value.trim()); + if (url.username || url.password || url.search || url.hash) return undefined; + const path = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${path}`; + } catch { + return undefined; + } +} + +export function isCanonicalOpenAiForwardProvider(provider: OcxProviderConfig): boolean { + return provider.adapter === "openai-responses" + && provider.authMode === "forward" + && normalizedBaseUrl(provider.baseUrl) === CODEX_FORWARD_BASE_URL; +} + +const OPENAI_API_ORIGIN = "https://api.openai.com"; +const OPENAI_API_BASE_URL = `${OPENAI_API_ORIGIN}/v1`; +const OPENAI_API_RESPONSES_URL = `${OPENAI_API_BASE_URL}/responses`; + +/** + * The Responses endpoint the adapter would actually POST key-auth traffic to, normalized. + * + * Mirrors the adapter's own construction (`src/adapters/openai-responses.ts`): a configured + * `responsesPath` is appended to the base verbatim, and only the default branch runs the + * `/v1/responses` suffix normalization. Classifying on the base URL alone would call + * `baseUrl: "https://api.openai.com"` with `responsesPath: "/other"` official even though that + * request never reaches the official Responses endpoint. + */ +function resolvedResponsesEndpoint(provider: OcxProviderConfig): string | undefined { + try { + const raw = provider.responsesPath === undefined + ? openaiResponsesUrl(provider.baseUrl) + : `${provider.baseUrl.replace(/\/$/, "")}${provider.responsesPath}`; + return normalizedBaseUrl(raw); + } catch { + return undefined; + } +} + +function isOfficialOpenAiResponsesDestination(provider: OcxProviderConfig): boolean { + // Exact normalized URL keeps lookalike/suffix hosts out of this set: `api.openai.com.evil.test` + // resolves to its own origin, never to the official one. + return resolvedResponsesEndpoint(provider) === OPENAI_API_RESPONSES_URL; +} + +/** + * Whether this provider can serve `POST /responses/compact`. The canonical ChatGPT + * backend can, and so can the official OpenAI API — but an arbitrary gateway that + * merely speaks the Responses wire cannot, and calling it there fails compaction + * with an unhelpful error instead of falling back to a routed summary (#422). + */ +export function supportsNativeResponsesCompactEndpoint( + providerName: string, + provider: OcxProviderConfig, +): boolean { + if (isCanonicalOpenAiForwardProvider(provider)) return true; + return providerName === OPENAI_API_PROVIDER_ID + && provider.adapter === "openai-responses" + && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL; +} + +/** + * Whether this destination is an OpenAI-operated Responses backend — the canonical ChatGPT Codex + * surface or the official OpenAI API. + * + * Deliberately not keyed on `authMode === "forward"`: a noncanonical forward provider does not + * receive the caller's credentials (see the forward-header gate in the Responses adapter), so + * forward auth says nothing about which backend is on the other end. + */ +export function isOpenAiOperatedResponsesDestination(provider: OcxProviderConfig): boolean { + if (isCanonicalOpenAiForwardProvider(provider)) return true; + return provider.adapter === "openai-responses" + && isOfficialOpenAiResponsesDestination(provider); +} + +/** + * Whether this destination can decode a native (non-`ocx1:`) compaction blob. + * + * Only the backend that minted a blob can decode it. `authMode: "forward"` alone is not a signal: + * the adapter forwards caller credentials only to the canonical ChatGPT Codex surface, while a + * noncanonical forward provider receives no caller credentials and may point at any backend. + * + * Relay only to an OpenAI-operated destination or a destination whose operator explicitly opts in. + * Keyed by destination rather than provider id: a blob's issuer is the URL that produced it, not the + * local config key a replay travels under. + */ +export function destinationDecodesNativeCompactionBlob(provider: OcxProviderConfig): boolean { + return isOpenAiOperatedResponsesDestination(provider) + || provider.decodesNativeCompactionBlobs === true; +} diff --git a/src/providers/openai-tiers.ts b/src/providers/openai-tiers.ts index 5e99c89963..9b33d6e573 100644 --- a/src/providers/openai-tiers.ts +++ b/src/providers/openai-tiers.ts @@ -2,13 +2,9 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig, ProviderCostOverla import { OPENAI_PROVIDER_TIER_VERSION } from "../types"; import { openaiResponsesUrl } from "../adapters/openai-responses-url"; import { MAX_COST4_RATE } from "../usage/expected-prices"; +import { OPENAI_CODEX_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, LEGACY_CHATGPT_PROVIDER_ID, CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "./openai-tiers-destination"; +export { OPENAI_CODEX_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, LEGACY_CHATGPT_PROVIDER_ID, CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint, isOpenAiOperatedResponsesDestination, destinationDecodesNativeCompactionBlob } from "./openai-tiers-destination"; -export const OPENAI_CODEX_PROVIDER_ID = "openai"; -export const LEGACY_OPENAI_MULTI_PROVIDER_ID = "openai-multi"; -export const OPENAI_API_PROVIDER_ID = "openai-apikey"; -export const LEGACY_CHATGPT_PROVIDER_ID = "chatgpt"; - -export const CODEX_FORWARD_BASE_URL = "https://chatgpt.com/backend-api/codex"; const LEGACY_OPENAI_MULTI_PREFIX = `${LEGACY_OPENAI_MULTI_PROVIDER_ID}/`; function canonicalCodexForwardProvider(mode: CodexAccountMode): OcxProviderConfig { @@ -20,99 +16,6 @@ function canonicalCodexForwardProvider(mode: CodexAccountMode): OcxProviderConfi }; } -function normalizedBaseUrl(value: string): string | undefined { - try { - const url = new URL(value.trim()); - if (url.username || url.password || url.search || url.hash) return undefined; - const path = url.pathname.replace(/\/+$/, ""); - return `${url.origin}${path}`; - } catch { - return undefined; - } -} - -export function isCanonicalOpenAiForwardProvider(provider: OcxProviderConfig): boolean { - return provider.adapter === "openai-responses" - && provider.authMode === "forward" - && normalizedBaseUrl(provider.baseUrl) === CODEX_FORWARD_BASE_URL; -} - -const OPENAI_API_ORIGIN = "https://api.openai.com"; -const OPENAI_API_BASE_URL = `${OPENAI_API_ORIGIN}/v1`; -const OPENAI_API_RESPONSES_URL = `${OPENAI_API_BASE_URL}/responses`; - -/** - * The Responses endpoint the adapter would actually POST key-auth traffic to, normalized. - * - * Mirrors the adapter's own construction (`src/adapters/openai-responses.ts`): a configured - * `responsesPath` is appended to the base verbatim, and only the default branch runs the - * `/v1/responses` suffix normalization. Classifying on the base URL alone would call - * `baseUrl: "https://api.openai.com"` with `responsesPath: "/other"` official even though that - * request never reaches the official Responses endpoint. - */ -function resolvedResponsesEndpoint(provider: OcxProviderConfig): string | undefined { - try { - const raw = provider.responsesPath === undefined - ? openaiResponsesUrl(provider.baseUrl) - : `${provider.baseUrl.replace(/\/$/, "")}${provider.responsesPath}`; - return normalizedBaseUrl(raw); - } catch { - return undefined; - } -} - -function isOfficialOpenAiResponsesDestination(provider: OcxProviderConfig): boolean { - // Exact normalized URL keeps lookalike/suffix hosts out of this set: `api.openai.com.evil.test` - // resolves to its own origin, never to the official one. - return resolvedResponsesEndpoint(provider) === OPENAI_API_RESPONSES_URL; -} - -/** - * Whether this provider can serve `POST /responses/compact`. The canonical ChatGPT - * backend can, and so can the official OpenAI API — but an arbitrary gateway that - * merely speaks the Responses wire cannot, and calling it there fails compaction - * with an unhelpful error instead of falling back to a routed summary (#422). - */ -export function supportsNativeResponsesCompactEndpoint( - providerName: string, - provider: OcxProviderConfig, -): boolean { - if (isCanonicalOpenAiForwardProvider(provider)) return true; - return providerName === OPENAI_API_PROVIDER_ID - && provider.adapter === "openai-responses" - && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL; -} - -/** - * Whether this destination is an OpenAI-operated Responses backend — the canonical ChatGPT Codex - * surface or the official OpenAI API. - * - * Deliberately not keyed on `authMode === "forward"`: a noncanonical forward provider does not - * receive the caller's credentials (see the forward-header gate in the Responses adapter), so - * forward auth says nothing about which backend is on the other end. - */ -export function isOpenAiOperatedResponsesDestination(provider: OcxProviderConfig): boolean { - if (isCanonicalOpenAiForwardProvider(provider)) return true; - return provider.adapter === "openai-responses" - && isOfficialOpenAiResponsesDestination(provider); -} - -/** - * Whether this destination can decode a native (non-`ocx1:`) compaction blob. - * - * Only the backend that minted a blob can decode it. `authMode: "forward"` alone is not a signal: - * the adapter forwards caller credentials only to the canonical ChatGPT Codex surface, while a - * noncanonical forward provider receives no caller credentials and may point at any backend. - * - * Relay only to an OpenAI-operated destination or a destination whose operator explicitly opts in. - * Keyed by destination rather than provider id: a blob's issuer is the URL that produced it, not the - * local config key a replay travels under. - */ -export function destinationDecodesNativeCompactionBlob(provider: OcxProviderConfig): boolean { - return isOpenAiOperatedResponsesDestination(provider) - || provider.decodesNativeCompactionBlobs === true; -} - export interface OpenAiTierMigrationProjection { config: OcxConfig; changed: boolean; diff --git a/tests/adapters/openai/openai-provider-option.test.ts b/tests/adapters/openai/openai-provider-option.test.ts index fa80f73556..5e1943ceb0 100644 --- a/tests/adapters/openai/openai-provider-option.test.ts +++ b/tests/adapters/openai/openai-provider-option.test.ts @@ -1,4 +1,10 @@ import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../../helpers/repo-root"; +import { + isCanonicalOpenAiForwardProvider as destinationIsCanonicalOpenAiForwardProvider, + OPENAI_CODEX_PROVIDER_ID as DESTINATION_OPENAI_CODEX_PROVIDER_ID, +} from "../../../src/providers/openai-tiers-destination"; import { getDefaultConfig } from "../../../src/config"; import { deriveInitProviders, deriveProviderPresets, listRegistryEntries, providerConfigSeed } from "../../../src/providers/derive"; import { getProviderRegistryEntry, providerCodexAccountMode } from "../../../src/providers/registry"; @@ -100,3 +106,10 @@ describe("OpenAI single-provider option foundation", () => { expect(getDefaultConfig().providers.openai).toMatchObject({ codexAccountMode: "pool" }); }); }); + +test("destination leaf preserves facade bindings without importing the facade", () => { + expect(destinationIsCanonicalOpenAiForwardProvider).toBe(isCanonicalOpenAiForwardProvider); + expect(DESTINATION_OPENAI_CODEX_PROVIDER_ID).toBe(OPENAI_CODEX_PROVIDER_ID); + const source = readFileSync(repoPath("src/providers/openai-tiers-destination.ts"), "utf8"); + expect(source).not.toMatch(/(?:from\s*|import\s*(?:\(\s*)?)["']\.\/openai-tiers(?:\.ts)?["']/); +});