diff --git a/README.md b/README.md index 3147352..36f745c 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,119 @@ # @corbits/codex-provider -OpenAI Codex ("Login with ChatGPT") as an Interchange inference provider: OAuth constants and token mapping for `@corbits/oauth-core`, and a Responses adapter for Codex's ChatGPT backend over `@corbits/openai-responses`. Login, token storage, and usage reporting compose in the host from `@corbits/oauth-core`. +OpenAI Codex ("Login with ChatGPT") for `@intx/inference`: the OAuth client config and token mapping for `@corbits/oauth-core`, and a Responses API adapter for Codex's ChatGPT backend built on `@corbits/openai-responses`. An inference provider for Corbits and Interchange agents that also works in any host that runs `@intx/inference`. -## Runtime support +## Why @corbits/codex-provider? -Bun >= 1.2 and Node >= 24 consume the published compiled `dist/` output. -`@corbits/oauth-core`, `@corbits/openai-responses`, `@intx/inference`, and -`@intx/types` are peer dependencies and must resolve to the host's own copies. +1. **Inference on a ChatGPT subscription.** Codex has no API-key path. This package supplies the PKCE login config, the code exchange and refresh, and the `chatgpt-account-id` the backend requires on every request. +2. **The exact request Codex accepts.** `store: false`, `parallel_tool_calls: false`, no `max_output_tokens`, the host prompt as a leading `developer` message, and the Codex CLI's `originator` header are fixed in the adapter, not left to each host. +3. **Backend bugs repaired, host identity required.** `withCodexContentTypeRepair` restores the `content-type` the backend drops on some SSE streams. The host names itself through `CodexQuirks`; there is no default product name. + +## Install + +```bash +bun add @corbits/codex-provider @corbits/oauth-core@^0.1.0 @corbits/openai-responses@^0.1.0 @intx/inference@^0.4.0 @intx/types@^0.4.0 +``` + +Runs on Bun >= 1.2 or Node >= 24. ## Quickstart -```sh -npm add @corbits/codex-provider -pnpm add @corbits/codex-provider -yarn add @corbits/codex-provider -bun add @corbits/codex-provider +Needs `CODEX_ACCESS_TOKEN` and `CODEX_ACCOUNT_ID` from a completed "Login with ChatGPT". + +```ts +import { createDefaultScheduler, runInference } from "@intx/inference"; +import { + CODEX_ACCOUNT_ID_OPTION, + CODEX_BASE_URL, + CODEX_PROVIDER, + createCodexResponsesAdapter, + withCodexContentTypeRepair, +} from "@corbits/codex-provider"; + +const accountId = process.env["CODEX_ACCOUNT_ID"]; +if (accountId === undefined) throw new Error("CODEX_ACCOUNT_ID is not set"); + +// createDependencies binds the global fetch, so build deps directly to +// install the content-type repair. +const deps = { + fetch: withCodexContentTypeRepair(fetch), + scheduler: createDefaultScheduler(), + adapters: { + has: (provider: string) => provider === CODEX_PROVIDER, + resolve: createCodexResponsesAdapter, + }, +}; + +let seq = 0; +for await (const event of runInference({ + deps, + source: { + id: "codex", + provider: CODEX_PROVIDER, + baseURL: CODEX_BASE_URL, + credentialId: "CODEX_ACCESS_TOKEN", + model: "gpt-5.5", + quirks: { productName: "My Agent", environmentTagName: "my_agent_env" }, + }, + turns: [ + { + role: "user", + timestamp: Date.now(), + content: [{ type: "text", text: "Say hello." }], + }, + ], + inferenceOptions: { + providerOptions: { [CODEX_ACCOUNT_ID_OPTION]: accountId }, + }, + nextSeq: () => seq++, + readMaterial: (id) => { + const secret = process.env[id]; + if (secret === undefined) throw new Error(`${id} is not set`); + return { secret }; + }, +})) { + if (event.type === "inference.text.delta") + process.stdout.write(event.data.token); + if (event.type === "inference.error") + throw new Error(event.data.error.message); +} +process.stdout.write("\n"); ``` -### Register the OAuth login +## Where it fits + +[Interchange](https://github.com/faremeter/interchange) runs AI agents as principals (accounts that hold their own identity, permissions and credentials). Corbits packages add what an agent product needs around it. + +- **Runs in:** the agent sidecar (the runtime next to each agent) for inference, and the hub (the multi-tenant control plane) for login and token refresh. +- **Plugs into:** the [`@intx/inference`](https://github.com/faremeter/interchange/tree/main/packages/inference) adapter registry, as the factory for the `codex` provider id. +- **Pairs with:** [`@corbits/oauth-core`](https://github.com/corbitsdev/corbits-oauth-core) for login and refresh, and [`@corbits/openai-responses`](https://github.com/corbitsdev/corbits-openai-responses), the wire protocol underneath. -A host mounts "Continue with Codex" through `@corbits/oauth-core/hub`'s `mountOAuthLogin`, which takes a map of `OAuthLoginProviders`. This package supplies one entry for that map — it never runs its own callback server, token store, or refresh loop; `oauth-core` owns all of that. +## Reference + +| Export | Description | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `createCodexResponsesAdapter` | `AdapterFactory`. Validates the source's `quirks` as `CodexQuirks` and throws `CodexQuirksError` when absent. | +| `withCodexContentTypeRepair(fetch)` | Wraps `fetch` to restore a missing `content-type` on 2xx `/codex/responses` responses from the request's `accept`. | +| `codexOAuthConfig` | `OAuthClientConfig` for `@corbits/oauth-core`: client id, endpoints, loopback redirect, scopes, extra params. | +| `exchangeCodexCode(code, verifier, now, fetch?)` | Redeems an authorization code for `CodexTokens`. | +| `refreshCodexTokens(refresh, now, previous?, fetch?)` | Refreshes `CodexTokens`. Carries `previous.accountId` forward when the response has no `id_token`. | +| `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. | +| `CodexTokens`, `FetchLike` | Types. | +| `CODEX_PROVIDER` | The `"codex"` provider id. | +| `CODEX_BASE_URL`, `CODEX_RESPONSES_PATH` | `https://chatgpt.com/backend-api` and `/codex/responses`. | +| `CODEX_ACCOUNT_ID_OPTION` | `providerOptions` key sent as the `chatgpt-account-id` header. | +| `CODEX_SESSION_ID_OPTION` | `providerOptions` key sent as `prompt_cache_key` and the `session_id` header. | +| `CODEX_REASONING_EFFORT_OPTION` | `providerOptions` key sent as `reasoning.effort`; `"none"` omits it. | +| `CODEX_REFRESH_SKEW_MS` | Refresh this long before expiry. | + +The OAuth constants behind `codexOAuthConfig` (`CODEX_CLIENT_ID`, `CODEX_AUTHORIZE_URL`, `CODEX_TOKEN_URL`, `CODEX_REDIRECT_URI`, `CODEX_SCOPES`, `CODEX_AUTHORIZE_EXTRA_PARAMS`, `CODEX_ORIGINATOR`, `CODEX_TOKEN_TIMEOUT_MS`) are exported too. + +## Using with Interchange + +Register the login on the hub. `@corbits/oauth-core/hub`'s `mountOAuthLogin` and `createOAuthTokenRefresher` take a map of providers; this package supplies the Codex entry. The refresher keeps the stored `accountId` metadata when a refresh response omits `id_token`. ```ts import type { OAuthLoginProviders } from "@corbits/oauth-core/hub"; @@ -30,82 +124,42 @@ import { refreshCodexTokens, } from "@corbits/codex-provider"; -const providers: OAuthLoginProviders = { +export const providers: OAuthLoginProviders = { [CODEX_PROVIDER]: { oauthConfig: codexOAuthConfig, exchange: (code, verifier, now) => exchangeCodexCode(code, verifier, now), refresh: (refreshSecret, now) => refreshCodexTokens(refreshSecret, now), - // The Codex backend rejects inference without this header value. metadata: (tokens) => "accountId" in tokens && typeof tokens.accountId === "string" ? { accountId: tokens.accountId } : {}, }, - // ...the host's other providers, each contributing one entry the same way. }; ``` -Pass `providers` as-is to `mountOAuthLogin` (login route) and to `createOAuthTokenRefresher` (background renewal ahead of expiry) — both live on `@corbits/oauth-core/hub` and own the database, the cipher, and the grant check. - -### Register the inference adapter - -`CodexQuirks` (`productName`, `environmentTagName`) has no default — an absent bag is a validation error. The ChatGPT backend omits `content-type` on some streamed responses; wrap the host `fetch` with `withCodexContentTypeRepair` so the harness sees SSE. +Load the adapter in the sidecar from an operator-configured `AdapterManifest`, and wrap the harness `fetch` with the content-type repair: ```ts -import type { AdapterManifest } from "@intx/inference"; -import type { LastCycleSource } from "@intx/types/runtime"; -import { - CODEX_PROVIDER, - createCodexResponsesAdapter, - withCodexContentTypeRepair, -} from "@corbits/codex-provider"; +import { createDefaultScheduler, type AdapterManifest } from "@intx/inference"; +import { loadAdapterRegistry } from "@intx/inference/providers"; +import { withCodexContentTypeRepair } from "@corbits/codex-provider"; -// Host-owned: register the adapter under the host's provider id. -export const inferenceManifest: AdapterManifest = [ +const manifest: AdapterManifest = [ { - provider: CODEX_PROVIDER, + provider: "codex", specifier: "@corbits/codex-provider", export: "createCodexResponsesAdapter", }, ]; - -// Host-owned: the host identity the bridge message needs — never defaulted. -const source: LastCycleSource = { - sourceId: "codex/1", - provider: CODEX_PROVIDER, - model: "gpt-5.5", +export const deps = { + fetch: withCodexContentTypeRepair(fetch), + scheduler: createDefaultScheduler(), + adapters: await loadAdapterRegistry(manifest), }; - -export const adapter = createCodexResponsesAdapter(source, { - productName: "My Harness", - environmentTagName: "my_harness_environment", -}); - -// Host-owned: wrap the host fetch so SSE streams expose a content-type. -export const hostFetch = withCodexContentTypeRepair(globalThis.fetch); -``` - -The host's catalog record for a Codex credential points at `CODEX_BASE_URL` with the subscription access token that OAuth login produced. - -## How it works - -Codex has no API-key path — only the ChatGPT OAuth subscription token. This package identifies as the public Codex CLI (`originator: codex_cli_rs`) because that backend expects that client identity on every inference request. A host operating prompt rides as the leading `developer` message via `wrapCodexBridgeMessage`; there is no `instructions` field. - -## Development - -```sh -git clone https://github.com/corbitsdev/corbits-codex-provider.git -cd corbits-codex-provider -bun install -bun run typecheck -bun run lint -bun run format:check -bun run test -bun run check ``` -`bun run format` rewrites the tree. `bun run check` is typecheck + lint + format:check + test. +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]`. ## License -LGPL-2.1-only. +[LGPL-2.1-only](https://github.com/corbitsdev/corbits-codex-provider/blob/main/LICENSE)