Files
2025-11-11 20:49:43 +00:00

462 lines
15 KiB
Markdown

# 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](#architecture-overview)
- [Stateless vs Stateful Mode](#stateless-vs-stateful-mode)
- [Message ID Handling](#message-id-handling)
- [Reasoning Content Flow](#reasoning-content-flow)
- [Request Pipeline](#request-pipeline)
- [Comparison with Codex CLI](#comparison-with-codex-cli)
- [Design Rationale](#design-rationale)
---
## 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):
```json
// 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`):
```rust
// 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:
```typescript
// 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**:
- ✅ 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` 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**:
```typescript
// 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):
```json
{"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`):
```typescript
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:
```typescript
// 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`
```typescript
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`):
```rust
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/codex-mini variants
└─ Normalize to "gpt-5", "gpt-5-codex", or "codex-mini-latest"
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. Prompt Caching & Session Headers
├─ Preserve host-supplied prompt_cache_key (OpenCode session id)
├─ Add conversation + account headers for Codex debugging when cache key exists
└─ Leave headers unset if host does not provide a cache key
8. Final Body
├─ store: false
├─ stream: true
├─ instructions: Codex system prompt
├─ input: Filtered messages (no IDs)
├─ reasoning: { effort, summary }
├─ text: { verbosity }
├─ include: ["reasoning.encrypted_content"]
└─ prompt_cache_key: conversation-scoped UUID
```
**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" / "codex-mini-latest" | 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 |
| **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 |
---
## Design Rationale
### Why Not store:true?
**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.
### Why Complete ID Removal?
**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)
### 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**:
- ✅ Explains tool name differences (apply_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`
### Why Per-Model Config Options?
**Alternative**: Single global config
**Problem**:
- `gpt-5-codex` optimal settings differ from `gpt-5-nano`
- 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/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
- [CONFIG_FLOW.md](./CONFIG_FLOW.md) - Configuration system guide
- [Codex CLI Source](https://github.com/openai/codex) - Official implementation
- [OpenCode Source](https://github.com/sst/opencode) - OpenCode implementation