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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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

Expand Down
11 changes: 9 additions & 2 deletions src/quirks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodexQuirksShape> = 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;
Expand Down
23 changes: 22 additions & 1 deletion src/responses-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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", {
Expand Down
10 changes: 7 additions & 3 deletions src/responses-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down Expand Up @@ -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
Expand All @@ -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);
};
Loading