From 4e883460830f09271a12974a31ddab0ce768b220 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 29 Oct 2026 12:23:50 -0700 Subject: [PATCH] feat: send chatgpt-account-id from the source's quirks An Interchange catalog source carries no providerOptions, so a deployment can now state the account in CodexQuirks.accountId. A per-call option still overrides it. --- AGENTS.md | 2 +- README.md | 4 ++-- src/quirks.ts | 11 +++++++++-- src/responses-adapter.test.ts | 23 ++++++++++++++++++++++- src/responses-adapter.ts | 10 +++++++--- 5 files changed, 41 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f48a26c..ca56dc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ than reimplementing OAuth or the Responses wire protocol. - `src/constants.ts` — endpoints, client id, headers, timeouts. - `src/oauth.ts` — `codexOAuthConfig`, `accountIdFromIdToken`, token-response mapping. - `src/instructions.ts` — the bridge-message wrap for the host's operating prompt. -- `src/quirks.ts` — `CodexQuirks`, the host-identity bag `wrapCodexBridgeMessage` needs. +- `src/quirks.ts` — `CodexQuirks`, the host-identity bag `wrapCodexBridgeMessage` needs, plus the optional `chatgpt-account-id`. - `src/responses-adapter.ts` — `createCodexResponsesAdapter`, a plain `(source, quirks?)` `AdapterFactory`. - `src/content-type-repair.ts` — `withCodexContentTypeRepair`, a fetch decorator for a backend content-type bug. - `src/index.ts` — the public surface; nothing else is imported by consumers. diff --git a/README.md b/README.md index 3c83813..cf71a2a 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ process.stdout.write("\n"); | `codexTokensFromResponse(response, now, previousRefresh?)` | Maps a token endpoint response to `CodexTokens`. | | `accountIdFromIdToken(idToken)` | Reads the ChatGPT account id from an `id_token`. Returns `undefined` on malformed input; does not verify signatures. | | `wrapCodexBridgeMessage(prompt, identity)` | The leading `developer` message the adapter builds from the host's system prompt. | -| `CodexQuirks`, `parseCodexQuirks`, `CodexQuirksError` | Schema, parser and error for `{ productName, environmentTagName }`. Unknown keys are rejected. | +| `CodexQuirks`, `parseCodexQuirks`, `CodexQuirksError` | Schema, parser and error for `{ productName, environmentTagName, accountId? }`. Unknown keys are rejected. | | `CodexTokens`, `FetchLike` | Types. | | `CODEX_PROVIDER` | The `"codex"` provider id. | | `CODEX_BASE_URL`, `CODEX_RESPONSES_PATH` | `https://chatgpt.com/backend-api` and `/codex/responses`. | @@ -159,7 +159,7 @@ export const deps = { }; ``` -Each Codex source points at `CODEX_BASE_URL`, uses the OAuth access token as its credential, and carries `quirks: { productName, environmentTagName }` naming the host. Pass the credential's `accountId` metadata as `providerOptions[CODEX_ACCOUNT_ID_OPTION]`. +Each Codex source points at `CODEX_BASE_URL`, uses the OAuth access token as its credential, and carries `quirks: { productName, environmentTagName }` naming the host. Put the credential's `accountId` metadata in the source's quirks as `accountId`; it is sent as `chatgpt-account-id`, and a per-call `providerOptions[CODEX_ACCOUNT_ID_OPTION]` overrides it. The quirk binds the source to one ChatGPT account, so each account's credential needs its own source, and the quirk is re-set when the credential is signed in to another account. ## License diff --git a/src/quirks.ts b/src/quirks.ts index 025661c..5221f07 100644 --- a/src/quirks.ts +++ b/src/quirks.ts @@ -3,15 +3,22 @@ import { type, type Type } from "arktype"; interface CodexQuirksShape { productName: string; environmentTagName: string; + accountId?: string; } /** - * Host identity a Codex bridge message needs and cannot default. An absent - * bag is a validation error — there is no honest generic product name. + * Host identity a Codex bridge message needs and cannot default, plus the + * optional ChatGPT account the requests are sent for. An absent bag is a + * validation error — there is no honest generic product name. */ export const CodexQuirks: Type = type({ productName: "string", environmentTagName: "string", + // Sent as `chatgpt-account-id` on every request. A catalog source carries + // no providerOptions, so a deployment states the account here. Visible + // ASCII only, so a value that is not a valid header fails here rather than + // on every request. + "accountId?": /^[\x21-\x7e]+$/, "+": "reject", }); export type CodexQuirks = CodexQuirksShape; diff --git a/src/responses-adapter.test.ts b/src/responses-adapter.test.ts index 2797385..549a349 100644 --- a/src/responses-adapter.test.ts +++ b/src/responses-adapter.test.ts @@ -1,7 +1,7 @@ import type { ConversationTurn, LastCycleSource } from "@intx/types/runtime"; import { type } from "arktype"; import { describe, expect, test } from "bun:test"; -import { createCodexResponsesAdapter } from "./index.js"; +import { CodexQuirksError, createCodexResponsesAdapter } from "./index.js"; const source: LastCycleSource = { sourceId: "test/codex", @@ -57,6 +57,27 @@ describe("Codex responses adapter — request shape", () => { expect(withoutAccount.headers["chatgpt-account-id"]).toBeUndefined(); }); + test("sends the account id quirk as the header, and the option overrides it", () => { + const adapter = createCodexResponsesAdapter(source, { + ...quirks, + accountId: "acct-quirk", + }); + expect( + adapter.buildRequest(turns, "gpt-5.5", {}).headers["chatgpt-account-id"], + ).toBe("acct-quirk"); + expect( + adapter.buildRequest(turns, "gpt-5.5", { + providerOptions: { codexAccountId: "acct-option" }, + }).headers["chatgpt-account-id"], + ).toBe("acct-option"); + }); + + test("rejects an account id quirk that would split the header", () => { + expect(() => + createCodexResponsesAdapter(source, { ...quirks, accountId: "a\r\nb" }), + ).toThrow(CodexQuirksError); + }); + test("sends the session id in both the body and the header", () => { const adapter = createCodexResponsesAdapter(source, quirks); const request = adapter.buildRequest(turns, "gpt-5.5", { diff --git a/src/responses-adapter.ts b/src/responses-adapter.ts index 7566561..5d31916 100644 --- a/src/responses-adapter.ts +++ b/src/responses-adapter.ts @@ -16,13 +16,16 @@ import { parseCodexQuirks, type CodexQuirks } from "./quirks.js"; // `store` and `parallelToolCalls` need an explicit `false` on the wire, and // `maxOutputTokens: false` because the backend 400s on `max_output_tokens`. -function codexResponsesQuirks(): ResponsesQuirks { +function codexResponsesQuirks(quirks: CodexQuirks): ResponsesQuirks { return { path: CODEX_RESPONSES_PATH, headers: { static: { "openai-beta": "responses=experimental", originator: CODEX_ORIGINATOR, + ...(quirks.accountId !== undefined + ? { "chatgpt-account-id": quirks.accountId } + : {}), }, fromOption: [ { optionKey: CODEX_ACCOUNT_ID_OPTION, header: "chatgpt-account-id" }, @@ -55,7 +58,8 @@ function codexResponsesHooks(quirks: CodexQuirks): ResponsesHooks { /** * `AdapterFactory` for Codex's Responses-speaking ChatGPT backend. `quirks` * is this package's own {@link CodexQuirks} (an `InferenceSource.quirks` - * bag) carrying the host identity `wrapCodexBridgeMessage` needs; unlike + * bag) carrying the host identity `wrapCodexBridgeMessage` needs and the + * optional `chatgpt-account-id`; unlike * `@corbits/openai-responses`'s own quirks, an absent bag is a validation * error rather than a default, since there is no honest generic product * name to fall back to. This shape — `(source, quirks?)` — is what makes @@ -64,7 +68,7 @@ function codexResponsesHooks(quirks: CodexQuirks): ResponsesHooks { export const createCodexResponsesAdapter: AdapterFactory = (source, quirks) => { const parsed = parseCodexQuirks(quirks); return responsesAdapterFactory( - codexResponsesQuirks(), + codexResponsesQuirks(parsed), codexResponsesHooks(parsed), )(source); };