chore: import upstream snapshot with attribution
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:43:05 +08:00
commit 426e9eeabd
41828 changed files with 9656266 additions and 0 deletions
+142
View File
@@ -0,0 +1,142 @@
# @elizaos/plugin-codex-cli
ChatGPT Codex model provider plugin for elizaOS — routes text generation through a user's ChatGPT Plus/Pro subscription using the OAuth token cache written by the official `codex` CLI.
## Purpose / role
This plugin registers model handlers so Eliza agents can use ChatGPT Codex models (`gpt-5`, `gpt-5.5`, etc.) as their inference backend. It is **not** auto-enabled by an env var; it activates when an auth profile in the runtime config sets `provider: "codex-cli"`, or when `agents.defaults.subscriptionProvider` is `"openai-codex"`. It is node-only (`"platforms": ["node"]`).
## Plugin surface
No actions, providers, evaluators, or routes are registered. The plugin registers **model handlers only**:
| Model type registered | What it does |
|---|---|
| `TEXT_SMALL` | Codex-backed small text generation |
| `TEXT_NANO` | Codex-backed nano text generation |
| `TEXT_MEDIUM` | Codex-backed medium text generation |
| `TEXT_LARGE` | Codex-backed large text generation |
| `TEXT_MEGA` | Codex-backed mega text generation |
| `RESPONSE_HANDLER` | Codex-backed response handler model |
| `ACTION_PLANNER` | Codex-backed action planner model |
All model types delegate to `generateTextWithCodex()` in `index.ts`, which calls `CodexBackend.generate()`. Streaming is supported via `TextStreamResult`.
## Layout
```
plugins/plugin-codex-cli/
index.ts Plugin entry point — registers model handlers, exports Plugin object
index.node.ts Node build re-export (re-exports index.ts default)
index.browser.ts Unsupported browser export; plugin is node-only
auto-enable.ts Auto-enable module (shouldEnable + shouldForce); referenced by package.json elizaos.plugin.autoEnableModule
src/
codex-backend.ts CodexBackend class — HTTP client for ChatGPT Codex /responses SSE endpoint; FIFO queue + jitter
codex-auth.ts OAuth token load/save/refresh with file-level lock; isExpired() JWT check
sse-parser.ts Spec-compliant SSE AsyncGenerator parser for ReadableStream<Uint8Array>
tool-format-openai.ts toOpenAITool() / toOpenAITools() — maps elizaOS ToolDefinition to OpenAI function tool shape
__tests__/
codex-backend.test.ts Unit tests for CodexBackend, message translation, and auth
build.ts Bun.build script (node + browser bundles via Bun bundler; declarations via tsc)
vitest.config.ts Test config
```
## Commands
All commands run from the plugin directory:
```bash
bun run --cwd plugins/plugin-codex-cli build # compile (Bun.build + tsc for declarations)
bun run --cwd plugins/plugin-codex-cli dev # watch build
bun run --cwd plugins/plugin-codex-cli test # vitest run
bun run --cwd plugins/plugin-codex-cli lint # biome check --write --unsafe
bun run --cwd plugins/plugin-codex-cli lint:check # biome check (no write)
bun run --cwd plugins/plugin-codex-cli format # biome format --write
bun run --cwd plugins/plugin-codex-cli typecheck # tsgo --noEmit
bun run --cwd plugins/plugin-codex-cli clean # rm -rf dist .turbo
```
## Config / env vars
| Var | Required | Default | Description |
|---|---|---|---|
| `CODEX_AUTH_PATH` | No | `~/.codex/auth.json` | Path to codex CLI OAuth cache file |
| `CODEX_BASE_URL` | No | `https://chatgpt.com/backend-api/codex` | Codex backend base URL; only `chatgpt.com` or `localhost` accepted |
| `CODEX_MODEL` | No | `gpt-5.5` | Model identifier; must be one of the supported models |
| `CODEX_JITTER_MS_MAX` | No | `200` | Max pre-request jitter in ms; set `0` to disable |
| `CODEX_ORIGINATOR` | No | `codex_cli_rs` | Originator header sent to the backend |
| `CODEX_USER_AGENT` | No | `codex_cli_rs/0.124.0` | User-Agent header (env only; not in agentConfig) |
Settings are read via `runtime.getSetting()` first, falling back to `process.env`. None are required; the plugin will fail at request time if `CODEX_AUTH_PATH` is missing or the auth file is absent/expired with no valid refresh token.
No auto-enable env var trigger exists. Auto-enable logic lives in `auto-enable.ts`:
- `shouldEnable`: any auth profile has `provider === "codex-cli"`.
- `shouldForce`: `agents.defaults.subscriptionProvider === "openai-codex"`.
## How to extend
**Add a new model type handler:**
1. Import the new `ModelType` constant from `@elizaos/core` in `index.ts`.
2. Add an entry to the `codexModels` object following the existing pattern: `[ModelType.NEW_TYPE]: (runtime, params) => generateTextWithCodex(runtime, params, ModelType.NEW_TYPE)`.
3. Declare it in `package.json` under `elizaos.plugin.capabilities` if appropriate.
**Swap the backend or auth mechanism:**
- `CodexBackend` accepts `loadAuth`, `refreshAuth`, `fetchImpl`, and `toolTranslator` overrides in its constructor config — use these in tests and when integrating alternate auth flows.
**Add a provider or action:**
- This plugin intentionally has no actions or providers. Adding one follows the standard elizaOS pattern: define it, add it to the `Plugin` object's `actions` or `providers` array in `index.ts`.
## Conventions / gotchas
- **Node-only.** The browser export (`index.browser.ts`) exists to satisfy the build; the plugin will not function in a browser runtime. The `src/codex-auth.ts` module uses `node:fs`, `node:crypto`, and `node:os`.
- **Single in-flight FIFO queue.** `CodexBackend` chains requests via `this.tail` promise — all calls on the same backend instance serialize. One `CodexBackend` instance per runtime is maintained via `backendByRuntime` WeakMap in `index.ts`.
- **Base URL validation.** `CODEX_BASE_URL` must target `https://chatgpt.com` or `localhost`; any other host throws at backend construction to prevent OAuth token exfiltration.
- **401 auto-refresh.** On a 401 from the Responses endpoint, the backend refreshes the OAuth token (with a file lock) and retries exactly once.
- **Tool calls return an object, not a string.** When `params.tools` is non-empty, `params.messages` is provided, or the backend returns tool calls, `generateTextWithCodex` returns `TextResultWithNativeTools` (`{ text, toolCalls, finishReason, usage }`) rather than a plain string.
- **`responseSchema` support.** When callers pass a `responseSchema` and no explicit `responseFormat`, the backend wraps it in `{ type: "json_schema", schema }` for the OpenAI Responses API structured output format.
- **Per-call model override.** Text handlers honor `params.model` before `CODEX_MODEL`. Workflow generation uses this for isolated Codex model tests without changing every Codex CLI text call.
- **Auth file lock.** `codex-auth.ts` uses a `.lock` file alongside `auth.json` (30 s stale timeout, 30 retries at 100 ms). Stale locks are cleaned up automatically.
- **Supported models:** `gpt-5`, `gpt-5-codex`, `gpt-5.4`, `gpt-5.5`, `gpt-5.5-pro`. Setting `CODEX_MODEL` to anything else will be sent as-is; the backend may reject it.
- See the root `AGENTS.md` for repo-wide architecture rules, logger conventions, and ESM requirements.
<!-- BEGIN: evidence-and-e2e-mandate (managed; canonical standard = repo-root AGENTS.md) -->
## ⛔ NON-NEGOTIABLE — evidence, trajectories & real end-to-end tests
> The binding, repo-wide standard is **[AGENTS.md](../../AGENTS.md)**. Read it.
> Nothing in this package is *done* until it is *proven* done — a reviewer must confirm it
> works **without reading the code**, from the artifacts you attach. This applies to **every**
> feature, fix, refactor, and chore here. "Tests pass" is not proof; "CI is green" is not proof.
- **Record AND read model trajectories.** Capture the *actual* inputs and outputs of the model
from a **live** LLM — not the deterministic proxy, not a mock: the prompt, the
providers/context, the raw model output, every tool/action call, and the result. Then **open
the trajectory and review it by hand.** A captured-but-unread trajectory is not evidence
(`packages/scenario-runner/bin/eliza-scenarios run <scenario> --report <out>`).
- **Real, full-featured E2E — no larp.** Every feature ships detailed end-to-end tests that
drive the *real* path end to end. Not the happy "front door" only: cover error paths,
edge/empty/invalid input, concurrency, roles/permissions, and adversarial input. A test that
asserts against a mock/stub/fixture standing in for the thing under test **does not count**.
If the real model/device/chain/connector/account is hard to reach, **make it reachable — that
is the work**, not an excuse to mock. If the existing tests here are shallow or mocked, fixing
them is part of your change.
- **Screenshots + logs at every phase**, plus a **complete walkthrough video/run-through** of
the entire feature or view, start to finish (`bun run test:e2e:record`).
- **Manually review every artifact the change touches** — never just the green check: client
logs (console + network), server logs (`[ClassName] …`), the model trajectories in and out,
before/after full-page screenshots, **and the domain artifacts listed below for this package.**
- **No residuals. No shortcuts.** The goal is not "done" — it is *everything* done. Clear every
blocker by the **hard path**: build the real architecture, stand up the real
model/device/service, actually test it. Never leave a TODO, a stub, a stepping-stone, or a
"follow-up." When unsure, research thoroughly, weigh the options, and ship the best,
highest-effort, production-ready version. Keep going until every possibility is exhausted.
Artifacts → attached inline in the PR (MP4 video, JPG screenshots, logs in `<details>`); attach each evidence type **or**
explicitly mark it N/A with a reason — never leave it blank. If `develop` moved and changed
behavior, **re-capture** evidence; stale proof is worse than none.
**Capture & manually review for this package — CLI / tooling:**
- The real command/flow invocation transcript (args in, stdout/stderr, exit code) and the artifacts it generated (files, scaffolds, manifests, screenshots/recordings).
- Failure paths: bad args, missing deps, partial state, permission/network errors.
- A recording/log of the actual run end to end — not a unit test of one helper.
- Any model interaction captured as a live trajectory and reviewed.
<!-- END: evidence-and-e2e-mandate -->
+142
View File
@@ -0,0 +1,142 @@
# @elizaos/plugin-codex-cli
ChatGPT Codex model provider plugin for elizaOS — routes text generation through a user's ChatGPT Plus/Pro subscription using the OAuth token cache written by the official `codex` CLI.
## Purpose / role
This plugin registers model handlers so Eliza agents can use ChatGPT Codex models (`gpt-5`, `gpt-5.5`, etc.) as their inference backend. It is **not** auto-enabled by an env var; it activates when an auth profile in the runtime config sets `provider: "codex-cli"`, or when `agents.defaults.subscriptionProvider` is `"openai-codex"`. It is node-only (`"platforms": ["node"]`).
## Plugin surface
No actions, providers, evaluators, or routes are registered. The plugin registers **model handlers only**:
| Model type registered | What it does |
|---|---|
| `TEXT_SMALL` | Codex-backed small text generation |
| `TEXT_NANO` | Codex-backed nano text generation |
| `TEXT_MEDIUM` | Codex-backed medium text generation |
| `TEXT_LARGE` | Codex-backed large text generation |
| `TEXT_MEGA` | Codex-backed mega text generation |
| `RESPONSE_HANDLER` | Codex-backed response handler model |
| `ACTION_PLANNER` | Codex-backed action planner model |
All model types delegate to `generateTextWithCodex()` in `index.ts`, which calls `CodexBackend.generate()`. Streaming is supported via `TextStreamResult`.
## Layout
```
plugins/plugin-codex-cli/
index.ts Plugin entry point — registers model handlers, exports Plugin object
index.node.ts Node build re-export (re-exports index.ts default)
index.browser.ts Unsupported browser export; plugin is node-only
auto-enable.ts Auto-enable module (shouldEnable + shouldForce); referenced by package.json elizaos.plugin.autoEnableModule
src/
codex-backend.ts CodexBackend class — HTTP client for ChatGPT Codex /responses SSE endpoint; FIFO queue + jitter
codex-auth.ts OAuth token load/save/refresh with file-level lock; isExpired() JWT check
sse-parser.ts Spec-compliant SSE AsyncGenerator parser for ReadableStream<Uint8Array>
tool-format-openai.ts toOpenAITool() / toOpenAITools() — maps elizaOS ToolDefinition to OpenAI function tool shape
__tests__/
codex-backend.test.ts Unit tests for CodexBackend, message translation, and auth
build.ts Bun.build script (node + browser bundles via Bun bundler; declarations via tsc)
vitest.config.ts Test config
```
## Commands
All commands run from the plugin directory:
```bash
bun run --cwd plugins/plugin-codex-cli build # compile (Bun.build + tsc for declarations)
bun run --cwd plugins/plugin-codex-cli dev # watch build
bun run --cwd plugins/plugin-codex-cli test # vitest run
bun run --cwd plugins/plugin-codex-cli lint # biome check --write --unsafe
bun run --cwd plugins/plugin-codex-cli lint:check # biome check (no write)
bun run --cwd plugins/plugin-codex-cli format # biome format --write
bun run --cwd plugins/plugin-codex-cli typecheck # tsgo --noEmit
bun run --cwd plugins/plugin-codex-cli clean # rm -rf dist .turbo
```
## Config / env vars
| Var | Required | Default | Description |
|---|---|---|---|
| `CODEX_AUTH_PATH` | No | `~/.codex/auth.json` | Path to codex CLI OAuth cache file |
| `CODEX_BASE_URL` | No | `https://chatgpt.com/backend-api/codex` | Codex backend base URL; only `chatgpt.com` or `localhost` accepted |
| `CODEX_MODEL` | No | `gpt-5.5` | Model identifier; must be one of the supported models |
| `CODEX_JITTER_MS_MAX` | No | `200` | Max pre-request jitter in ms; set `0` to disable |
| `CODEX_ORIGINATOR` | No | `codex_cli_rs` | Originator header sent to the backend |
| `CODEX_USER_AGENT` | No | `codex_cli_rs/0.124.0` | User-Agent header (env only; not in agentConfig) |
Settings are read via `runtime.getSetting()` first, falling back to `process.env`. None are required; the plugin will fail at request time if `CODEX_AUTH_PATH` is missing or the auth file is absent/expired with no valid refresh token.
No auto-enable env var trigger exists. Auto-enable logic lives in `auto-enable.ts`:
- `shouldEnable`: any auth profile has `provider === "codex-cli"`.
- `shouldForce`: `agents.defaults.subscriptionProvider === "openai-codex"`.
## How to extend
**Add a new model type handler:**
1. Import the new `ModelType` constant from `@elizaos/core` in `index.ts`.
2. Add an entry to the `codexModels` object following the existing pattern: `[ModelType.NEW_TYPE]: (runtime, params) => generateTextWithCodex(runtime, params, ModelType.NEW_TYPE)`.
3. Declare it in `package.json` under `elizaos.plugin.capabilities` if appropriate.
**Swap the backend or auth mechanism:**
- `CodexBackend` accepts `loadAuth`, `refreshAuth`, `fetchImpl`, and `toolTranslator` overrides in its constructor config — use these in tests and when integrating alternate auth flows.
**Add a provider or action:**
- This plugin intentionally has no actions or providers. Adding one follows the standard elizaOS pattern: define it, add it to the `Plugin` object's `actions` or `providers` array in `index.ts`.
## Conventions / gotchas
- **Node-only.** The browser export (`index.browser.ts`) exists to satisfy the build; the plugin will not function in a browser runtime. The `src/codex-auth.ts` module uses `node:fs`, `node:crypto`, and `node:os`.
- **Single in-flight FIFO queue.** `CodexBackend` chains requests via `this.tail` promise — all calls on the same backend instance serialize. One `CodexBackend` instance per runtime is maintained via `backendByRuntime` WeakMap in `index.ts`.
- **Base URL validation.** `CODEX_BASE_URL` must target `https://chatgpt.com` or `localhost`; any other host throws at backend construction to prevent OAuth token exfiltration.
- **401 auto-refresh.** On a 401 from the Responses endpoint, the backend refreshes the OAuth token (with a file lock) and retries exactly once.
- **Tool calls return an object, not a string.** When `params.tools` is non-empty, `params.messages` is provided, or the backend returns tool calls, `generateTextWithCodex` returns `TextResultWithNativeTools` (`{ text, toolCalls, finishReason, usage }`) rather than a plain string.
- **`responseSchema` support.** When callers pass a `responseSchema` and no explicit `responseFormat`, the backend wraps it in `{ type: "json_schema", schema }` for the OpenAI Responses API structured output format.
- **Per-call model override.** Text handlers honor `params.model` before `CODEX_MODEL`. Workflow generation uses this for isolated Codex model tests without changing every Codex CLI text call.
- **Auth file lock.** `codex-auth.ts` uses a `.lock` file alongside `auth.json` (30 s stale timeout, 30 retries at 100 ms). Stale locks are cleaned up automatically.
- **Supported models:** `gpt-5`, `gpt-5-codex`, `gpt-5.4`, `gpt-5.5`, `gpt-5.5-pro`. Setting `CODEX_MODEL` to anything else will be sent as-is; the backend may reject it.
- See the root `AGENTS.md` for repo-wide architecture rules, logger conventions, and ESM requirements.
<!-- BEGIN: evidence-and-e2e-mandate (managed; canonical standard = repo-root AGENTS.md) -->
## ⛔ NON-NEGOTIABLE — evidence, trajectories & real end-to-end tests
> The binding, repo-wide standard is **[AGENTS.md](../../AGENTS.md)**. Read it.
> Nothing in this package is *done* until it is *proven* done — a reviewer must confirm it
> works **without reading the code**, from the artifacts you attach. This applies to **every**
> feature, fix, refactor, and chore here. "Tests pass" is not proof; "CI is green" is not proof.
- **Record AND read model trajectories.** Capture the *actual* inputs and outputs of the model
from a **live** LLM — not the deterministic proxy, not a mock: the prompt, the
providers/context, the raw model output, every tool/action call, and the result. Then **open
the trajectory and review it by hand.** A captured-but-unread trajectory is not evidence
(`packages/scenario-runner/bin/eliza-scenarios run <scenario> --report <out>`).
- **Real, full-featured E2E — no larp.** Every feature ships detailed end-to-end tests that
drive the *real* path end to end. Not the happy "front door" only: cover error paths,
edge/empty/invalid input, concurrency, roles/permissions, and adversarial input. A test that
asserts against a mock/stub/fixture standing in for the thing under test **does not count**.
If the real model/device/chain/connector/account is hard to reach, **make it reachable — that
is the work**, not an excuse to mock. If the existing tests here are shallow or mocked, fixing
them is part of your change.
- **Screenshots + logs at every phase**, plus a **complete walkthrough video/run-through** of
the entire feature or view, start to finish (`bun run test:e2e:record`).
- **Manually review every artifact the change touches** — never just the green check: client
logs (console + network), server logs (`[ClassName] …`), the model trajectories in and out,
before/after full-page screenshots, **and the domain artifacts listed below for this package.**
- **No residuals. No shortcuts.** The goal is not "done" — it is *everything* done. Clear every
blocker by the **hard path**: build the real architecture, stand up the real
model/device/service, actually test it. Never leave a TODO, a stub, a stepping-stone, or a
"follow-up." When unsure, research thoroughly, weigh the options, and ship the best,
highest-effort, production-ready version. Keep going until every possibility is exhausted.
Artifacts → attached inline in the PR (MP4 video, JPG screenshots, logs in `<details>`); attach each evidence type **or**
explicitly mark it N/A with a reason — never leave it blank. If `develop` moved and changed
behavior, **re-capture** evidence; stale proof is worse than none.
**Capture & manually review for this package — CLI / tooling:**
- The real command/flow invocation transcript (args in, stdout/stderr, exit code) and the artifacts it generated (files, scaffolds, manifests, screenshots/recordings).
- Failure paths: bad args, missing deps, partial state, permission/network errors.
- A recording/log of the actual run end to end — not a unit test of one helper.
- Any model interaction captured as a live trajectory and reviewed.
<!-- END: evidence-and-e2e-mandate -->
+89
View File
@@ -0,0 +1,89 @@
# @elizaos/plugin-codex-cli
ChatGPT Codex model provider for elizaOS. This node-only plugin lets an Eliza agent use a user's ChatGPT Plus/Pro subscription as a frontier-model provider by reusing the OAuth token cache written by the official `codex` CLI.
This is intentionally a model provider plugin. It does not add a reasoning runtime, planner loop, context manager, or any native-reasoning architecture. It sits alongside providers like `@elizaos/plugin-openai` and `@elizaos/plugin-anthropic` and registers handlers for text (`TEXT_SMALL`, `TEXT_NANO`, `TEXT_MEDIUM`, `TEXT_LARGE`, `TEXT_MEGA`), `RESPONSE_HANDLER`, and `ACTION_PLANNER` model types.
## How it works
The plugin reads OAuth tokens from `~/.codex/auth.json` by default, sends requests to:
```txt
https://chatgpt.com/backend-api/codex/responses
```
and uses the same codex-oriented request headers as the CLI path:
- `Authorization: Bearer <access_token>`
- `originator: codex_cli_rs`
- `User-Agent: codex_cli_rs/...`
- `chatgpt-account-id: <account_id>`
- `OpenAI-Beta: responses=v1`
On a 401, it refreshes the token with the cached refresh token, writing the auth file atomically under a file lock, then retries once.
## Supported models
Set `CODEX_MODEL` to one of:
- `gpt-5`
- `gpt-5-codex`
- `gpt-5.4`
- `gpt-5.5`
- `gpt-5.5-pro`
The default is `gpt-5.5`.
## Configuration
```bash
CODEX_AUTH_PATH=~/.codex/auth.json
CODEX_BASE_URL=https://chatgpt.com/backend-api/codex # must target chatgpt.com or localhost
CODEX_MODEL=gpt-5.5
CODEX_JITTER_MS_MAX=200
CODEX_ORIGINATOR=codex_cli_rs
CODEX_USER_AGENT=codex_cli_rs/0.124.0
```
## Usage
Install or enable the plugin the same way as other elizaOS model-provider plugins, then configure an agent to load `@elizaos/plugin-codex-cli`.
Example direct runtime call:
```ts
import { ModelType } from "@elizaos/core";
const text = await runtime.useModel(ModelType.RESPONSE_HANDLER, {
prompt: "Reply as the agent in one short paragraph.",
});
```
Tool-capable calls can pass provider-neutral `tools`, `toolChoice`, and `messages`. The plugin forwards tools to the OpenAI Responses API function-tool shape and returns a native result object when tools or messages are used:
```ts
const result = await runtime.useModel(ModelType.RESPONSE_HANDLER, {
messages,
tools: [
{
name: "lookup",
description: "Look up a fact",
parameters: { type: "object", properties: { query: { type: "string" } } },
},
],
});
// result.text
// result.toolCalls
```
## Soft mitigation
To reduce account/session weirdness against the ChatGPT Codex backend, each backend instance uses:
- a single in-flight FIFO semaphore
- configurable jitter before requests, default 50-200ms
## Scope
This plugin provides a Codex-backed model provider surface. It does not own a reasoning loop, planner, or context manager — those concerns belong to the agent runtime and other plugins.
@@ -0,0 +1,298 @@
/**
* Unit coverage for the Codex provider: plugin model metadata, JWT expiry, the
* SSE parser, tool-schema translation, and CodexBackend request/stream handling
* driven by a fake fetch (no live model or network).
*/
import { afterEach, describe, expect, it } from "vitest";
import { __INTERNAL_buildCodexGenerateParams, codexCliPlugin } from "../index";
import {
__resetCodexAuthDeps,
__setCodexAuthDeps,
type CodexAuth,
isExpired,
} from "../src/codex-auth";
import { CodexBackend, translateMessagesToCodexInput } from "../src/codex-backend";
import { parseSSE } from "../src/sse-parser";
import { toOpenAITool } from "../src/tool-format-openai";
function jwtWithExp(exp: number): string {
const payload = Buffer.from(JSON.stringify({ exp })).toString("base64url");
return `x.${payload}.y`;
}
function sseResponse(events: string[]): Response {
return new Response(events.join(""), {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
const auth: CodexAuth = {
OPENAI_API_KEY: null,
auth_mode: "chatgpt",
last_refresh: "2026-01-01T00:00:00.000Z",
tokens: {
id_token: "id",
access_token: "access",
refresh_token: "refresh",
account_id: "acct_123",
},
};
describe("codex plugin metadata", () => {
it("declares CODEX_MODEL as display metadata for every model handler", () => {
const modelTypes = Object.keys(codexCliPlugin.models ?? {});
expect(modelTypes.length).toBeGreaterThan(0);
expect(Object.keys(codexCliPlugin.modelMetadata ?? {}).sort()).toEqual(modelTypes.sort());
for (const modelType of modelTypes) {
expect(codexCliPlugin.modelMetadata?.[modelType]).toEqual({
displayModelSetting: "CODEX_MODEL",
});
}
});
});
describe("codex auth helpers", () => {
afterEach(() => __resetCodexAuthDeps());
it("detects expired JWT access tokens with a buffer", () => {
__setCodexAuthDeps({ now: () => 1_000_000 });
expect(isExpired({ ...auth, tokens: { ...auth.tokens, access_token: jwtWithExp(900) } })).toBe(
true
);
expect(
isExpired({ ...auth, tokens: { ...auth.tokens, access_token: jwtWithExp(2_000) } })
).toBe(false);
});
});
describe("SSE parser", () => {
it("parses named events with multiline data", async () => {
const stream = new Response(
'event: response.output_text.delta\ndata: {"delta":"hel"}\ndata: {"delta":"lo"}\n\n'
).body;
expect(stream).toBeTruthy();
const events = [];
for await (const event of parseSSE(stream as ReadableStream<Uint8Array>)) events.push(event);
expect(events).toEqual([
{
event: "response.output_text.delta",
data: '{"delta":"hel"}\n{"delta":"lo"}',
},
]);
});
});
describe("tool translation", () => {
it("converts eliza tool definitions to OpenAI Responses function tools", () => {
expect(
toOpenAITool({
name: "search",
description: "Search the web",
parameters: { type: "object", properties: { q: { type: "string" } } },
})
).toEqual({
type: "function",
name: "search",
description: "Search the web",
parameters: { type: "object", properties: { q: { type: "string" } } },
strict: false,
});
});
it("normalizes strict tool schemas (codex backend 400s on partial required / missing additionalProperties)", () => {
const out = toOpenAITool({
name: "TASKS",
description: "spawn/list coding sub-agents",
strict: true,
parameters: {
type: "object",
properties: {
action: { type: "string", enum: ["create", "list"] },
prompt: { type: "string" },
},
required: ["action"],
},
});
expect(out.strict).toBe(true);
// required must list EVERY property, additionalProperties:false, and the
// originally-optional `prompt` is made nullable to keep its optional meaning.
expect(out.parameters).toEqual({
type: "object",
properties: {
action: { type: "string", enum: ["create", "list"] },
prompt: { type: ["string", "null"] },
},
required: ["action", "prompt"],
additionalProperties: false,
});
});
});
describe("CodexBackend", () => {
it("honors a per-call model override before Codex slot defaults", () => {
const runtime = {
getSetting(key: string) {
if (key === "CODEX_MODEL") return "gpt-5.5";
return undefined;
},
};
expect(
__INTERNAL_buildCodexGenerateParams(runtime as never, {
prompt: "hello",
model: " gpt-5-codex ",
}).model
).toBe("gpt-5-codex");
});
it("translates provider-neutral messages and tool calls", () => {
expect(
translateMessagesToCodexInput(
[
{ role: "system", content: "ignore me here" },
{ role: "user", content: "hello" },
{
role: "assistant",
content: "",
toolCalls: [{ id: "call_1", name: "lookup", arguments: { q: "x" } }],
},
{ role: "tool", toolCallId: "call_1", content: "result" },
],
"fallback"
)
).toEqual([
{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] },
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"q":"x"}' },
{ type: "function_call_output", call_id: "call_1", output: "result" },
{ type: "message", role: "user", content: [{ type: "input_text", text: "fallback" }] },
]);
});
it("preserves tool-call and tool-result CONTENT PARTS (the planner's transcript format)", () => {
// The planner renders prior steps as `tool-call` / `tool-result` content
// parts, NOT the `toolCalls` field + plain-text tool message. Dropping
// these made codex blind to its own fetches ("I don't have the fetched
// contents visible here") and re-issue the same call in a loop.
expect(
translateMessagesToCodexInput(
[
{ role: "user", content: "newest nodejs version?" },
{
role: "assistant",
content: [
{ type: "text", text: "Fetching the dist index." },
{
type: "tool-call",
toolCallId: "call_1",
toolName: "WEB_FETCH",
input: { url: "https://nodejs.org/dist/index.json" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "WEB_FETCH",
output: { type: "text", value: 'text: [{"version":"v26.3.1"}]' },
},
],
},
],
"newest nodejs version?"
)
).toEqual([
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "newest nodejs version?" }],
},
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Fetching the dist index." }],
},
{
type: "function_call",
call_id: "call_1",
name: "WEB_FETCH",
arguments: '{"url":"https://nodejs.org/dist/index.json"}',
},
{ type: "function_call_output", call_id: "call_1", output: 'text: [{"version":"v26.3.1"}]' },
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "newest nodejs version?" }],
},
]);
});
it("posts codex headers, parses text and function calls", async () => {
const bodies: unknown[] = [];
const backend = new CodexBackend({
authPath: "/tmp/auth.json",
jitterMaxMs: 0,
loadAuth: async () => auth,
fetchImpl: (async (_url: string | URL | Request, init?: RequestInit) => {
bodies.push(JSON.parse(String(init?.body)));
expect((init?.headers as Record<string, string>).Authorization).toBe("Bearer access");
expect((init?.headers as Record<string, string>)["chatgpt-account-id"]).toBe("acct_123");
expect((init?.headers as Record<string, string>).originator).toBe("codex_cli_rs");
return sseResponse([
'event: response.output_text.delta\ndata: {"delta":"hi"}\n\n',
'event: response.output_item.added\ndata: {"item":{"id":"item_1","type":"function_call","call_id":"call_1","name":"lookup"}}\n\n',
'event: response.function_call_arguments.delta\ndata: {"item_id":"item_1","delta":"{\\"q\\":"}\n\n',
'event: response.output_item.done\ndata: {"item":{"id":"item_1","type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\\"q\\":\\"x\\"}"}}\n\n',
'event: response.completed\ndata: {"response":{"stop_reason":"tool_calls","usage":{"input_tokens":3,"output_tokens":4}}}\n\n',
]);
}) as typeof fetch,
});
const result = await backend.generate({
prompt: "hello",
tools: [{ name: "lookup", parameters: { type: "object" } }],
toolChoice: { name: "lookup" },
});
expect(result).toEqual({
text: "hi",
toolCalls: [{ id: "call_1", name: "lookup", arguments: { q: "x" }, type: "function" }],
finishReason: "tool_calls",
usage: { inputTokens: 3, outputTokens: 4, totalTokens: 7 },
});
expect(bodies[0]).toMatchObject({
model: "gpt-5.5",
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }],
tools: [{ type: "function", name: "lookup" }],
tool_choice: { type: "function", name: "lookup" },
});
});
it("never forwards temperature or max_output_tokens (the codex backend 400s on them)", async () => {
const bodies: unknown[] = [];
const backend = new CodexBackend({
authPath: "/tmp/auth.json",
jitterMaxMs: 0,
loadAuth: async () => auth,
fetchImpl: (async (_url: string | URL | Request, init?: RequestInit) => {
bodies.push(JSON.parse(String(init?.body)));
return sseResponse([
'event: response.output_text.delta\ndata: {"delta":"ok"}\n\n',
'event: response.completed\ndata: {"response":{"stop_reason":"stop","usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
]);
}) as typeof fetch,
});
// The runtime's planner/response-handler calls always pass maxTokens (and
// often temperature); the ChatGPT codex backend rejects both with a 400
// "Unsupported parameter", which previously emptied every codex turn.
await backend.generate({ prompt: "hi", temperature: 0.7, maxTokens: 2048 });
const body = bodies[0] as Record<string, unknown>;
expect(body).not.toHaveProperty("temperature");
expect(body).not.toHaveProperty("max_output_tokens");
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* Auto-enable gate for the Codex CLI model-provider plugin.
* The manifest loads this module during boot, so it stays limited to config inspection and avoids backend/auth imports.
*/
import type { PluginAutoEnableContext } from "@elizaos/core";
/**
* Enable when any auth profile in the user's config selects the codex-cli
* provider. The plugin authenticates via OAuth tokens from `~/.codex/auth.json`,
* not an env var, so config presence is the right signal.
*/
export function shouldEnable(ctx: PluginAutoEnableContext): boolean {
const profiles = (ctx.config?.auth as Record<string, unknown> | undefined)
?.profiles;
if (!profiles || typeof profiles !== "object") return false;
return Object.values(profiles as Record<string, unknown>).some((p) => {
if (!p || typeof p !== "object") return false;
return (p as Record<string, unknown>).provider === "codex-cli";
});
}
/**
* Force-enable when the user picked the openai-codex subscription, even if
* the plugin entry has been explicitly disabled. The user deliberately
* connected the subscription, so the runtime needs the codex-cli plugin to
* resolve their chosen provider.
*/
export function shouldForce(ctx: PluginAutoEnableContext): boolean {
const agents = (ctx.config as Record<string, unknown> | undefined)?.agents as
| Record<string, unknown>
| undefined;
const defaults = agents?.defaults as Record<string, unknown> | undefined;
return defaults?.subscriptionProvider === "openai-codex";
}
+50
View File
@@ -0,0 +1,50 @@
{
"root": false,
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"files": {
"includes": [
"build.ts",
"index.ts",
"index.browser.ts",
"index.node.ts",
"init.ts",
"models/**",
"providers/**",
"types/**",
"utils/**",
"__tests__/**",
"!dist",
"!node_modules"
]
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"noUnusedVariables": "error"
},
"a11y": {
"useButtonType": "off"
},
"style": {},
"complexity": {
"noCommaOperator": "off"
}
}
},
"formatter": {
"enabled": true,
"indentWidth": 2,
"indentStyle": "space",
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "es5",
"semicolons": "always"
}
}
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bun
/**
* Build script for @elizaos/plugin-codex-cli (Node + Browser).
* Orchestration lives in the shared driver (plugins/plugin-build.ts); this lists
* only what differs.
*/
import { buildPlugin } from "../plugin-build";
// Single-quoted re-export to keep the emitted .d.ts byte-stable.
const reexport = "export * from '../index';\nexport { default } from '../index';\n";
await buildPlugin({
name: "@elizaos/plugin-codex-cli",
targets: [
{
label: "Node",
entry: "index.node.ts",
outSubdir: "node",
target: "node",
format: "esm",
},
{
label: "Browser",
entry: "index.browser.ts",
outSubdir: "browser",
target: "browser",
format: "esm",
minify: true,
},
],
dtsProject: "tsconfig.build.json",
dtsShims: [
{ path: "node/index.d.ts", content: reexport },
{ path: "browser/index.d.ts", content: reexport },
],
});
+26
View File
@@ -0,0 +1,26 @@
/**
* Browser build stub for the node-only Codex provider. Its model handlers throw
* because authentication reads ~/.codex/auth.json; the working implementation
* lives in index.ts and is only reachable in a node runtime.
*/
import type { Plugin } from "@elizaos/core";
import { ModelType } from "@elizaos/core";
const unsupported = async (): Promise<never> => {
throw new Error("@elizaos/plugin-codex-cli is node-only because it reads ~/.codex/auth.json");
};
export const codexCliPlugin: Plugin = {
name: "codex-cli",
description: "ChatGPT Codex model provider using the codex CLI OAuth token cache (node-only)",
config: {},
async init(): Promise<void> {
// Browser bundle intentionally does not import node:fs/node:crypto auth code.
},
models: {
[ModelType.TEXT_SMALL]: unsupported,
[ModelType.TEXT_LARGE]: unsupported,
},
};
export default codexCliPlugin;
+5
View File
@@ -0,0 +1,5 @@
/** Node build entry: re-exports the plugin from index.ts as the node bundle's default. */
import pluginDefault from "./index";
export * from "./index";
export default pluginDefault;
+276
View File
@@ -0,0 +1,276 @@
/**
* Plugin entry for the ChatGPT Codex model provider: registers the TEXT_*,
* RESPONSE_HANDLER, and ACTION_PLANNER model handlers that route generation
* through a user's ChatGPT subscription via the codex CLI OAuth cache. Every
* handler delegates to a single per-runtime CodexBackend (held in a WeakMap so
* calls on one runtime serialize through the backend's FIFO queue).
*
* Auto-enables only when an auth profile selects provider "codex-cli"; there is
* no env-key trigger. Tool- or message-bearing calls return native tool calls
* rather than a plain string, and streaming calls attach toolCalls so the
* planner still sees tool-only responses.
*/
import type { GenerateTextParams, IAgentRuntime, Plugin, TextStreamResult } from "@elizaos/core";
import { logger, ModelType } from "@elizaos/core";
import {
CodexBackend,
type CodexGenerateParams,
type CodexGenerateResult,
} from "./src/codex-backend";
const TEXT_NANO_MODEL_TYPE = (ModelType.TEXT_NANO ?? "TEXT_NANO") as string;
const TEXT_MEDIUM_MODEL_TYPE = (ModelType.TEXT_MEDIUM ?? "TEXT_MEDIUM") as string;
const TEXT_MEGA_MODEL_TYPE = (ModelType.TEXT_MEGA ?? "TEXT_MEGA") as string;
const RESPONSE_HANDLER_MODEL_TYPE = (ModelType.RESPONSE_HANDLER ?? "RESPONSE_HANDLER") as string;
const ACTION_PLANNER_MODEL_TYPE = (ModelType.ACTION_PLANNER ?? "ACTION_PLANNER") as string;
const CODEX_MODEL_SETTING = "CODEX_MODEL";
const CODEX_SUPPORTED_MODELS = [
"gpt-5",
"gpt-5-codex",
"gpt-5.4",
"gpt-5.5",
"gpt-5.5-pro",
] as const;
type RuntimeWithSettings = IAgentRuntime & {
getSetting?: (key: string) => string | number | boolean | undefined | null;
};
type TextResultWithNativeTools = {
text: string;
toolCalls: CodexGenerateResult["toolCalls"];
finishReason?: string;
usage?: CodexGenerateResult["usage"];
};
function readEnv(name: string): string | undefined {
if (typeof process === "undefined") return undefined;
return process.env[name];
}
function getSetting(runtime: IAgentRuntime, key: string): string | undefined {
const value = (runtime as RuntimeWithSettings).getSetting?.(key);
return value === undefined || value === null ? readEnv(key) : String(value);
}
function getCodexModel(runtime: IAgentRuntime): string {
return getSetting(runtime, CODEX_MODEL_SETTING) ?? "gpt-5.5";
}
function getRequestedCodexModel(runtime: IAgentRuntime, params: GenerateTextParams): string {
const requestedModel = (params as GenerateTextParams & { model?: unknown }).model;
return typeof requestedModel === "string" && requestedModel.trim().length > 0
? requestedModel.trim()
: getCodexModel(runtime);
}
const backendByRuntime = new WeakMap<IAgentRuntime, CodexBackend>();
function createBackend(runtime: IAgentRuntime): CodexBackend {
const existing = backendByRuntime.get(runtime);
if (existing) return existing;
const jitterRaw = getSetting(runtime, "CODEX_JITTER_MS_MAX");
const jitterMaxMs = jitterRaw === undefined ? undefined : Number.parseInt(jitterRaw, 10);
const backend = new CodexBackend({
authPath: getSetting(runtime, "CODEX_AUTH_PATH"),
baseUrl: getSetting(runtime, "CODEX_BASE_URL"),
model: getCodexModel(runtime),
originator: getSetting(runtime, "CODEX_ORIGINATOR"),
jitterMaxMs: Number.isFinite(jitterMaxMs) ? jitterMaxMs : undefined,
});
backendByRuntime.set(runtime, backend);
return backend;
}
function toTextReturn(
params: GenerateTextParams,
result: CodexGenerateResult
): string | TextResultWithNativeTools {
if (params.tools?.length || params.messages?.length || result.toolCalls.length > 0) {
return {
text: result.text,
toolCalls: result.toolCalls,
finishReason: result.finishReason,
usage: result.usage,
};
}
return result.text;
}
function buildCodexGenerateParams(
runtime: IAgentRuntime,
params: GenerateTextParams
): CodexGenerateParams {
// Honor `responseSchema` natively. OpenAI-compatible Codex models accept
// `response_format: { type: "json_schema", schema }` for guaranteed JSON
// output; if the caller already passed a custom `responseFormat`, leave it
// alone.
const paramsWithSchema = params as GenerateTextParams & {
responseSchema?: unknown;
system?: string;
};
const responseFormat =
params.responseFormat ??
(paramsWithSchema.responseSchema
? {
type: "json_schema" as const,
schema: paramsWithSchema.responseSchema as Record<string, unknown>,
}
: undefined);
return {
prompt: params.prompt ?? "",
system: paramsWithSchema.system,
messages: params.messages,
tools: params.tools,
toolChoice: params.toolChoice,
model: getRequestedCodexModel(runtime, params),
temperature: params.temperature,
maxTokens: params.maxTokens,
responseFormat,
};
}
function streamTextWithCodex(runtime: IAgentRuntime, params: GenerateTextParams): TextStreamResult {
const queue: string[] = [];
let notify: (() => void) | undefined;
let done = false;
const wake = () => {
notify?.();
notify = undefined;
};
const resultPromise = createBackend(runtime)
.generate({
...buildCodexGenerateParams(runtime, params),
onTextDelta: (delta) => {
queue.push(delta);
wake();
},
})
.finally(() => {
done = true;
wake();
});
async function* textStream(): AsyncIterable<string> {
while (!done || queue.length > 0) {
const next = queue.shift();
if (next !== undefined) {
yield next;
continue;
}
await new Promise<void>((resolve) => {
notify = resolve;
});
}
}
// The planner streams (the Discord message handler sets onStreamChunk), so
// ACTION_PLANNER/RESPONSE_HANDLER go through THIS path. The runtime collapses
// the stream and only preserves native tool calls when `toolCalls` is present
// on the returned object (it duck-types `"toolCalls" in streamRaw`). Without
// it, a tool-only response (empty text + native toolCalls) collapses to a bare
// empty string and the planner never sees the tool call — it replans until the
// required-tool cap and gives up. Mirror plugin-openai: attach `toolCalls`
// whenever the call is native, and stamp the model name for trajectory pricing.
const shouldReturnNativeTools = Boolean(
params.messages?.length || params.tools?.length || params.toolChoice
);
return {
textStream: textStream(),
text: resultPromise.then((result) => result.text),
...(shouldReturnNativeTools
? { toolCalls: resultPromise.then((result) => result.toolCalls) }
: {}),
usage: resultPromise.then((result) =>
result.usage
? {
promptTokens: result.usage.inputTokens,
completionTokens: result.usage.outputTokens,
totalTokens: result.usage.totalTokens,
}
: undefined
),
finishReason: resultPromise.then((result) => result.finishReason),
providerMetadata: { modelName: getRequestedCodexModel(runtime, params) },
};
}
async function generateTextWithCodex(
runtime: IAgentRuntime,
params: GenerateTextParams,
modelType: string
): Promise<string | TextResultWithNativeTools | TextStreamResult> {
const model = getRequestedCodexModel(runtime, params);
logger.debug(`[codex-cli] Using ${modelType} model: ${model}`);
if (params.stream) return streamTextWithCodex(runtime, params);
const result = await createBackend(runtime).generate(buildCodexGenerateParams(runtime, params));
return toTextReturn(params, result);
}
const codexModels = {
[ModelType.TEXT_SMALL]: (runtime: IAgentRuntime, params: GenerateTextParams) =>
generateTextWithCodex(runtime, params, ModelType.TEXT_SMALL),
[TEXT_NANO_MODEL_TYPE]: (runtime: IAgentRuntime, params: GenerateTextParams) =>
generateTextWithCodex(runtime, params, TEXT_NANO_MODEL_TYPE),
[TEXT_MEDIUM_MODEL_TYPE]: (runtime: IAgentRuntime, params: GenerateTextParams) =>
generateTextWithCodex(runtime, params, TEXT_MEDIUM_MODEL_TYPE),
[ModelType.TEXT_LARGE]: (runtime: IAgentRuntime, params: GenerateTextParams) =>
generateTextWithCodex(runtime, params, ModelType.TEXT_LARGE),
[TEXT_MEGA_MODEL_TYPE]: (runtime: IAgentRuntime, params: GenerateTextParams) =>
generateTextWithCodex(runtime, params, TEXT_MEGA_MODEL_TYPE),
[RESPONSE_HANDLER_MODEL_TYPE]: (runtime: IAgentRuntime, params: GenerateTextParams) =>
generateTextWithCodex(runtime, params, RESPONSE_HANDLER_MODEL_TYPE),
[ACTION_PLANNER_MODEL_TYPE]: (runtime: IAgentRuntime, params: GenerateTextParams) =>
generateTextWithCodex(runtime, params, ACTION_PLANNER_MODEL_TYPE),
} as Plugin["models"];
const codexModelMetadata = Object.fromEntries(
Object.keys(codexModels ?? {}).map((modelType) => [
modelType,
{ displayModelSetting: CODEX_MODEL_SETTING },
])
) satisfies NonNullable<Plugin["modelMetadata"]>;
/** @internal - exported for shape tests only. */
export const __INTERNAL_buildCodexGenerateParams = buildCodexGenerateParams;
export const codexCliPlugin: Plugin = {
name: "codex-cli",
description: "ChatGPT Codex model provider using the codex CLI OAuth token cache",
autoEnable: {
// No env-key auto-enable; activated when an auth profile selects codex-cli
// as its provider (e.g. via subscription onboarding).
shouldEnable: (_env, config) => {
const auth = (config as { auth?: { profiles?: Record<string, unknown> } }).auth;
const profiles = auth?.profiles;
if (!profiles || typeof profiles !== "object") return false;
return Object.values(profiles).some((profile) => {
if (!profile || typeof profile !== "object") return false;
return (profile as { provider?: unknown }).provider === "codex-cli";
});
},
},
config: {
CODEX_AUTH_PATH: readEnv("CODEX_AUTH_PATH") ?? null,
CODEX_BASE_URL: readEnv("CODEX_BASE_URL") ?? null,
[CODEX_MODEL_SETTING]: readEnv(CODEX_MODEL_SETTING) ?? null,
CODEX_JITTER_MS_MAX: readEnv("CODEX_JITTER_MS_MAX") ?? null,
CODEX_ORIGINATOR: readEnv("CODEX_ORIGINATOR") ?? null,
},
async init(): Promise<void> {
logger.info(`[codex-cli] initialized. Supported models: ${CODEX_SUPPORTED_MODELS.join(", ")}`);
},
models: codexModels,
modelMetadata: codexModelMetadata,
};
export * from "./src/codex-auth";
export * from "./src/sse-parser";
export * from "./src/tool-format-openai";
export { CODEX_SUPPORTED_MODELS, CodexBackend };
export default codexCliPlugin;
+124
View File
@@ -0,0 +1,124 @@
{
"name": "@elizaos/plugin-codex-cli",
"version": "2.0.3-beta.7",
"type": "module",
"main": "dist/node/index.node.js",
"module": "dist/node/index.node.js",
"types": "dist/index.d.ts",
"browser": "dist/browser/index.browser.js",
"sideEffects": false,
"repository": {
"type": "git",
"url": "git+https://github.com/elizaos-plugins/plugin-codex-cli.git"
},
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"browser": {
"types": "./dist/browser/index.d.ts",
"import": "./dist/browser/index.browser.js",
"default": "./dist/browser/index.browser.js"
},
"node": {
"types": "./dist/node/index.d.ts",
"import": "./dist/node/index.node.js",
"default": "./dist/node/index.node.js"
},
"default": "./dist/node/index.node.js"
},
"./*.css": "./dist/*.css",
"./*": {
"types": "./dist/*.d.ts",
"import": "./dist/*.js",
"default": "./dist/*.js"
}
},
"files": [
"dist",
"auto-enable.ts"
],
"elizaos": {
"plugin": {
"autoEnableModule": "./auto-enable.ts",
"capabilities": [
"text-large",
"text-small",
"tool-use"
]
}
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@elizaos/core": "workspace:*",
"@types/bun": "^1.3.8",
"@types/node": "^25.0.3",
"typescript": "^6.0.3",
"vitest": "^4.0.0"
},
"peerDependencies": {
"@elizaos/core": "workspace:*",
"zod": "^4.4.3"
},
"scripts": {
"build:ts": "bun run build.ts",
"dev": "bun --hot build.ts",
"lint": "bunx @biomejs/biome check --write --unsafe .",
"lint:check": "bunx @biomejs/biome check .",
"clean": "node ../../packages/scripts/rm-path-recursive.mjs dist .turbo",
"format": "bunx @biomejs/biome format --write .",
"format:check": "bunx @biomejs/biome format .",
"typecheck": "tsgo --noEmit",
"test": "vitest run --config ./vitest.config.ts",
"test:ts": "vitest run --config ./vitest.config.ts",
"build": "bun run build.ts"
},
"publishConfig": {
"access": "public"
},
"agentConfig": {
"pluginType": "elizaos:plugin:1.0.0",
"pluginParameters": {
"CODEX_AUTH_PATH": {
"type": "string",
"description": "Path to the codex CLI OAuth auth cache. Defaults to ~/.codex/auth.json.",
"required": false,
"sensitive": false
},
"CODEX_BASE_URL": {
"type": "string",
"description": "Base URL for the ChatGPT Codex backend endpoint.",
"required": false,
"default": "https://chatgpt.com/backend-api/codex",
"sensitive": false
},
"CODEX_MODEL": {
"type": "string",
"description": "Codex model identifier to use for text/object generation.",
"required": false,
"default": "gpt-5.5",
"sensitive": false
},
"CODEX_JITTER_MS_MAX": {
"type": "number",
"description": "Maximum pre-request jitter in milliseconds. Set 0 to disable.",
"required": false,
"default": 200,
"sensitive": false
},
"CODEX_ORIGINATOR": {
"type": "string",
"description": "Originator header sent to the ChatGPT Codex backend.",
"required": false,
"default": "codex_cli_rs",
"sensitive": false
}
}
},
"eliza": {
"platforms": [
"node"
],
"runtime": "node"
}
}
+220
View File
@@ -0,0 +1,220 @@
/** Load, refresh, and atomically save codex CLI ChatGPT OAuth cache. */
import { randomBytes } from "node:crypto";
import { open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
const OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
const OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const LOCK_STALE_MS = 30_000;
const LOCK_RETRY_DELAY_MS = 100;
const LOCK_RETRY_MAX = 30;
export interface CodexAuth {
OPENAI_API_KEY: string | null;
auth_mode: "chatgpt" | "apikey";
last_refresh: string;
tokens: {
id_token: string;
access_token: string;
refresh_token: string;
account_id: string;
};
}
export interface CodexAuthDeps {
fetch?: typeof fetch;
now?: () => number;
}
let injectedDeps: CodexAuthDeps = {};
export function __setCodexAuthDeps(deps: CodexAuthDeps): void {
injectedDeps = deps;
}
export function __resetCodexAuthDeps(): void {
injectedDeps = {};
}
function getFetch(): typeof fetch {
return injectedDeps.fetch ?? fetch;
}
function nowMs(): number {
return injectedDeps.now ? injectedDeps.now() : Date.now();
}
export function defaultAuthPath(): string {
return join(homedir(), ".codex", "auth.json");
}
export async function loadCodexAuth(path?: string): Promise<CodexAuth> {
const p = path ?? defaultAuthPath();
const raw = await readFile(p, "utf8");
const parsed = JSON.parse(raw) as Partial<CodexAuth>;
if (
!parsed ||
typeof parsed !== "object" ||
!parsed.tokens ||
typeof parsed.tokens.access_token !== "string" ||
typeof parsed.tokens.refresh_token !== "string"
) {
throw new Error(`codex auth.json malformed at ${p}: missing access/refresh token fields`);
}
return {
OPENAI_API_KEY: parsed.OPENAI_API_KEY ?? null,
auth_mode: parsed.auth_mode === "apikey" ? "apikey" : "chatgpt",
last_refresh: parsed.last_refresh ?? new Date(0).toISOString(),
tokens: {
id_token: typeof parsed.tokens.id_token === "string" ? parsed.tokens.id_token : "",
access_token: parsed.tokens.access_token,
refresh_token: parsed.tokens.refresh_token,
account_id: typeof parsed.tokens.account_id === "string" ? parsed.tokens.account_id : "",
},
};
}
export async function saveCodexAuth(auth: CodexAuth, path: string): Promise<void> {
const tmp = `${path}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
await writeFile(tmp, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
try {
await rename(tmp, path);
} catch (err) {
try {
await unlink(tmp);
} catch {
// ignore cleanup failure
}
throw err;
}
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split(".");
if (parts.length < 2 || !parts[1]) return null;
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
try {
return JSON.parse(Buffer.from(b64 + pad, "base64").toString("utf8")) as Record<string, unknown>;
} catch {
return null;
}
}
export function isExpired(auth: CodexAuth, bufferSeconds = 60): boolean {
const payload = decodeJwtPayload(auth.tokens.access_token);
const exp = payload?.exp;
if (typeof exp !== "number") return true;
return nowMs() + bufferSeconds * 1000 >= exp * 1000;
}
interface AcquiredLock {
release: () => Promise<void>;
}
async function tryCreateLock(lockPath: string): Promise<AcquiredLock | null> {
try {
const fh = await open(lockPath, "wx", 0o600);
try {
await fh.writeFile(`${process.pid}\n`);
} finally {
await fh.close();
}
return {
release: async () => {
try {
await unlink(lockPath);
} catch {
// already gone
}
},
};
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EEXIST") return null;
throw err;
}
}
async function isLockStale(lockPath: string): Promise<boolean> {
try {
return nowMs() - (await stat(lockPath)).mtimeMs > LOCK_STALE_MS;
} catch {
return false;
}
}
async function acquireLock(authPath: string): Promise<AcquiredLock> {
const lockPath = `${authPath}.lock`;
for (let attempt = 0; attempt < LOCK_RETRY_MAX; attempt++) {
const lock = await tryCreateLock(lockPath);
if (lock) return lock;
if (await isLockStale(lockPath)) {
try {
await unlink(lockPath);
} catch {
// race with another process
}
continue;
}
await new Promise<void>((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS));
}
throw new Error(`codex-auth: could not acquire lock ${lockPath} after ${LOCK_RETRY_MAX} retries`);
}
interface OAuthTokenResponse {
access_token: string;
refresh_token?: string;
id_token?: string;
}
export async function refreshCodexAuth(currentAuth: CodexAuth, path: string): Promise<CodexAuth> {
const lock = await acquireLock(path);
try {
try {
const onDisk = await loadCodexAuth(path);
if (!isExpired(onDisk)) return onDisk;
currentAuth = onDisk;
} catch {
// fall back to current auth
}
const res = await getFetch()(OAUTH_TOKEN_URL, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: currentAuth.tokens.refresh_token,
client_id: OAUTH_CLIENT_ID,
}).toString(),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`codex-auth: refresh failed: ${res.status} ${res.statusText} ${text}`.trim());
}
const json = (await res.json()) as OAuthTokenResponse;
if (!json || typeof json.access_token !== "string") {
throw new Error("codex-auth: refresh response missing access_token");
}
const next: CodexAuth = {
...currentAuth,
last_refresh: new Date(nowMs()).toISOString(),
tokens: {
...currentAuth.tokens,
access_token: json.access_token,
refresh_token: json.refresh_token ?? currentAuth.tokens.refresh_token,
id_token: json.id_token ?? currentAuth.tokens.id_token,
},
};
await saveCodexAuth(next, path);
return next;
} finally {
await lock.release();
}
}
@@ -0,0 +1,577 @@
/**
* HTTP client for the ChatGPT Codex `/responses` SSE endpoint, plus the
* provider-neutral message/tool translation it needs. CodexBackend loads the
* codex CLI OAuth token, posts a Responses-API request, and consumes the event
* stream into text, native tool calls, finish reason, and token usage.
*
* Calls on one instance serialize through a FIFO tail promise with optional
* pre-request jitter. A 401 triggers exactly one OAuth refresh-and-retry. The
* base URL is restricted to chatgpt.com or localhost to prevent token
* exfiltration, and temperature/max_output_tokens are never sent because the
* gpt-5.x reasoning models reject them with a 400.
*/
import { logger, type ChatMessage, type JsonValue, type ToolCall, type ToolDefinition } from "@elizaos/core";
import { parseSSE } from "./sse-parser";
import { toOpenAITool, type OpenAITool } from "./tool-format-openai";
import {
defaultAuthPath,
loadCodexAuth as loadCodexAuthDefault,
refreshCodexAuth as refreshCodexAuthDefault,
type CodexAuth,
} from "./codex-auth";
export type { CodexAuth } from "./codex-auth";
export type { OpenAITool } from "./tool-format-openai";
export interface CodexBackendConfig {
authPath?: string;
model?: string;
baseUrl?: string;
userAgent?: string;
originator?: string;
jitterMaxMs?: number;
fetchImpl?: typeof fetch;
loadAuth?: (path: string) => Promise<CodexAuth>;
refreshAuth?: (currentAuth: CodexAuth, path: string) => Promise<CodexAuth>;
toolTranslator?: (tool: ToolDefinition) => OpenAITool;
}
type CodexToolChoice =
| "auto"
| "none"
| "required"
| { type: "tool"; name: string }
| { type: "function"; function: { name: string } }
| { name: string };
export interface CodexGenerateParams {
prompt: string;
system?: string;
messages?: ChatMessage[];
tools?: ToolDefinition[];
toolChoice?: CodexToolChoice;
model?: string;
temperature?: number;
maxTokens?: number;
abortSignal?: AbortSignal;
onTextDelta?: (delta: string) => void;
responseFormat?:
| { type: "json_object" | "text" }
| { type: "json_schema"; schema: Record<string, unknown> }
| string;
}
export interface CodexGenerateResult {
text: string;
toolCalls: ToolCall[];
finishReason?: string;
usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
}
type CodexInputItem =
| {
type: "message";
role: "user" | "assistant" | "system";
content: Array<{ type: "input_text" | "output_text"; text: string }>;
}
| { type: "function_call"; call_id: string; name: string; arguments: string }
| { type: "function_call_output"; call_id: string; output: string };
interface CodexResponseBody {
model: string;
instructions: string;
input: CodexInputItem[];
store: false;
stream: true;
tools?: OpenAITool[];
tool_choice?: "auto" | "none" | "required" | { type: "function"; name: string };
text?: { format: { type: "json_object" } };
// NB: the ChatGPT codex backend rejects `temperature` and `max_output_tokens`
// (gpt-5.x reasoning models) with 400 "Unsupported parameter" — never include
// them in the request body.
}
const DEFAULT_MODEL = "gpt-5.5";
const DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex";
const DEFAULT_USER_AGENT = "codex_cli_rs/0.124.0";
const DEFAULT_ORIGINATOR = "codex_cli_rs";
const DEFAULT_JITTER_MAX_MS = 200;
const DEFAULT_JITTER_MIN_MS = 50;
export class CodexBackend {
readonly name = "codex-cli";
private readonly authPath: string;
private readonly model: string;
private readonly baseUrl: string;
private readonly userAgent: string;
private readonly originator: string;
private readonly jitterMaxMs: number;
private readonly fetchImpl: typeof fetch;
private readonly loadAuth: (path: string) => Promise<CodexAuth>;
private readonly refreshAuth: (currentAuth: CodexAuth, path: string) => Promise<CodexAuth>;
private readonly toolTranslator: (tool: ToolDefinition) => OpenAITool;
private tail: Promise<unknown> = Promise.resolve();
constructor(config: CodexBackendConfig = {}) {
this.authPath = config.authPath ?? process.env.CODEX_AUTH_PATH ?? defaultAuthPath();
this.model = config.model ?? process.env.CODEX_MODEL ?? DEFAULT_MODEL;
this.baseUrl = validateBaseUrl(stripTrailingSlash(config.baseUrl ?? process.env.CODEX_BASE_URL ?? DEFAULT_BASE_URL));
this.userAgent = config.userAgent ?? process.env.CODEX_USER_AGENT ?? DEFAULT_USER_AGENT;
this.originator = config.originator ?? process.env.CODEX_ORIGINATOR ?? DEFAULT_ORIGINATOR;
this.jitterMaxMs = config.jitterMaxMs ?? envInt("CODEX_JITTER_MS_MAX", DEFAULT_JITTER_MAX_MS);
this.fetchImpl = config.fetchImpl ?? fetch;
this.loadAuth = config.loadAuth ?? loadCodexAuthDefault;
this.refreshAuth = config.refreshAuth ?? refreshCodexAuthDefault;
this.toolTranslator = config.toolTranslator ?? toOpenAITool;
}
async generate(params: CodexGenerateParams): Promise<CodexGenerateResult> {
const prior = this.tail;
let release!: () => void;
this.tail = new Promise<void>((resolve) => {
release = resolve;
});
try {
await prior;
await this.jitter();
return await this.generateInner(params);
} finally {
release();
}
}
private async jitter(): Promise<void> {
if (this.jitterMaxMs <= 0) return;
const lo = Math.min(DEFAULT_JITTER_MIN_MS, this.jitterMaxMs);
const span = Math.max(0, this.jitterMaxMs - lo);
await new Promise((resolve) => setTimeout(resolve, lo + Math.floor(Math.random() * (span + 1))));
}
private async generateInner(params: CodexGenerateParams): Promise<CodexGenerateResult> {
const systemPrompt = params.system ?? extractSystemPrompt(params.messages) ?? "";
const body: CodexResponseBody = {
model: params.model ?? this.model,
instructions: systemPrompt,
input: translateMessagesToCodexInput(params.messages, params.prompt),
store: false,
stream: true,
};
const tools = (params.tools ?? []).map((tool) => this.toolTranslator(tool));
if (tools.length > 0) body.tools = tools;
const toolChoice = toCodexToolChoice(params.toolChoice);
if (toolChoice !== undefined) body.tool_choice = toolChoice;
// The ChatGPT codex backend (gpt-5.x reasoning models on the Responses API)
// rejects `temperature` and `max_output_tokens` with a 400 "Unsupported
// parameter" — reasoning models honor neither, and the official codex CLI
// never sends them. The runtime's planner/response-handler calls always pass
// maxTokens (and often temperature), so forwarding them 400'd every codex
// turn → empty result → no reply. Never send them to the codex backend.
if (isJsonResponse(params.responseFormat)) body.text = { format: { type: "json_object" } };
let auth = await this.loadAuth(this.authPath);
let res = await this.postResponses(auth, body, params.abortSignal);
if (res.status === 401) {
logger.warn("[codex-cli] 401 from /responses, refreshing OAuth and retrying once");
auth = await this.refreshAuth(auth, this.authPath);
res = await this.postResponses(auth, body, params.abortSignal);
}
if (!res.ok) {
const errText = await safeReadText(res);
throw new Error(`codex /responses returned ${res.status} ${res.statusText} :: ${errText.slice(0, 512)}`);
}
if (!res.body) throw new Error("codex /responses returned no body");
return consumeResponseStream(res.body, params.abortSignal, params.onTextDelta);
}
private async postResponses(auth: CodexAuth, body: CodexResponseBody, signal?: AbortSignal): Promise<Response> {
const headers: Record<string, string> = {
Authorization: `Bearer ${auth.tokens.access_token}`,
"Content-Type": "application/json",
originator: this.originator,
"User-Agent": this.userAgent,
"OpenAI-Beta": "responses=v1",
Accept: "text/event-stream",
};
if (auth.tokens.account_id) headers["chatgpt-account-id"] = auth.tokens.account_id;
return this.fetchImpl(`${this.baseUrl}/responses`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal,
});
}
}
export function translateMessagesToCodexInput(messages: ChatMessage[] | undefined, prompt: string): CodexInputItem[] {
const out: CodexInputItem[] = [];
if (!messages || messages.length === 0) {
if (prompt.trim().length > 0) {
out.push({ type: "message", role: "user", content: [{ type: "input_text", text: prompt }] });
}
return out;
}
let lastText = "";
// function_call ids emitted but not yet paired with their output, in order.
// The Responses API rejects a function_call_output whose call_id has no
// matching function_call ("No tool call found for function call output with
// call_id ..."), so every output must reference a real preceding call.
const pendingCallIds: string[] = [];
for (const message of messages) {
if (message.role === "system" || message.role === "developer") continue;
if (message.role === "tool") {
// A tool turn carries one or more results. The planner renders them as
// `tool-result` CONTENT PARTS (toolCallId + output on the part); older
// callers put a single result in plain-text content keyed by the
// message-level toolCallId. Handle both so the fetched data actually
// reaches the model — dropping it makes the model believe its own tool
// call never ran ("I don't have the fetched contents visible here").
const resultParts = toolResultPartsFromContent(message.content);
const results =
resultParts.length > 0
? resultParts
: [{ toolCallId: message.toolCallId, text: contentToText(message.content) }];
for (const result of results) {
const wantId = result.toolCallId ?? message.toolCallId;
let callId: string | undefined;
const idx = wantId ? pendingCallIds.indexOf(wantId) : -1;
if (idx >= 0) {
callId = pendingCallIds[idx];
pendingCallIds.splice(idx, 1);
} else if (pendingCallIds.length > 0) {
callId = pendingCallIds.shift();
}
if (callId) {
out.push({
type: "function_call_output",
call_id: callId,
output: result.text,
});
} else if (result.text.trim().length > 0) {
// Orphaned tool result (no preceding function_call in this
// transcript): render its content as plain context instead of an
// unmatched function_call_output that the backend would 400 on.
out.push({
type: "message",
role: "user",
content: [{ type: "input_text", text: result.text }],
});
}
}
continue;
}
if (message.role === "assistant") {
const text = contentToText(message.content);
if (text.length > 0) {
lastText = text;
out.push({ type: "message", role: "assistant", content: [{ type: "output_text", text }] });
}
// Tool calls arrive either as a structured `toolCalls` field or as
// `tool-call` content parts (what the planner renders). Emit both,
// deduped by id, so the assistant's prior calls are preserved and the
// following tool results have a real function_call to pair with.
const seen = new Set<string>();
const toolCalls = [
...(message.toolCalls ?? []).map((toolCall) => ({
id: toolCall.id,
name: toolCall.name,
arguments:
typeof toolCall.arguments === "string"
? toolCall.arguments
: JSON.stringify(toolCall.arguments ?? {}),
})),
...toolCallPartsFromContent(message.content),
];
for (const toolCall of toolCalls) {
if (!toolCall.id || seen.has(toolCall.id)) continue;
seen.add(toolCall.id);
out.push({
type: "function_call",
call_id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
});
pendingCallIds.push(toolCall.id);
}
continue;
}
const text = contentToText(message.content);
lastText = text;
out.push({ type: "message", role: "user", content: [{ type: "input_text", text }] });
}
if (prompt.trim().length > 0 && prompt !== lastText) {
out.push({ type: "message", role: "user", content: [{ type: "input_text", text: prompt }] });
}
return out;
}
function extractSystemPrompt(messages?: ChatMessage[]): string | undefined {
const parts = messages
?.filter((message) => message.role === "system" || message.role === "developer")
.map((message) => contentToText(message.content))
.filter(Boolean);
return parts && parts.length > 0 ? parts.join("\n\n") : undefined;
}
function contentToText(content: ChatMessage["content"]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((part) => (part.type === "text" && typeof part.text === "string" ? part.text : ""))
.filter(Boolean)
.join("\n");
}
/** Flatten a tool-result part's `output`/`result` into a single string. */
function toolOutputText(output: unknown): string {
if (output == null) return "";
if (typeof output === "string") return output;
if (Array.isArray(output)) return contentToText(output as ChatMessage["content"]);
if (isRecord(output)) {
if (typeof output.value === "string") return output.value;
if (typeof output.text === "string") return output.text;
return JSON.stringify(output);
}
return String(output);
}
/** Extract `tool-result` content parts (toolCallId + flattened output text). */
function toolResultPartsFromContent(
content: ChatMessage["content"],
): Array<{ toolCallId?: string; text: string }> {
if (!Array.isArray(content)) return [];
const results: Array<{ toolCallId?: string; text: string }> = [];
for (const part of content) {
if (!isRecord(part) || part.type !== "tool-result") continue;
results.push({
toolCallId: typeof part.toolCallId === "string" ? part.toolCallId : undefined,
text: toolOutputText(part.output ?? part.result),
});
}
return results;
}
/** Extract `tool-call` content parts as codex function-call descriptors. */
function toolCallPartsFromContent(
content: ChatMessage["content"],
): Array<{ id: string; name: string; arguments: string }> {
if (!Array.isArray(content)) return [];
const calls: Array<{ id: string; name: string; arguments: string }> = [];
for (const part of content) {
if (!isRecord(part) || part.type !== "tool-call") continue;
const argSource = part.input ?? part.args ?? {};
calls.push({
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
name: typeof part.toolName === "string" ? part.toolName : "",
arguments: typeof argSource === "string" ? argSource : JSON.stringify(argSource ?? {}),
});
}
return calls;
}
interface ActiveFunctionCall {
id: string;
name: string;
args: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function stringProperty(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;
}
function recordProperty(record: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
const value = record[key];
return isRecord(value) ? value : undefined;
}
function isJsonRecord(value: unknown): value is Record<string, JsonValue> {
if (!isRecord(value)) return false;
return Object.values(value).every(isJsonValue);
}
function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return true;
if (Array.isArray(value)) return value.every(isJsonValue);
return isJsonRecord(value);
}
async function consumeResponseStream(
body: ReadableStream<Uint8Array>,
abortSignal?: AbortSignal,
onTextDelta?: (delta: string) => void
): Promise<CodexGenerateResult> {
let text = "";
const toolCalls: ToolCall[] = [];
const activeByItemId = new Map<string, ActiveFunctionCall>();
let finishReason: string | undefined;
let usage: CodexGenerateResult["usage"] | undefined;
let failed: { code?: string; message?: string } | null = null;
const iter = parseSSE(body);
let abortPromise: Promise<never> | null = null;
let onAbort: (() => void) | null = null;
if (abortSignal) {
abortPromise = new Promise<never>((_, reject) => {
onAbort = () => reject(new Error("codex stream aborted"));
abortSignal.addEventListener("abort", onAbort, { once: true });
});
}
try {
while (true) {
if (abortSignal?.aborted) throw new Error("codex stream aborted");
const next = abortPromise ? await Promise.race([iter.next(), abortPromise]) : await iter.next();
if (next.done) break;
if (!next.value.data) continue;
let payload: unknown;
try {
payload = JSON.parse(next.value.data);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const preview = next.value.data.slice(0, 200);
logger.debug(
`[codex-cli] dropped malformed SSE event: ${message} preview=${preview}`,
);
continue;
}
const payloadRecord = isRecord(payload) ? payload : {};
const evType = next.value.event ?? stringProperty(payloadRecord, "type") ?? "";
switch (evType) {
case "response.output_text.delta": {
const delta = payloadRecord.delta;
if (typeof delta === "string") {
text += delta;
onTextDelta?.(delta);
}
break;
}
case "response.output_item.added": {
const item = recordProperty(payloadRecord, "item");
if (item?.type === "function_call") {
const itemId = stringProperty(item, "id") ?? stringProperty(item, "call_id");
const callId = stringProperty(item, "call_id");
const name = stringProperty(item, "name");
if (itemId && callId && name) {
activeByItemId.set(itemId, { id: callId, name, args: "" });
}
}
break;
}
case "response.function_call_arguments.delta": {
const itemId = stringProperty(payloadRecord, "item_id");
const delta = payloadRecord.delta;
const call = itemId ? activeByItemId.get(itemId) : undefined;
if (call && typeof delta === "string") call.args += delta;
break;
}
case "response.output_item.done": {
const item = recordProperty(payloadRecord, "item");
if (item?.type === "function_call") {
const itemId = stringProperty(item, "id") ?? stringProperty(item, "call_id");
const call = itemId ? activeByItemId.get(itemId) : undefined;
if (call) {
const argStr = stringProperty(item, "arguments") ?? call.args;
let parsed: Record<string, JsonValue> | string = argStr;
try {
if (argStr) {
const parsedJson: unknown = JSON.parse(argStr);
parsed = isJsonRecord(parsedJson) ? parsedJson : argStr;
} else {
parsed = {};
}
} catch {
// keep raw string
}
toolCalls.push({ id: call.id, name: call.name, arguments: parsed, type: "function" });
if (itemId) activeByItemId.delete(itemId);
}
}
break;
}
case "response.completed": {
const resp = recordProperty(payloadRecord, "response");
const stopReason = resp ? resp.stop_reason : undefined;
if (stopReason) finishReason = String(stopReason);
const respUsage = resp ? recordProperty(resp, "usage") : undefined;
if (respUsage) {
const inputTokens = numOrZero(respUsage.input_tokens);
const outputTokens = numOrZero(respUsage.output_tokens);
usage = { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens };
}
return { text, toolCalls, finishReason, usage };
}
case "response.failed": {
const resp = recordProperty(payloadRecord, "response");
const error = resp ? recordProperty(resp, "error") : undefined;
failed = {
code: error ? stringProperty(error, "code") : undefined,
message: error ? stringProperty(error, "message") : undefined,
};
throw new Error(`codex response.failed: ${failed.code ?? "unknown"} ${failed.message ?? ""}`.trim());
}
default:
break;
}
}
} finally {
if (abortSignal && onAbort) abortSignal.removeEventListener("abort", onAbort);
void iter.return?.(undefined).catch(() => {});
if (!body.locked) void body.cancel().catch(() => {});
}
logger.warn(
`[codex-cli] SSE stream ended without response.completed; returning partial result (text=${text.length} toolCalls=${toolCalls.length} finishReason=${finishReason})`,
);
return { text, toolCalls, finishReason, usage };
}
function stripTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}
function validateBaseUrl(value: string): string {
const url = new URL(value);
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
if (url.hostname === "chatgpt.com" && url.protocol === "https:") return value;
if (localHosts.has(url.hostname) && (url.protocol === "http:" || url.protocol === "https:")) return value;
throw new Error("CODEX_BASE_URL may only target https://chatgpt.com or localhost to avoid OAuth token exfiltration");
}
function envInt(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined) return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
function numOrZero(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function isJsonResponse(responseFormat: CodexGenerateParams["responseFormat"]): boolean {
return responseFormat === "json_object" || (typeof responseFormat === "object" && responseFormat?.type === "json_object");
}
function toCodexToolChoice(
toolChoice: CodexGenerateParams["toolChoice"]
): CodexResponseBody["tool_choice"] | undefined {
if (!toolChoice) return undefined;
if (toolChoice === "auto" || toolChoice === "none" || toolChoice === "required") return toolChoice;
if ("name" in toolChoice) return { type: "function", name: toolChoice.name };
return { type: "function", name: toolChoice.function.name };
}
async function safeReadText(res: Response): Promise<string> {
try {
return await res.text();
} catch {
return "";
}
}
@@ -0,0 +1,88 @@
/** Spec-compliant enough SSE parser for ChatGPT Codex response streams. */
export interface SSEEvent {
event?: string;
data: string;
id?: string;
retry?: number;
}
export async function* parseSSE(stream: ReadableStream<Uint8Array>): AsyncGenerator<SSEEvent> {
const decoder = new TextDecoder();
const reader = stream.getReader();
let buffer = "";
let eventName: string | undefined;
let eventId: string | undefined;
let retry: number | undefined;
let dataLines: string[] = [];
const emit = (): SSEEvent | null => {
if (dataLines.length === 0 && eventName === undefined && eventId === undefined && retry === undefined) {
return null;
}
const event: SSEEvent = { data: dataLines.join("\n") };
if (eventName !== undefined) event.event = eventName;
if (eventId !== undefined) event.id = eventId;
if (retry !== undefined) event.retry = retry;
eventName = undefined;
retry = undefined;
dataLines = [];
return event;
};
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (true) {
const nl = buffer.search(/\r\n|\r|\n/);
if (nl === -1) break;
const line = buffer.slice(0, nl);
const nextChar = buffer[nl] === "\r" && buffer[nl + 1] === "\n" ? 2 : 1;
buffer = buffer.slice(nl + nextChar);
if (line === "") {
const event = emit();
if (event) yield event;
continue;
}
if (line.startsWith(":")) continue;
const colon = line.indexOf(":");
const field = colon === -1 ? line : line.slice(0, colon);
let valueText = colon === -1 ? "" : line.slice(colon + 1);
if (valueText.startsWith(" ")) valueText = valueText.slice(1);
if (field === "event") eventName = valueText;
else if (field === "data") dataLines.push(valueText);
else if (field === "id") eventId = valueText;
else if (field === "retry") {
const parsed = Number.parseInt(valueText, 10);
if (Number.isFinite(parsed)) retry = parsed;
}
}
}
buffer += decoder.decode();
if (buffer.length > 0) {
for (const line of buffer.split(/\r\n|\r|\n/)) {
if (line === "") {
const event = emit();
if (event) yield event;
} else if (!line.startsWith(":")) {
const colon = line.indexOf(":");
const field = colon === -1 ? line : line.slice(0, colon);
let valueText = colon === -1 ? "" : line.slice(colon + 1);
if (valueText.startsWith(" ")) valueText = valueText.slice(1);
if (field === "event") eventName = valueText;
else if (field === "data") dataLines.push(valueText);
else if (field === "id") eventId = valueText;
}
}
}
const event = emit();
if (event) yield event;
} finally {
reader.releaseLock();
}
}
@@ -0,0 +1,88 @@
/**
* Maps elizaOS ToolDefinition objects to the OpenAI Responses function-tool
* shape. Strict tools are normalized to satisfy the codex backend's schema
* rules (see makeStrictSchema below); loose tools pass through verbatim.
*/
import type { ToolDefinition } from "@elizaos/core";
export interface OpenAITool {
type: "function";
name: string;
description: string;
parameters: object;
strict?: boolean;
}
type JsonSchema = Record<string, unknown>;
/**
* The ChatGPT codex backend enforces OpenAI strict-function-schema rules whenever
* a tool is marked `strict: true`: every object schema MUST set
* `additionalProperties: false` and list ALL of its `properties` keys in
* `required`. elizaOS action tools (core's to-tool.ts) set `strict: true` but
* author schemas with partial `required` and no `additionalProperties`, so the
* backend rejects them with 400 "Invalid schema for function '<name>'". Other
* providers tolerate this; the codex backend does not.
*
* `makeStrictSchema` normalizes a schema to be strict-compliant: `required`
* becomes every property key, `additionalProperties` becomes false, and keys that
* were NOT originally required are made nullable (a `null` union member) so the
* now-required keys keep their optional semantics — the model may emit `null`
* instead of being forced to invent a value.
*/
function makeNullable(schema: unknown): unknown {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
const s = schema as JsonSchema;
if (Array.isArray(s.type)) {
return (s.type as unknown[]).includes("null")
? s
: { ...s, type: [...(s.type as unknown[]), "null"] };
}
if (typeof s.type === "string") return { ...s, type: [s.type, "null"] };
return s;
}
function makeStrictSchema(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) return schema.map(makeStrictSchema);
const out = { ...(schema as JsonSchema) };
if (out.type === "object" && out.properties && typeof out.properties === "object") {
const props = out.properties as JsonSchema;
const keys = Object.keys(props);
const origRequired = new Set(
Array.isArray(out.required) ? (out.required as string[]) : [],
);
const next: JsonSchema = {};
for (const key of keys) {
let prop = makeStrictSchema(props[key]);
if (!origRequired.has(key)) prop = makeNullable(prop);
next[key] = prop;
}
out.properties = next;
out.required = keys;
out.additionalProperties = false;
}
if (out.items) out.items = makeStrictSchema(out.items);
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
if (Array.isArray(out[key])) out[key] = (out[key] as unknown[]).map(makeStrictSchema);
}
return out;
}
export function toOpenAITool(tool: ToolDefinition): OpenAITool {
const strict = tool.strict ?? false;
const parameters = (tool.parameters ?? { type: "object", properties: {} }) as object;
return {
type: "function",
name: tool.name,
description: tool.description ?? "",
// Only strict tools need normalization; the backend accepts loose schemas
// verbatim when strict is false.
parameters: strict ? (makeStrictSchema(parameters) as object) : parameters,
strict,
};
}
export function toOpenAITools(tools: ToolDefinition[]): OpenAITool[] {
return tools.map(toOpenAITool);
}
@@ -0,0 +1,32 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"ignoreDeprecations": "6.0",
"noEmit": false,
"paths": {
"@elizaos/core": [
"../../../packages/core/dist"
]
},
"types": [
"node",
"bun-types"
]
},
"include": [
"index.ts",
"index.node.ts",
"index.browser.ts"
],
"exclude": [
"node_modules",
"dist",
"__tests__",
"**/*.test.ts",
"build.ts"
]
}
+46
View File
@@ -0,0 +1,46 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": [
"ES2022",
"DOM"
],
"strict": true,
"strictNullChecks": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": false,
"allowImportingTsExtensions": true,
"types": [
"node",
"bun-types"
],
"paths": {
"@elizaos/core": [
"../../../packages/core/dist/node"
]
}
},
"include": [
"./**/*.ts"
],
"exclude": [
"node_modules",
"dist",
"**/__tests__/**",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.e2e.test.ts",
"build.ts"
]
}
+21
View File
@@ -0,0 +1,21 @@
/** Vitest config: aliases @elizaos/core to its built node dist and skips the live/real-model suites. */
import path from "node:path";
import { defineConfig } from "vitest/config";
const elizaRoot = path.resolve(import.meta.dirname, "../..");
export default defineConfig({
resolve: {
alias: [
{
find: /^@elizaos\/core$/,
replacement: path.join(elizaRoot, "packages", "core", "dist", "node", "index.node.js"),
},
],
},
test: {
environment: "node",
include: ["__tests__/**/*.test.ts", "src/**/*.test.ts"],
exclude: ["**/node_modules/**", "**/dist/**", "**/*.live.test.ts", "**/*.real.test.ts"],
},
});