OpenCode Codex Auth Plugin

Access GPT-5 Codex through your ChatGPT Plus/Pro subscription

Download as .zip Download as .tar.gz View on GitHub

Plugin Architecture & Technical Decisions

This document explains the technical design decisions, architecture, and implementation details of the OpenAI Codex OAuth plugin for OpenCode.

Table of Contents


Architecture Overview

┌─────────────┐
│  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        │
└──────────────────────────────┘

Stateless vs Stateful Mode

Why store:false?

The plugin uses store: false (stateless mode) because:

  1. ChatGPT Backend Requirement (confirmed via testing):
    // Attempt with store:true  400 Bad Request
    {"detail":"Store must be set to false"}
    
  2. 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:

  1. ChatGPT backend REQUIRES store:false (not optional)
  2. Codex CLI uses store:false for ChatGPT
  3. Azure requires store:true (different endpoint, not supported by this plugin)
  4. Stateless mode = no server-side conversation storage

How Context Works with store:false

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)
]
// All IDs stripped, item_reference filtered out

Context is maintained through:

Source: Verified via ENABLE_PLUGIN_REQUEST_LOGGING=1 logs

Store Comparison

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 ❌ Must filter item_reference ✅ 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).


Message ID Handling & AI SDK Compatibility

The Problem

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:

  1. item_reference - AI SDK construct for server state lookup (not in Codex API spec)
  2. 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)

The Solution

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?

  1. Filter item_reference - Not in Codex API, AI SDK-only construct
  2. Keep all messages - LLM needs full conversation history for context
  3. Strip ALL IDs - Matches Codex CLI stateless behavior
  4. Future-proof - No ID pattern matching, handles any ID format

Debug Logging

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


Reasoning Content Flow

Context Preservation Without Storage

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:

  1. Turn 1: Model generates reasoning, encrypted content returned
  2. Client: Stores encrypted content locally
  3. Turn 2: Client sends encrypted content back in request
  4. Server: Decrypts content to restore reasoning context
  5. 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


Request Pipeline

Transformation Steps

1. Original OpenCode Request
   ├─ model: "gpt-5-codex"
   ├─ input: [{ id: "msg_123", ... }, { id: "rs_456", ... }]
   └─ tools: [...]

2. Model Normalization
   ├─ Detect codex/gpt-5 variants
   └─ Normalize to "gpt-5" or "gpt-5-codex"

3. Config Merging
   ├─ Global options (provider.openai.options)
   ├─ Model-specific options (provider.openai.models[name].options)
   └─ Result: merged config for this model

4. Message ID Filtering
   ├─ Remove ALL IDs from input array
   ├─ Log original IDs for debugging
   └─ Verify no IDs remain

5. System Prompt Handling (CODEX_MODE)
   ├─ Filter out OpenCode system prompts
   └─ Add Codex-OpenCode bridge prompt

6. Reasoning Configuration
   ├─ Set reasoningEffort (minimal/low/medium/high)
   ├─ Set reasoningSummary (auto/detailed)
   └─ Based on model variant

7. Final Body
   ├─ store: false
   ├─ stream: true
   ├─ instructions: Codex system prompt
   ├─ input: Filtered messages (no IDs)
   ├─ reasoning: { effort, summary }
   ├─ text: { verbosity }
   └─ include: ["reasoning.encrypted_content"]

Source: lib/request/request-transformer.ts:265-329


Comparison with Codex CLI

What We Match

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” Same
Reasoning Effort medium (default) medium (default)
Text Verbosity medium (codex), low (gpt-5) Same

What We Add

Feature Codex CLI This Plugin Why?
Codex-OpenCode Bridge N/A (native) ✅ Custom prompt OpenCode → Codex translation
OpenCode Prompt Filtering N/A ✅ Filter & replace Remove OpenCode-specific prompts
Per-Model Options CLI flags ✅ Config file Better UX in OpenCode
Custom Model Names No ✅ Display names UI convenience

Design Rationale

Why Not store:true?

Pros of store:true:

Cons of store:true:

Decision: Use store:false for Codex parity and simplicity.

Why Complete ID Removal?

Alternative: Filter specific ID patterns (rs_*, msg_*, etc.)

Problem:

Solution: Remove ALL IDs

Rationale:

Why Codex-OpenCode Bridge?

Problem: OpenCode’s system prompts are optimized for OpenCode’s tool set and behavior patterns.

Solution: Replace OpenCode prompts with Codex-specific instructions.

Benefits:

Source: lib/prompts/codex-opencode-bridge.ts

Why Per-Model Config Options?

Alternative: Single global config

Problem:

Solution: Per-model options in config

Benefits:

Source: config/full-opencode.json


Error Handling

Common Errors

1. “Item with id ‘X’ not found”

Cause: Message ID leaked through filtering Fix: Improved filterInput() removes ALL IDs Prevention: Debug logging catches remaining IDs

2. Token Expiration

Cause: OAuth access token expired Fix: shouldRefreshToken() checks expiration Prevention: Auto-refresh before requests

3. “store: false” Validation Error (Azure)

Cause: Azure doesn’t support stateless mode Workaround: Codex CLI uses store: true for Azure only This Plugin: Only supports ChatGPT OAuth (no Azure)


Performance Considerations

Token Usage

Codex Bridge Prompt: ~550 tokens (~90% reduction vs full OpenCode prompt) Benefit: Faster inference, lower costs

Request Optimization

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


Future Improvements

Potential Enhancements

  1. Azure Support: Add store: true mode with ID management
  2. Version Detection: Adapt to OpenCode/AI SDK version changes
  3. Config Validation: Warn about unsupported options
  4. Test Coverage: Unit tests for all transformation functions
  5. Performance Metrics: Log token usage and latency

Breaking Changes to Watch

  1. AI SDK Updates: Changes to .responses() method
  2. OpenCode Changes: New message ID formats
  3. Codex API Changes: New request parameters

See Also