Skip to content
Merged
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
18 changes: 12 additions & 6 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { COMPACT_PROMPT, decodeCompactionSummary, SUMMARY_PREFIX } from "../resp
import { collectResponsesToolGroups } from "../responses/tool-groups";
import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy";
import { decodeServerSentEvents } from "../lib/sse-decoder";
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
import { modelRecordValue } from "../reasoning-effort";
import type { TranslatorBudget } from "../lib/translator-budget";
Expand Down Expand Up @@ -1171,8 +1171,12 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
let url: string;

if (provider.authMode === "forward") {
const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider);
// OAuth passthrough: ChatGPT backend path is `${baseUrl}/responses` (no /v1).
url = `${provider.baseUrl}/responses`;
const baseUrl = mayForwardCallerCredentials
? CODEX_FORWARD_BASE_URL
: provider.baseUrl.replace(/\/+$/, "");
url = `${baseUrl}/responses`;
if (provider.headers) Object.assign(headers, provider.headers); // static headers first…
const runtimeProvider = provider as {
_codexAccountOverride?: { accessToken: string; chatgptAccountId: string };
Expand All @@ -1181,12 +1185,14 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
if (runtimeProvider._codexAccountRequired && !runtimeProvider._codexAccountOverride) {
throw new Error("Codex pool account auth is required but unavailable");
}
for (const h of FORWARD_HEADERS) {
const v = incoming?.headers.get(h);
if (v) headers[h] = v; // …so forwarded auth always wins.
if (mayForwardCallerCredentials) {
for (const h of FORWARD_HEADERS) {
const v = incoming?.headers.get(h);
if (v) headers[h] = v; // …so forwarded auth always wins.
}
}
const override = runtimeProvider._codexAccountOverride;
if (override) {
if (override && mayForwardCallerCredentials) {
headers["authorization"] = `Bearer ${override.accessToken}`;
headers["chatgpt-account-id"] = override.chatgptAccountId;
}
Expand Down
11 changes: 9 additions & 2 deletions src/providers/openai-sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { extractAccountId } from "../oauth/chatgpt";
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../server/auth-cors";
import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
import {
CODEX_FORWARD_BASE_URL,
isCanonicalOpenAiForwardProvider,
OPENAI_API_PROVIDER_ID,
OPENAI_CODEX_PROVIDER_ID,
Expand Down Expand Up @@ -62,10 +63,16 @@ export function listOpenAiForwardSidecarCandidates(config: OcxConfig): OpenAiFor
? { ...provider, authMode: "forward" as const }
: provider;
if (!isCanonicalOpenAiForwardProvider(canonicalProvider)) return [];
// The predicate accepts harmless trailing-slash variants. Pin the provider returned
// to credential-bearing sidecars so every consumer builds one exact ChatGPT path
// instead of independently concatenating the operator's equivalent spelling.
const pinnedProvider = canonicalProvider.baseUrl === CODEX_FORWARD_BASE_URL
? canonicalProvider
: { ...canonicalProvider, baseUrl: CODEX_FORWARD_BASE_URL };
return [{
providerName: OPENAI_CODEX_PROVIDER_ID,
provider: canonicalProvider,
accountMode: providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, canonicalProvider) ?? "pool",
provider: pinnedProvider,
accountMode: providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, pinnedProvider) ?? "pool",
}];
}

Expand Down
2 changes: 1 addition & 1 deletion src/server/live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ export function keyedLiveUrl(baseUrl: string): string {
}

export function forwardLiveUrl(baseUrl: string, usesBackendShape: boolean): string {
const root = baseUrl.replace(/\/$/, "");
const root = baseUrl.replace(/\/+$/, "");
if (usesBackendShape) return withAvasQuery(`${root}/realtime/calls`);
// Frameless API shape posts to /live without the AVAS query (codex RealtimeCallClient).
return `${root}/live`;
Expand Down
6 changes: 4 additions & 2 deletions src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ import {
} from "../../codex/upstream-host-health";
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
import { isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers";
import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers";
import { slugsEquivalent } from "../../providers/slug-codec";
import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
import { isUsageDebugEnabled } from "../../usage/debug";
Expand Down Expand Up @@ -390,7 +390,9 @@ export async function handleResponsesCompact(
}
throw err;
}
const base = (compactProvider.baseUrl ?? "").replace(/\/$/, "");
const base = isCanonicalOpenAiForwardProvider(compactProvider)
? CODEX_FORWARD_BASE_URL
: (compactProvider.baseUrl ?? "").replace(/\/+$/, "");
if (compactProvider.authMode !== "forward" && compactProvider.apiKey) {
headers.set("authorization", `Bearer ${resolveEnvValue(compactProvider.apiKey)}`);
}
Expand Down
4 changes: 2 additions & 2 deletions tests/codex-metadata-integrity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ describe("Codex metadata integrity", () => {
_codexAccountRequired: boolean;
} = {
adapter: "openai-responses",
baseUrl: "https://chatgpt.test/backend-api/codex",
baseUrl: "https://chatgpt.com/backend-api/codex",
authMode: "forward",
_codexAccountRequired: true,
_codexAccountOverride: {
Expand All @@ -142,7 +142,7 @@ describe("Codex metadata integrity", () => {
test("adapter forward mode preserves genuine client metadata", () => {
const provider: OcxProviderConfig = {
adapter: "openai-responses",
baseUrl: "https://chatgpt.test/backend-api/codex",
baseUrl: "https://chatgpt.com/backend-api/codex",
authMode: "forward",
};
const adapter = createResponsesPassthroughAdapter(provider);
Expand Down
70 changes: 69 additions & 1 deletion tests/openai-responses-passthrough.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,78 @@ const createResponsesPassthroughAdapter = (...args: Parameters<typeof createResp

const provider = {
adapter: "openai-responses",
baseUrl: "https://chatgpt.example/backend-api/codex",
baseUrl: "https://chatgpt.com/backend-api/codex",
authMode: "forward" as const,
};

test("noncanonical forward providers cannot receive caller or runtime credentials", () => {
const userInfoUrl = new URL("https://chatgpt.com/backend-api/codex");
userInfoUrl.username = "user";
userInfoUrl.password = "secret";
for (const baseUrl of [
"https://provider.example/v1/",
"https://chatgpt.com/backend-api/not-codex",
"https://chatgpt.example/backend-api/codex",
"https://chatgpt.com/backend-api/codex?target=custom",
"https://chatgpt.com/backend-api/codex#custom",
userInfoUrl.toString(),
]) {
const adapter = createResponsesPassthroughAdapter({
adapter: "openai-responses",
baseUrl,
authMode: "forward",
headers: { "x-provider-option": "enabled" },
_codexAccountRequired: true,
_codexAccountOverride: {
accessToken: "runtime-secret",
chatgptAccountId: "runtime-account",
},
} as Parameters<typeof createResponsesPassthroughAdapter>[0]);
const request = adapter.buildRequest({
modelId: "test-model",
context: { messages: [] },
stream: true,
options: {},
_rawBody: { model: "test-model", input: "ping" },
}, {
headers: new Headers({
authorization: "Bearer caller-secret",
"chatgpt-account-id": "caller-account",
session_id: "caller-session",
}),
});

expect(request.url).toBe(`${baseUrl.replace(/\/+$/, "")}/responses`);
expect(request.headers["x-provider-option"]).toBe("enabled");
expect(request.headers.authorization).toBeUndefined();
expect(request.headers["chatgpt-account-id"]).toBeUndefined();
expect(request.headers.session_id).toBeUndefined();
}
});

test("canonical forward providers normalize trailing slashes and let the pool override win", () => {
const adapter = createResponsesPassthroughAdapter({
...provider,
baseUrl: "https://chatgpt.com/backend-api/codex///",
_codexAccountRequired: true,
_codexAccountOverride: {
accessToken: "runtime-secret",
chatgptAccountId: "runtime-account",
},
} as Parameters<typeof createResponsesPassthroughAdapter>[0]);
const request = adapter.buildRequest({
modelId: "test-model",
context: { messages: [] },
stream: true,
options: {},
_rawBody: { model: "test-model", input: "ping" },
}, { headers: new Headers({ authorization: "Bearer caller-secret" }) });

expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses");
expect(request.headers.authorization).toBe("Bearer runtime-secret");
expect(request.headers["chatgpt-account-id"]).toBe("runtime-account");
});

test("passthrough serialized-body observation releases after the request settles", () => {
const budget = createTranslatorBudget();
const request = createResponsesPassthroughAdapter(provider).buildRequest({
Expand Down
2 changes: 1 addition & 1 deletion tests/passthrough-override.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { withTestTranslatorBudget } from "./helpers/translator-budget";
const createResponsesPassthroughAdapter = (...args: Parameters<typeof createResponsesPassthroughAdapterProduction>) =>
withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args));

const forwardProvider = { adapter: "openai-responses", baseUrl: "https://chat.openai.com/backend-api/codex", authMode: "forward" as const };
const forwardProvider = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const };

describe("passthrough token override", () => {
test("buildRequest uses original auth when no override", () => {
Expand Down
24 changes: 24 additions & 0 deletions tests/responses-compaction-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,30 @@ describe("compact alternate-account attempt (#913)", () => {
});
}

test("canonical trailing slashes are pinned before native compact sends pool credentials", async () => {
await withPoolEnv("ocx-compact-canonical-url-", async config => {
config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///";
let observedUrl = "";
let observedHeaders = new Headers();
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
observedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
observedHeaders = new Headers(init?.headers);
return jsonResponse(completedPayload("canonical compact response"));
}) as typeof fetch;

const res = await handleResponsesCompact(
compactionRequest(baseCompactionBody({ model: "gpt-5.5" })),
config,
{ model: "", provider: "" },
);

expect(res.status).toBe(200);
expect(observedUrl).toBe("https://chatgpt.com/backend-api/codex/responses/compact");
expect(observedHeaders.get("authorization")).toBe("Bearer pool-a-access-token");
expect(observedHeaders.get("chatgpt-account-id")).toBe("pool_acc_a");
});
});

for (const rejection of [429, 402] as const) {
test(`a pre-body ${rejection} tries exactly one alternate account`, async () => {
await withPoolEnv(`ocx-compact-alt-${rejection}-`, async config => {
Expand Down
4 changes: 3 additions & 1 deletion tests/server-images.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,9 @@ test("image response byte reader enforces the stream cap when Content-Length is
test("POST /v1/images/generations relays to the ChatGPT forward provider with forwarded auth", async () => {
const captured: CapturedRequest[] = [];
const upstream = fakeImagesUpstream(captured);
saveConfig(forwardConfig(upstream.url.toString().replace(/\/$/, "")));
const config = forwardConfig();
config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///";
saveConfig(config);

const server = startServer(0);
try {
Expand Down
7 changes: 6 additions & 1 deletion tests/server-live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,9 @@ function multipartLiveBody(
test("POST /v1/live rewrites ChatGPT multipart into backend realtime/calls JSON", async () => {
const captured: CapturedRequest[] = [];
const upstream = fakeLiveUpstream(captured);
saveConfig(forwardConfig());
const config = forwardConfig();
config.providers.openai!.baseUrl = "https://chatgpt.com/backend-api/codex///";
saveConfig(config);

const server = startServer(0);
try {
Expand Down Expand Up @@ -612,6 +614,9 @@ test("buildLiveSidebandUpstreamWsUrl maps Frameless and Realtime join shapes", a
expect(forwardLiveUrl("https://chatgpt.com/backend-api/codex", true)).toBe(
"https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas",
);
expect(forwardLiveUrl("https://chatgpt.com/backend-api/codex///", true)).toBe(
"https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas",
);
expect(keyedLiveUrl("https://api.openai.com/v1")).toBe(
"https://api.openai.com/v1/realtime/calls?intent=quicksilver&architecture=avas",
);
Expand Down
5 changes: 4 additions & 1 deletion tests/vision-sidecar-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,15 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => {
let sidecarBody = "";
let sidecarAuth: string | null = null;
let sidecarAccount: string | null = null;
let sidecarPath = "";
let sidecarHits = 0;
upstream = serveUpstream(b => { upstreamBody = b; });
sidecar = serveSidecar((req, b) => {
sidecarHits += 1;
sidecarBody = b;
sidecarAuth = req.headers.get("authorization");
sidecarAccount = req.headers.get("chatgpt-account-id");
sidecarPath = new URL(req.url).pathname;
});
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
Expand All @@ -185,7 +187,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => {
openai: {
adapter: "openai-responses",
authMode: "forward",
baseUrl: "https://chatgpt.com/backend-api/codex",
baseUrl: "https://chatgpt.com/backend-api/codex///",
codexAccountMode: "direct",
},
},
Expand All @@ -207,6 +209,7 @@ describe("vision sidecar fallback (issue #88, end-to-end)", () => {

// Activation evidence: the sidecar actually ran, got the image + OAuth passthrough.
expect(sidecarHits).toBe(1);
expect(sidecarPath).toBe("/responses");
expect(sidecarAuth).toBe(`Bearer ${token}`);
expect(sidecarAccount).toBe("acct-vision-sidecar");
expect(sidecarBody).toContain("input_image");
Expand Down
42 changes: 40 additions & 2 deletions tests/web-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
import { parseRequest } from "../src/responses/parser";
import { planWebSearch, shouldResolveOpenAiWebSearchSidecar, webSearchStallTimeoutSec } from "../src/web-search";
import { runWithWebSearch as runWithWebSearchProduction, type WebSearchLoopDeps } from "../src/web-search/loop";
import { runWebSearch as runOpenAiWebSearch } from "../src/web-search/executor";
import { createOpenAIChatAdapter } from "../src/adapters/openai-chat";
import { headersForCodexAuthContext } from "../src/codex/auth-context";
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar } from "../src/providers/openai-sidecar";
Expand Down Expand Up @@ -162,14 +163,17 @@ describe("web-search sidecar planning", () => {
providers: {
openai: {
adapter: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api/codex",
baseUrl: "https://chatgpt.com/backend-api/codex///",
codexAccountMode: "direct",
},
},
};
expect(listOpenAiForwardSidecarCandidates(canonicalWithoutAuthMode)).toMatchObject([{
providerName: "openai",
provider: { authMode: "forward" },
provider: {
authMode: "forward",
baseUrl: "https://chatgpt.com/backend-api/codex",
},
accountMode: "direct",
}]);

Expand Down Expand Up @@ -378,6 +382,40 @@ describe("web-search sidecar planning", () => {
const originalFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = originalFetch; });

test("OpenAI web-search execution uses the pinned canonical URL and selected credentials", async () => {
const cfg = config({
providers: {
routed: routedProvider,
openai: {
adapter: "openai-responses",
baseUrl: "https://chatgpt.com/backend-api/codex///",
authMode: "forward",
codexAccountMode: "direct",
},
},
});
const candidate = listOpenAiForwardSidecarCandidates(cfg)[0]!;
let observedUrl = "";
let observedHeaders = new Headers();
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
observedUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
observedHeaders = new Headers(init?.headers);
return new Response("data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } });
}) as typeof fetch;

await runOpenAiWebSearch(
"current docs",
{ type: "web_search" },
candidate.provider,
new Headers({ authorization: "Bearer selected-token", "chatgpt-account-id": "selected-account" }),
{ model: "gpt-5.6-luna", reasoning: "low", timeoutMs: 1_000 },
);

expect(observedUrl).toBe("https://chatgpt.com/backend-api/codex/responses");
expect(observedHeaders.get("authorization")).toBe("Bearer selected-token");
expect(observedHeaders.get("chatgpt-account-id")).toBe("selected-account");
});

async function collectSse(stream: ReadableStream<Uint8Array>): Promise<{ event?: string; data: Record<string, unknown> }[]> {
const reader = stream.getReader();
const decoder = new TextDecoder();
Expand Down
Loading