This document explains the technical design decisions, architecture, and implementation details of the OpenAI Codex OAuth plugin for OpenCode.
- Architecture Overview
- Stateless vs Stateful Mode
- Message ID Handling
- Reasoning Content Flow
- Request Pipeline
- Comparison with Codex CLI
- Design Rationale
- TUI Parity Checklist
┌─────────────┐
│ OpenCode │ TUI/Desktop client
└──────┬──────┘
│
│ streamText() with AI SDK
│
▼
┌──────────────────────────────┐
│ OpenCode Provider System │
│ - Loads plugin │
│ - Calls plugin.auth.loader() │
│ - Passes provider config │
└──────┬───────────────────────┘
│
│ Custom fetch()
│
▼
┌──────────────────────────────┐
│ This Plugin │
│ - OAuth authentication │
│ - Request transformation │
│ - store:false handling │
│ - Codex bridge prompts │
└──────┬───────────────────────┘
│
│ HTTP POST with OAuth
│
▼
┌──────────────────────────────┐
│ OpenAI Codex API │
│ (ChatGPT Backend) │
│ - Requires OAuth │
│ - Supports store:false │
│ - Returns SSE stream │
└──────────────────────────────┘
The plugin uses store: false (stateless mode) because:
-
ChatGPT Backend Requirement (confirmed via testing):
// Attempt with store:true → 400 Bad Request {"detail":"Store must be set to false"}
-
Codex CLI Behavior (
tmp/codex/codex-rs/core/src/client.rs:215-232):// Codex CLI uses store:false for ChatGPT OAuth let azure_workaround = self.provider.is_azure_responses_endpoint(); store: azure_workaround, // false for ChatGPT, true for Azure
Key Points:
- ✅ ChatGPT backend REQUIRES store:false (not optional)
- ✅ Codex CLI uses store:false for ChatGPT
- ✅ Azure requires store:true (different endpoint, not supported by this plugin)
- ✅ Stateless mode = no server-side conversation storage
Question: If there's no server storage, how does the LLM remember previous turns?
Answer: Full message history is sent in every request:
// Turn 3 request contains ALL previous messages:
input: [
{ role: "developer", content: "..." }, // System prompts
{ role: "user", content: "write test.txt" }, // Turn 1 user
{ type: "function_call", name: "write", ... }, // Turn 1 tool call
{ type: "function_call_output", ... }, // Turn 1 tool result
{ role: "assistant", content: "Done!" }, // Turn 1 response
{ role: "user", content: "read it" }, // Turn 2 user
{ type: "function_call", name: "read", ... }, // Turn 2 tool call
{ type: "function_call_output", ... }, // Turn 2 tool result
{ role: "assistant", content: "Contents..." }, // Turn 2 response
{ role: "user", content: "what did you write?" } // Turn 3 user (current)
]
// Legacy mode strips IDs and item_reference; native mode preserves host payload shapeContext is maintained through:
- ✅ Full message history (LLM sees all previous messages)
- ✅ Full tool call history (LLM sees what it did)
- ✅
reasoning.encrypted_content(preserves reasoning between turns)
Source: Verified via ENABLE_PLUGIN_REQUEST_LOGGING=1 CODEX_PLUGIN_LOG_BODIES=1 logs
| Aspect | store:false (This Plugin) | store:true (Azure Only) |
|---|---|---|
| ChatGPT Support | ✅ Required | ❌ Rejected by API |
| Message History | ✅ Sent in each request (no IDs) | Stored on server |
| Message IDs | ❌ Must strip all | ✅ Required |
| AI SDK Compat | ✅ Native mode preserves host payload; legacy mode filters unsupported item_reference + IDs |
✅ Works natively |
| Context | Full history + encrypted reasoning | Server-stored conversation |
| Codex CLI Parity | ✅ Perfect match | ❌ Different mode |
Decision: Use store:false (only option for ChatGPT backend).
This section documents
requestTransformMode: "legacy"behavior. Native mode bypasses this rewrite path.
OpenCode/AI SDK sends two incompatible constructs:
// Multi-turn request from OpenCode
const body = {
input: [
{ type: "message", role: "developer", content: [...] },
{ type: "message", role: "user", content: [...], id: "msg_abc" },
{ type: "item_reference", id: "rs_xyz" }, // ← AI SDK construct
{ type: "function_call", id: "fc_123" }
]
};Two issues:
item_reference- AI SDK construct for server state lookup (not in Codex API spec)- Message IDs - Cause "item not found" with
store: false
ChatGPT Backend Requirement (confirmed via testing):
{"detail":"Store must be set to false"}Errors that occurred:
❌ "Item with id 'msg_abc' not found. Items are not persisted when `store` is set to false."
❌ "Missing required parameter: 'input[3].id'" (when item_reference has no ID)
Filter AI SDK Constructs + Strip IDs (lib/request/request-transformer.ts:114-135):
export function filterInput(input: InputItem[]): InputItem[] {
return input
.filter((item) => {
// Remove AI SDK constructs not supported by Codex API
if (item.type === "item_reference") {
return false; // AI SDK only - references server state
}
return true; // Keep all other items
})
.map((item) => {
// Strip IDs from all items (stateless mode)
if (item.id) {
const { id, ...itemWithoutId } = item;
return itemWithoutId as InputItem;
}
return item;
});
}Why this approach?
- ✅ Filter
item_reference- Not in Codex API, AI SDK-only construct - ✅ Keep all messages - LLM needs full conversation history for context
- ✅ Strip ALL IDs - Matches Codex CLI stateless behavior
- ✅ Future-proof - No ID pattern matching, handles any ID format
The plugin logs ID filtering for debugging:
// Before filtering
console.log(`[openai-codex-plugin] Filtering ${originalIds.length} message IDs from input:`, originalIds);
// After filtering
console.log(`[openai-codex-plugin] Successfully removed all ${originalIds.length} message IDs`);
// Or warning if IDs remain
console.warn(`[openai-codex-plugin] WARNING: ${remainingIds.length} IDs still present after filtering:`, remainingIds);Source: lib/request/request-transformer.ts:287-301
Challenge: How to maintain context across turns when store:false means no server-side storage?
Solution: Use reasoning.encrypted_content
body.include = modelConfig.include || ["reasoning.encrypted_content"];How it works:
- Turn 1: Model generates reasoning, encrypted content returned
- Client: Stores encrypted content locally
- Turn 2: Client sends encrypted content back in request
- Server: Decrypts content to restore reasoning context
- Model: Has full context without server-side storage
Flow Diagram:
Turn 1:
Client → [Request without IDs] → Server
Server → [Response + encrypted reasoning] → Client
Client stores encrypted content locally
Turn 2:
Client → [Request with encrypted content, no IDs] → Server
Server decrypts reasoning context
Server → [Response + new encrypted reasoning] → Client
Codex CLI equivalent (tmp/codex/codex-rs/core/src/client.rs:190-194):
let include: Vec<String> = if reasoning.is_some() {
vec!["reasoning.encrypted_content".to_string()]
} else {
vec![]
};Source: lib/request/request-transformer.ts:303
1. Parse OpenCode request body
- Preserve the original payload shape before any optional rewrites
2. Request transform mode gate
- native (default): keep host payload unchanged
- legacy: fetch Codex instructions and apply compatibility transforms
3. Legacy-mode transforms (when enabled)
- Normalize model aliases to canonical Codex IDs
- Filter unsupported AI SDK constructs (item_reference)
- Strip IDs for stateless compatibility (store: false)
- Apply bridge or tool-remap prompt logic (codexMode)
- Normalize orphaned tool outputs and inject missing outputs
4. Common post-processing
- Resolve reasoning + verbosity settings
- Ensure include contains reasoning.encrypted_content
- Force store: false and stream: true for ChatGPT backend
5. Header shaping
- Add OAuth/account headers
- Preserve host-provided prompt_cache_key session headers when present
Source: lib/request/fetch-helpers.ts:379-454, lib/request/request-transformer.ts:843-1015
| Feature | Codex CLI | This Plugin | Match? |
|---|---|---|---|
| OAuth Flow | ✅ PKCE + ChatGPT login | ✅ Same | ✅ |
| store Parameter | false (ChatGPT) |
false |
✅ |
| Message IDs | Stripped in stateless | Stripped | ✅ |
| reasoning.encrypted_content | ✅ Included | ✅ Included | ✅ |
| Model Normalization | "gpt-5" / "gpt-5-codex" / "codex-mini-latest" | Same | ✅ |
| Reasoning Effort | medium (default) | opinionated defaults by model family (for example GPT-5.3/5.2 Codex prefer xhigh) |
|
| Text Verbosity | model-dependent defaults | config-driven (default: medium) | ✅ |
| Feature | Codex CLI | This Plugin | Why? |
|---|---|---|---|
| Codex-OpenCode Bridge | N/A (native) | ✅ Legacy-mode prompt injection | OpenCode -> Codex behavioral translation when legacy mode is enabled |
| OpenCode Prompt Filtering | N/A | ✅ Legacy-mode prompt filtering | Removes OpenCode prompts and keeps env/AGENTS context in legacy mode |
| Orphan Tool Output Handling | ✅ Drop orphans | ✅ Convert to messages | Preserve context + avoid 400s |
| Usage-limit messaging | CLI prints status | ✅ Friendly error summary | Surface 5h/weekly windows in OpenCode |
| Per-Model Options | CLI flags | ✅ Config file | Better UX in OpenCode |
| Custom Model Names | No | ✅ Display names | UI convenience |
Pros of store:true:
- ✅ No ID filtering needed
- ✅ Server manages conversation
- ✅ Potentially more robust
Cons of store:true:
- ❌ Diverges from Codex CLI behavior
- ❌ Requires conversation ID management
- ❌ More complex error handling
- ❌ Unknown server-side storage limits
Decision: Use store:false for Codex parity and simplicity.
Alternative: Filter specific ID patterns (rs_*, msg_*, etc.)
Problem:
- ID patterns may change
- New ID types could be added
- Partial filtering is brittle
Solution: Remove ALL IDs
Rationale:
- Matches Codex CLI behavior exactly
- Future-proof against ID format changes
- Simpler implementation (no pattern matching)
- Clearer semantics (stateless = no IDs)
Problem: OpenCode's system prompts are optimized for OpenCode's tool set and behavior patterns.
Solution: Replace OpenCode prompts with Codex-specific instructions.
Benefits:
- ✅ Explains tool name differences (apply_patch intent → patch/edit)
- ✅ Documents available tools
- ✅ Maintains OpenCode working style
- ✅ Preserves Codex best practices
- ✅ 90% reduction in prompt tokens
Source: lib/prompts/codex-opencode-bridge.ts
Alternative: Single global config
Problem:
gpt-5-codexoptimal settings differ fromgpt-5.4orgpt-5.4-mini- Users want quick switching between quality levels
- No way to save "presets"
Solution: Per-model options in config
Benefits:
- ✅ Save multiple configurations
- ✅ Quick switching (no CLI args)
- ✅ Descriptive names ("Fast", "Balanced", "Max Quality")
- ✅ Persistent across sessions
Source: config/opencode-legacy.json (legacy) or config/opencode-modern.json (variants)
Cause: Message ID leaked through filtering
Fix: Improved filterInput() removes ALL IDs
Prevention: Debug logging catches remaining IDs
Cause: OAuth access token expired
Fix: shouldRefreshToken() checks expiration
Prevention: Auto-refresh before requests
Cause: Azure doesn't support stateless mode
Workaround: Codex CLI uses store: true for Azure only
This Plugin: Only supports ChatGPT OAuth (no Azure)
The plugin tracks account health and uses intelligent rotation:
Account Selection Flow:
1. Score = (health × 2) + (tokens × 5) + (freshness × 0.1)
2. Select account with highest score
3. Consume token from bucket
4. On success: health +1
5. On rate limit: health -10, mark rate-limited
6. On failure: health -20
7. Passive recovery: +2 health/hour
Client-side rate limiting prevents hitting API limits:
| Parameter | Value |
|---|---|
| Max tokens | 50 |
| Regeneration | 6 tokens/min |
| Consume per request | 1 token |
Different rate limit reasons use different backoff multipliers:
| Reason | Multiplier | Description |
|---|---|---|
quota |
3.0× | Daily quota exhausted |
tokens |
1.5× | Token limit hit |
concurrent |
0.5× | Concurrent request limit |
unknown |
1.0× | Default |
Prevents race conditions when multiple concurrent requests try to refresh the same token:
// Without RefreshQueue: N concurrent requests = N refresh attempts
// With RefreshQueue: N concurrent requests = 1 refresh, N-1 await
const queue = getRefreshQueue();
const tokens = await queue.queuedRefresh(refreshToken, async () => {
return await actualRefresh(refreshToken);
});Source: lib/refresh-queue.ts, lib/rotation.ts
The plugin now includes a beginner-focused operational layer in index.ts and lib/ui/beginner.ts:
-
Startup preflight summary
- Runs once per plugin loader lifecycle.
- Computes account readiness (
healthy,blocked,rate-limited) and surfaces a single next action. - Emits both toast + log summary.
-
Checklist and wizard flow
codex-setuprenders a checklist (add account,set active,verify health,label accounts,learn commands).codex-setup --wizardlaunches an interactive menu when terminal supports TTY interaction.- Wizard gracefully falls back to checklist output when menus are unavailable.
-
Doctor + next-action diagnostics
codex-doctormaps runtime/account states into severity findings (ok,warning,error) with specific action text.codex-doctor --fixperforms safe remediation:- refreshes tokens using queued refresh,
- persists refreshed credentials,
- switches active account to healthiest eligible account when beneficial.
codex-nextreturns exactly one recommended next action.
-
Interactive index selection
codex-switch,codex-label, andcodex-removeaccept optionalindex.- In interactive terminals, missing index opens a picker menu.
- In non-interactive contexts, commands return explicit usage guidance.
Storage schema now supports account metadata fields used by operational tooling:
accountLabel(existing)accountTags(new): normalized lowercase tag array for grouping/filteringaccountNote(new): short reminder text
Operational implications:
codex-listsupports tag filtering (tag) and shows tags in account labels.codex-tagandcodex-noteupdate metadata with persistence + manager cache reload.- Export/import flow hardening:
codex-exportcan auto-generate timestamped paths (createTimestampedBackupPath()),codex-importsupportsdryRunviapreviewImportAccounts(),- non-dry-run imports create timestamped pre-import backups before applying changes when existing accounts are present.
Codex Bridge Prompt: ~550 tokens (~90% reduction vs full OpenCode prompt) Benefit: Faster inference, lower costs
Prompt Caching: Uses promptCacheKey for session-based caching
Result: Reduced token usage on subsequent turns
Source: tmp/opencode/packages/opencode/src/provider/transform.ts:90-92
- Azure Support: Add
store: truemode with ID management - Version Detection: Adapt to OpenCode/AI SDK version changes
- Config Validation: Warn about unsupported options
- Test Coverage: Unit tests for all transformation functions
- Performance Metrics: Log token usage and latency
The plugin now supports a Codex-style terminal presentation layer for both interactive menus and text tool outputs.
- Default: enabled (
codexTuiV2: true) - Opt-out:
codexTuiV2: falseorCODEX_TUI_V2=0 - Color profile selection:
codexTuiColorProfile: "truecolor" | "ansi256" | "ansi16"CODEX_TUI_COLOR_PROFILE
- Glyph mode selection:
codexTuiGlyphMode: "ascii" | "unicode" | "auto"CODEX_TUI_GLYPHS
Legacy output remains unchanged when V2 is disabled.
- AI SDK Updates: Changes to
.responses()method - OpenCode Changes: New message ID formats
- Codex API Changes: New request parameters
- CONFIG_FLOW.md - Configuration system guide
- TUI_PARITY_CHECKLIST.md - Auth dashboard and interaction parity checklist
- Codex CLI Source - Official implementation
- OpenCode Source - OpenCode implementation