mirror of
https://github.com/hanzoai/opencode-openai-codex-auth.git
synced 2026-09-24 17:01:02 +00:00
feat: Add Task tool & MCP awareness with ETag-based prompt verification
This release enhances the bridge prompt system with awareness of OpenCode's
advanced capabilities and implements cache-based verification for more robust
prompt filtering.
**New Features:**
- Task tool awareness: Bridge prompt now documents sub-agent system
- MCP tool awareness: Documents Model Context Protocol tool conventions
- OpenCode prompt verification: ETag-based caching of codex.txt for 100% accurate filtering
- AGENTS.md: Renamed from CLAUDE.md for broader AI agent applicability
**Technical Changes:**
- Created lib/prompts/opencode-codex.ts for prompt verification caching
- Made request transformation pipeline async to support cache fetching
- Enhanced isOpenCodeSystemPrompt() with dual verification (cached + fallback)
- Updated bridge prompt with "Advanced Tools" section (~550 tokens, was ~450)
- Added 6 new tests verifying AGENTS.md content safety (129 total tests)
**Verification System:**
Primary: Compare against cached OpenCode prompt from GitHub
Fallback: Text signature matching ("You are a coding agent running in")
This ensures 100% confidence when filtering OpenCode system prompts
**Breaking Changes:** None - all changes are additive
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+3
-1
@@ -3,4 +3,6 @@ bun.lockb
|
||||
pnpm-lock.yaml
|
||||
dist/
|
||||
.DS_Store
|
||||
.history/
|
||||
.history/
|
||||
opencode.json
|
||||
.opencode/
|
||||
@@ -1,567 +1,192 @@
|
||||
# AI Agent Maintenance Guide
|
||||
# AGENTS.md
|
||||
|
||||
This guide is specifically written for AI coding assistants (Claude, GPT-4, etc.) maintaining the OpenAI Codex OAuth plugin. It provides quick reference for common tasks and patterns to follow.
|
||||
This file provides coding guidance for AI agents (including Claude Code, Codex, and others) when working with code in this repository.
|
||||
|
||||
## 🎯 Core Philosophy
|
||||
## Overview
|
||||
|
||||
This codebase follows these principles:
|
||||
1. **Single Responsibility** - Each function does one thing well
|
||||
2. **Centralized Configuration** - All magic values in `lib/constants.ts`
|
||||
3. **Type Safety First** - Strict TypeScript throughout
|
||||
4. **Test Everything** - 93 tests covering all functionality
|
||||
5. **Clear Naming** - Function names describe exactly what they do
|
||||
6. **Comprehensive JSDoc** - Every public function documented
|
||||
This is an **opencode plugin** that enables OAuth authentication with OpenAI's ChatGPT Plus/Pro Codex backend. It allows users to access `gpt-5-codex` and `gpt-5` models through their ChatGPT subscription instead of using OpenAI Platform API credits.
|
||||
|
||||
## 📁 Codebase Structure
|
||||
**Key architecture principle**: 7-step fetch flow that intercepts opencode's OpenAI SDK requests, transforms them for the ChatGPT backend API, and handles OAuth token management.
|
||||
|
||||
### Quick Navigation
|
||||
|
||||
```
|
||||
index.ts → Main plugin entry (213 lines)
|
||||
├── exports OpenAIAuthPlugin
|
||||
├── contains fetch() implementation (42 lines, 7 steps)
|
||||
└── contains authorize() OAuth flow
|
||||
|
||||
lib/
|
||||
├── constants.ts → ALL magic values, URLs, error messages
|
||||
├── types.ts → TypeScript type definitions
|
||||
├── fetch-helpers.ts → 10 focused helper functions
|
||||
├── auth.ts → OAuth/PKCE authentication
|
||||
├── request-transformer.ts → Request body transformation
|
||||
├── response-handler.ts → SSE to JSON conversion
|
||||
├── browser.ts → Platform-specific browser opening
|
||||
├── codex.ts → Codex instructions fetching
|
||||
├── logger.ts → Debug logging utilities
|
||||
└── server.ts → Local OAuth callback server
|
||||
|
||||
test/
|
||||
├── fetch-helpers.test.ts → 15 tests for helper functions
|
||||
├── browser.test.ts → 4 tests for platform detection
|
||||
├── auth.test.ts → 16 tests for OAuth flow
|
||||
├── request-transformer.test.ts → 30 tests for transformations
|
||||
├── response-handler.test.ts → 10 tests for SSE conversion
|
||||
├── config.test.ts → 13 tests for configuration
|
||||
└── logger.test.ts → 5 tests for logging
|
||||
|
||||
config/
|
||||
├── full-opencode.json → Complete configuration example
|
||||
├── minimal-opencode.json → Minimal configuration example
|
||||
└── README.md → Configuration documentation
|
||||
```
|
||||
|
||||
## 🔧 Common Modification Scenarios
|
||||
|
||||
### 1. Adding a New Header
|
||||
|
||||
**Location**: `lib/fetch-helpers.ts` → `createCodexHeaders()`
|
||||
|
||||
```typescript
|
||||
export function createCodexHeaders(
|
||||
init: RequestInit | undefined,
|
||||
accountId: string,
|
||||
accessToken: string
|
||||
): Headers {
|
||||
const headers = new Headers(init?.headers ?? {});
|
||||
|
||||
// Add your new header here
|
||||
headers.set("X-New-Header", "value");
|
||||
|
||||
return headers;
|
||||
}
|
||||
```
|
||||
|
||||
**Test Location**: `test/fetch-helpers.test.ts`
|
||||
|
||||
```typescript
|
||||
it('should include new header', () => {
|
||||
const headers = createCodexHeaders(undefined, accountId, accessToken);
|
||||
expect(headers.get('X-New-Header')).toBe('value');
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Adding a New Constant
|
||||
|
||||
**Location**: `lib/constants.ts`
|
||||
|
||||
```typescript
|
||||
// Add to appropriate category
|
||||
export const NEW_CONSTANT_GROUP = {
|
||||
KEY1: "value1",
|
||||
KEY2: "value2",
|
||||
} as const;
|
||||
```
|
||||
|
||||
**Pattern**: Always use `as const` for type safety and group related constants together.
|
||||
|
||||
### 3. Modifying URL Rewriting Logic
|
||||
|
||||
**Location**: `lib/fetch-helpers.ts` → `rewriteUrlForCodex()`
|
||||
|
||||
```typescript
|
||||
export function rewriteUrlForCodex(url: string): string {
|
||||
// Add new URL transformations here
|
||||
return url
|
||||
.replace(URL_PATHS.RESPONSES, URL_PATHS.CODEX_RESPONSES)
|
||||
.replace(URL_PATHS.NEW_PATH, URL_PATHS.NEW_CODEX_PATH); // New transformation
|
||||
}
|
||||
```
|
||||
|
||||
**Test Location**: `test/fetch-helpers.test.ts`
|
||||
|
||||
```typescript
|
||||
it('should rewrite new path correctly', () => {
|
||||
const url = 'https://example.com/new-path';
|
||||
expect(rewriteUrlForCodex(url)).toBe('https://example.com/codex/new-path');
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Adding Request Transformation Logic
|
||||
|
||||
**Location**: `lib/request-transformer.ts` → `transformRequestBody()`
|
||||
|
||||
This function already handles:
|
||||
- Reasoning effort configuration
|
||||
- Reasoning summary configuration
|
||||
- Text verbosity configuration
|
||||
- System message injection
|
||||
|
||||
**Pattern**: Follow existing configuration pattern in `transformRequestBody()`.
|
||||
|
||||
### 5. Modifying Token Refresh Logic
|
||||
|
||||
**Location**: `lib/fetch-helpers.ts` → `shouldRefreshToken()` and `refreshAndUpdateToken()`
|
||||
|
||||
```typescript
|
||||
// Check condition
|
||||
export function shouldRefreshToken(auth: Auth): boolean {
|
||||
return auth.type !== "oauth"
|
||||
|| !auth.access
|
||||
|| auth.expires < Date.now()
|
||||
|| yourNewCondition; // Add here
|
||||
}
|
||||
|
||||
// Refresh implementation
|
||||
export async function refreshAndUpdateToken(
|
||||
currentAuth: Auth,
|
||||
client: OpencodeClient
|
||||
): Promise<{ success: true; auth: Auth } | { success: false; response: Response }> {
|
||||
// Modification goes here
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Adding a New Platform for Browser Opening
|
||||
|
||||
**Location**: `lib/browser.ts` → `getBrowserOpener()`
|
||||
|
||||
**Step 1**: Add constant to `lib/constants.ts`
|
||||
```typescript
|
||||
export const PLATFORM_OPENERS = {
|
||||
darwin: "open",
|
||||
win32: "start",
|
||||
linux: "xdg-open",
|
||||
freebsd: "xdg-open", // Add new platform
|
||||
} as const;
|
||||
```
|
||||
|
||||
**Step 2**: Update `getBrowserOpener()`
|
||||
```typescript
|
||||
export function getBrowserOpener(): string {
|
||||
const platform = process.platform;
|
||||
if (platform === "darwin") return PLATFORM_OPENERS.darwin;
|
||||
if (platform === "win32") return PLATFORM_OPENERS.win32;
|
||||
if (platform === "freebsd") return PLATFORM_OPENERS.freebsd; // Add here
|
||||
return PLATFORM_OPENERS.linux;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3**: Add test in `test/browser.test.ts`
|
||||
```typescript
|
||||
it('should handle freebsd platform', () => {
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, 'platform', { value: 'freebsd' });
|
||||
expect(getBrowserOpener()).toBe(PLATFORM_OPENERS.freebsd);
|
||||
Object.defineProperty(process, 'platform', { value: originalPlatform });
|
||||
});
|
||||
```
|
||||
|
||||
### 7. Adding Error Messages
|
||||
|
||||
**Location**: `lib/constants.ts` → `ERROR_MESSAGES`
|
||||
|
||||
```typescript
|
||||
export const ERROR_MESSAGES = {
|
||||
NO_ACCOUNT_ID: "Failed to extract accountId from token",
|
||||
TOKEN_REFRESH_FAILED: "Failed to refresh token, authentication required",
|
||||
NEW_ERROR: "Your new error message", // Add here
|
||||
} as const;
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
```typescript
|
||||
console.error(`[${PLUGIN_NAME}] ${ERROR_MESSAGES.NEW_ERROR}`);
|
||||
```
|
||||
|
||||
### 8. Adding Configuration Options
|
||||
|
||||
**Location**: `lib/types.ts` → `ConfigOptions`
|
||||
|
||||
```typescript
|
||||
export interface ConfigOptions {
|
||||
reasoningEffort?: "minimal" | "low" | "medium" | "high";
|
||||
reasoningSummary?: "auto" | "concise" | "detailed";
|
||||
textVerbosity?: "low" | "medium" | "high";
|
||||
newOption?: "value1" | "value2"; // Add here
|
||||
include?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
**Then update**: `lib/request-transformer.ts` to handle the new option.
|
||||
|
||||
## 🧪 Testing Requirements
|
||||
|
||||
### When to Add Tests
|
||||
|
||||
**ALWAYS add tests when**:
|
||||
- Adding new functions
|
||||
- Modifying existing logic
|
||||
- Adding new constants (if they affect behavior)
|
||||
- Adding new configuration options
|
||||
- Changing URL rewriting
|
||||
- Modifying header creation
|
||||
- Updating token refresh logic
|
||||
|
||||
### Test Structure Pattern
|
||||
|
||||
```typescript
|
||||
describe('Module Name', () => {
|
||||
describe('functionName', () => {
|
||||
it('should handle normal case', () => {
|
||||
// Arrange
|
||||
const input = createTestInput();
|
||||
|
||||
// Act
|
||||
const result = functionName(input);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(expectedValue);
|
||||
});
|
||||
|
||||
it('should handle edge case', () => {
|
||||
// Test edge case
|
||||
});
|
||||
|
||||
it('should handle error case', () => {
|
||||
// Test error handling
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
npm test # Run all tests
|
||||
npm test -- --watch # Watch mode
|
||||
npm test -- --ui # Visual UI
|
||||
npm test -- --coverage # Coverage report
|
||||
# Build (compiles TypeScript + copies HTML file)
|
||||
npm run build
|
||||
|
||||
# Type checking only (no build)
|
||||
npm run typecheck
|
||||
|
||||
# Run all tests
|
||||
npm test
|
||||
|
||||
# Watch mode for TDD
|
||||
npm run test:watch
|
||||
|
||||
# Interactive test UI
|
||||
npm run test:ui
|
||||
|
||||
# Coverage report
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
**Requirement**: All 93 tests must pass before committing.
|
||||
**Important**: The build script has a critical step that copies `lib/oauth-success.html` to `dist/lib/`. This HTML file is required for the OAuth callback flow.
|
||||
|
||||
## 📝 Code Patterns to Follow
|
||||
## Code Architecture
|
||||
|
||||
### 1. Function Naming
|
||||
### Plugin Flow (index.ts)
|
||||
|
||||
✅ **Good** - Verb + Noun, describes what it does:
|
||||
```typescript
|
||||
shouldRefreshToken()
|
||||
createCodexHeaders()
|
||||
extractRequestUrl()
|
||||
rewriteUrlForCodex()
|
||||
handleErrorResponse()
|
||||
```
|
||||
The main entry point orchestrates a **7-step fetch flow**:
|
||||
|
||||
❌ **Bad** - Vague or unclear:
|
||||
```typescript
|
||||
checkToken() // Check what about token?
|
||||
makeHeaders() // Make what kind?
|
||||
getUrl() // Get from where?
|
||||
```
|
||||
1. **Token Management**: Check token expiration, refresh if needed
|
||||
2. **URL Rewriting**: Transform OpenAI Platform API URLs → ChatGPT backend API (`https://chatgpt.com/backend-api/codex/responses`)
|
||||
3. **Request Transformation**:
|
||||
- Normalize model names (all variants → `gpt-5` or `gpt-5-codex`)
|
||||
- Inject Codex system instructions from latest GitHub release
|
||||
- Apply reasoning configuration (effort, summary, verbosity)
|
||||
- Add CODEX_MODE bridge prompt (default) or tool remap message (legacy)
|
||||
- Filter OpenCode system prompts when in CODEX_MODE
|
||||
- Filter conversation history (remove `rs_*` IDs for stateless operation)
|
||||
4. **Headers**: Add OAuth token + ChatGPT account ID
|
||||
5. **Request Execution**: Send to Codex backend
|
||||
6. **Response Logging**: Optional debug logging (ENABLE_PLUGIN_REQUEST_LOGGING=1)
|
||||
7. **Response Handling**: Convert SSE to JSON (non-tool requests) or pass through
|
||||
|
||||
### 2. JSDoc Comments
|
||||
### Module Organization
|
||||
|
||||
**Always include** for public functions:
|
||||
```typescript
|
||||
/**
|
||||
* Brief description of what function does
|
||||
*
|
||||
* @param paramName - Description of parameter
|
||||
* @param anotherParam - Description of another parameter
|
||||
* @returns Description of return value
|
||||
*
|
||||
* @example
|
||||
* const result = functionName(input);
|
||||
*/
|
||||
export function functionName(paramName: Type, anotherParam: Type): ReturnType {
|
||||
// implementation
|
||||
}
|
||||
```
|
||||
**Core Plugin** (`index.ts`)
|
||||
- Plugin definition and main fetch orchestration
|
||||
- OAuth loader (extracts ChatGPT account ID from JWT)
|
||||
- Configuration loading and CODEX_MODE determination
|
||||
|
||||
### 3. Error Handling
|
||||
**Authentication** (`lib/auth/`)
|
||||
- `auth.ts`: OAuth flow (PKCE, token exchange, JWT decoding, refresh)
|
||||
- `server.ts`: Local HTTP server for OAuth callback (port 1455)
|
||||
- `browser.ts`: Platform-specific browser opening
|
||||
|
||||
**Pattern**: Return result objects instead of throwing
|
||||
```typescript
|
||||
// ✅ Good - Discriminated union return type
|
||||
type Result =
|
||||
| { success: true; data: Data }
|
||||
| { success: false; error: Error };
|
||||
**Request Handling** (`lib/request/`)
|
||||
- `fetch-helpers.ts`: 10 focused helper functions for main fetch flow
|
||||
- `request-transformer.ts`: Body transformations (model normalization, reasoning config, input filtering)
|
||||
- `response-handler.ts`: SSE to JSON conversion
|
||||
|
||||
export function doSomething(): Result {
|
||||
if (errorCondition) {
|
||||
return { success: false, error: new Error("message") };
|
||||
}
|
||||
return { success: true, data: result };
|
||||
}
|
||||
**Prompts** (`lib/prompts/`)
|
||||
- `codex.ts`: Fetches Codex instructions from GitHub (ETag-cached), tool remap message
|
||||
- `codex-opencode-bridge.ts`: CODEX_MODE bridge prompt for CLI parity
|
||||
|
||||
// Usage
|
||||
const result = doSomething();
|
||||
if (!result.success) {
|
||||
return result.error;
|
||||
}
|
||||
// Use result.data
|
||||
```
|
||||
**Configuration** (`lib/`)
|
||||
- `config.ts`: Plugin config loading, CODEX_MODE determination
|
||||
- `constants.ts`: All magic values, URLs, error messages
|
||||
- `types.ts`: TypeScript type definitions
|
||||
- `logger.ts`: Debug logging (controlled by env var)
|
||||
|
||||
### 4. Logging
|
||||
### Key Design Patterns
|
||||
|
||||
**Pattern**: Use constants and plugin name prefix
|
||||
```typescript
|
||||
import { PLUGIN_NAME, ERROR_MESSAGES, LOG_STAGES } from "./constants.js";
|
||||
**1. Stateless Operation**: Uses `store: false` + `include: ["reasoning.encrypted_content"]`
|
||||
- Allows multi-turn conversations without server-side storage
|
||||
- Encrypted reasoning content persists context across turns
|
||||
|
||||
// ✅ Good
|
||||
console.error(`[${PLUGIN_NAME}] ${ERROR_MESSAGES.TOKEN_REFRESH_FAILED}`);
|
||||
logRequest(LOG_STAGES.RESPONSE, data);
|
||||
**2. CODEX_MODE** (enabled by default):
|
||||
- **Priority**: `CODEX_MODE` env var > `~/.opencode/openai-codex-auth-config.json` > default (true)
|
||||
- When enabled: Filters out OpenCode system prompts, adds Codex-OpenCode bridge prompt with Task tool & MCP awareness
|
||||
- When disabled: Uses legacy tool remap message
|
||||
- Bridge prompt (~550 tokens): Tool mappings, available tools, working style, **Task tool/sub-agent awareness**, **MCP tool awareness**
|
||||
- **Prompt verification**: Caches OpenCode's codex.txt from GitHub (ETag-based) to verify exact prompt removal, with fallback to text signature matching
|
||||
|
||||
// ❌ Bad
|
||||
console.error("Failed to refresh token");
|
||||
console.log("Response:", data);
|
||||
```
|
||||
**3. Configuration Merging**:
|
||||
- Global options (`provider.openai.options`) + per-model options (`provider.openai.models[name].options`)
|
||||
- Model-specific options override global
|
||||
- Plugin defaults: `reasoningEffort: "medium"`, `reasoningSummary: "auto"`, `textVerbosity: "medium"`
|
||||
|
||||
### 5. Type Definitions
|
||||
**4. Model Normalization**:
|
||||
- All `gpt-5-codex` variants → `gpt-5-codex`
|
||||
- All `gpt-5` variants → `gpt-5`
|
||||
- `minimal` effort auto-normalized to `low` for gpt-5-codex (API limitation)
|
||||
|
||||
**Pattern**: Import types with `type` keyword
|
||||
```typescript
|
||||
// ✅ Good
|
||||
import type { Auth, OpencodeClient } from "@opencode-ai/sdk";
|
||||
import type { Plugin, PluginInput } from "@opencode-ai/plugin";
|
||||
import type { UserConfig, ConfigOptions } from "./types.js";
|
||||
**5. Codex Instructions Caching**:
|
||||
- Fetches from latest release tag (not main branch)
|
||||
- ETag-based HTTP conditional requests
|
||||
- Cache invalidation when release tag changes
|
||||
- Falls back to bundled version if GitHub unavailable
|
||||
|
||||
// ❌ Bad
|
||||
import { Auth } from "@opencode-ai/sdk"; // Runtime import when only type is needed
|
||||
```
|
||||
## Development Patterns
|
||||
|
||||
### 6. Constants
|
||||
### Adding New Configuration Options
|
||||
|
||||
**Pattern**: Group related constants, use `as const`
|
||||
```typescript
|
||||
// ✅ Good
|
||||
export const OPENAI_HEADERS = {
|
||||
ACCOUNT_ID: "ChatGPT-Account-ID",
|
||||
BETA: "OpenAI-Beta",
|
||||
SESSION_ID: "OpenAI-Sentinel-Chat-Session-Id",
|
||||
} as const;
|
||||
|
||||
// ❌ Bad
|
||||
export const HEADER_ACCOUNT_ID = "ChatGPT-Account-ID";
|
||||
export const HEADER_BETA = "OpenAI-Beta";
|
||||
export const HEADER_SESSION_ID = "OpenAI-Sentinel-Chat-Session-Id";
|
||||
```
|
||||
|
||||
## 🎯 Main Fetch Flow (Critical Understanding)
|
||||
|
||||
The `fetch()` function in `index.ts` is the heart of the plugin. It follows a strict 7-step flow:
|
||||
|
||||
```typescript
|
||||
async fetch(input: Request | string | URL, init?: RequestInit): Promise<Response> {
|
||||
// Step 1: Check and refresh token if needed
|
||||
const currentAuth = await getAuth();
|
||||
if (shouldRefreshToken(currentAuth)) {
|
||||
const refreshResult = await refreshAndUpdateToken(currentAuth, client);
|
||||
if (!refreshResult.success) return refreshResult.response;
|
||||
}
|
||||
|
||||
// Step 2: Extract and rewrite URL for Codex backend
|
||||
const originalUrl = extractRequestUrl(input);
|
||||
const url = rewriteUrlForCodex(originalUrl);
|
||||
|
||||
// Step 3: Transform request body with Codex instructions
|
||||
const transformation = transformRequestForCodex(init, url, CODEX_INSTRUCTIONS, userConfig);
|
||||
const hasTools = transformation?.body.tools !== undefined;
|
||||
const requestInit = transformation?.updatedInit ?? init;
|
||||
|
||||
// Step 4: Create headers with OAuth and ChatGPT account info
|
||||
const accessToken = currentAuth.type === "oauth" ? currentAuth.access : "";
|
||||
const headers = createCodexHeaders(requestInit, accountId, accessToken);
|
||||
|
||||
// Step 5: Make request to Codex API
|
||||
const response = await fetch(url, { ...requestInit, headers });
|
||||
|
||||
// Step 6: Log response
|
||||
logRequest(LOG_STAGES.RESPONSE, { status, ok, statusText, headers });
|
||||
|
||||
// Step 7: Handle error or success response
|
||||
if (!response.ok) return await handleErrorResponse(response);
|
||||
return await handleSuccessResponse(response, hasTools);
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: Each step is isolated in its own helper function for maintainability.
|
||||
|
||||
## 🔍 Where to Look for Specific Functionality
|
||||
|
||||
| Need to modify... | Go to... |
|
||||
|-------------------|----------|
|
||||
| URL rewriting | `lib/fetch-helpers.ts` → `rewriteUrlForCodex()` |
|
||||
| Headers | `lib/fetch-helpers.ts` → `createCodexHeaders()` |
|
||||
| Token refresh | `lib/fetch-helpers.ts` → `refreshAndUpdateToken()` |
|
||||
| Token validation | `lib/fetch-helpers.ts` → `shouldRefreshToken()` |
|
||||
| Request body transformation | `lib/request-transformer.ts` → `transformRequestBody()` |
|
||||
| SSE to JSON conversion | `lib/response-handler.ts` → `convertSseToJson()` |
|
||||
| Error handling | `lib/fetch-helpers.ts` → `handleErrorResponse()` |
|
||||
| OAuth flow | `lib/auth.ts` → `createAuthorizationFlow()` |
|
||||
| Browser opening | `lib/browser.ts` → `openBrowserUrl()` |
|
||||
| Codex instructions | `lib/codex.ts` → `getCodexInstructions()` |
|
||||
| Any constant/magic value | `lib/constants.ts` |
|
||||
| Type definitions | `lib/types.ts` |
|
||||
|
||||
## 🚀 Quick Start for Common Tasks
|
||||
|
||||
### Task: Fix a Bug in Token Refresh
|
||||
|
||||
1. Read `lib/fetch-helpers.ts` → `shouldRefreshToken()` and `refreshAndUpdateToken()`
|
||||
2. Read existing tests in `test/fetch-helpers.test.ts`
|
||||
3. Add test case reproducing the bug
|
||||
4. Fix the bug in the helper function
|
||||
5. Verify all 93 tests pass: `npm test`
|
||||
6. Run build: `npm run build`
|
||||
|
||||
### Task: Add Support for New Model Configuration
|
||||
|
||||
1. Update `lib/types.ts` → `ConfigOptions` interface
|
||||
2. Update `lib/request-transformer.ts` → `transformRequestBody()`
|
||||
1. Add to `ConfigOptions` interface in `lib/types.ts`
|
||||
2. Update `transformRequestBody()` in `lib/request/request-transformer.ts`
|
||||
3. Add tests in `test/request-transformer.test.ts`
|
||||
4. Update `config/full-opencode.json` with example
|
||||
5. Update `config/README.md` with documentation
|
||||
6. Verify all tests pass
|
||||
4. Document in README.md configuration section
|
||||
|
||||
### Task: Change How URLs are Rewritten
|
||||
### Modifying Request Transformation
|
||||
|
||||
1. Update `lib/fetch-helpers.ts` → `rewriteUrlForCodex()`
|
||||
2. Update constants in `lib/constants.ts` if needed
|
||||
3. Add tests in `test/fetch-helpers.test.ts`
|
||||
4. Verify all tests pass
|
||||
All request transformations go through `transformRequestBody()`:
|
||||
- Input filtering: `filterInput()`, `filterOpenCodeSystemPrompts()`
|
||||
- Message injection: `addCodexBridgeMessage()` or `addToolRemapMessage()`
|
||||
- Reasoning config: `getReasoningConfig()` (follows Codex CLI defaults, not opencode defaults)
|
||||
- Model config: `getModelConfig()` (merges global + per-model options)
|
||||
|
||||
### Task: Improve Error Messages
|
||||
### OAuth Flow Modifications
|
||||
|
||||
1. Update `lib/constants.ts` → `ERROR_MESSAGES`
|
||||
2. Update usage in relevant files (search for old message)
|
||||
3. Update tests that check error messages
|
||||
4. Verify all tests pass
|
||||
OAuth implementation follows OpenAI Codex CLI patterns:
|
||||
- Client ID: `app_EMoamEEZ73f0CkXaXp7hrann`
|
||||
- PKCE with S256 challenge
|
||||
- Special params: `codex_cli_simplified_flow=true`, `originator=codex_cli_rs`
|
||||
- Callback server on port 1455 (matches Codex CLI)
|
||||
|
||||
## ⚠️ Critical Rules
|
||||
### Testing Strategy
|
||||
|
||||
1. **NEVER modify code without reading relevant tests first**
|
||||
2. **ALWAYS run tests before and after changes**
|
||||
3. **NEVER use magic strings** - add to `lib/constants.ts`
|
||||
4. **ALWAYS add JSDoc** to new public functions
|
||||
5. **NEVER create functions > 70 lines** - extract helpers
|
||||
6. **ALWAYS use discriminated unions** for result types
|
||||
7. **NEVER throw exceptions** - return error objects
|
||||
8. **ALWAYS import types with `type` keyword** when only type is needed
|
||||
9. **NEVER commit with failing tests**
|
||||
10. **ALWAYS check TypeScript compilation**: `npm run build`
|
||||
- **123 comprehensive tests** covering all modules
|
||||
- Test files mirror source structure (`test/auth.test.ts` ↔ `lib/auth/auth.ts`)
|
||||
- Mock-heavy testing (no actual network calls or file I/O in tests)
|
||||
- Focus on edge cases: token expiration, model normalization, input filtering, CODEX_MODE toggling
|
||||
|
||||
## 📊 Quality Checklist
|
||||
## Important Configuration Differences
|
||||
|
||||
Before completing any task:
|
||||
This plugin **intentionally differs from opencode defaults** because it accesses ChatGPT backend API (not OpenAI Platform API):
|
||||
|
||||
- [ ] All 93 tests pass (`npm test`)
|
||||
- [ ] TypeScript compiles without errors (`npm run build`)
|
||||
- [ ] New functions have JSDoc comments
|
||||
- [ ] New functionality has tests
|
||||
- [ ] No magic strings (all in `constants.ts`)
|
||||
- [ ] Functions are < 70 lines
|
||||
- [ ] Clear, descriptive naming
|
||||
- [ ] Types imported with `type` keyword where applicable
|
||||
- [ ] No `any` types used
|
||||
- [ ] Error handling follows result pattern
|
||||
| Setting | opencode Default | This Plugin Default | Reason |
|
||||
|---------|-----------------|---------------------|--------|
|
||||
| `reasoningEffort` | "high" (gpt-5) | "medium" | Matches Codex CLI default |
|
||||
| `textVerbosity` | "low" (gpt-5) | "medium" | Matches Codex CLI default |
|
||||
| `reasoningSummary` | "detailed" | "auto" | Matches Codex CLI default |
|
||||
| gpt-5-codex config | (excluded) | Full support | opencode excludes gpt-5-codex from auto-config |
|
||||
| `store` | true | false | Required for ChatGPT backend |
|
||||
| `include` | (not set) | `["reasoning.encrypted_content"]` | Required for stateless operation |
|
||||
|
||||
## 🎓 Understanding the Architecture
|
||||
## File Paths & Locations
|
||||
|
||||
### Why 10 Helper Functions?
|
||||
- **Plugin config**: `~/.opencode/openai-codex-auth-config.json`
|
||||
- **Cache dir**: `~/.opencode/cache/`
|
||||
- `codex-instructions.md` (Codex CLI instructions from GitHub)
|
||||
- `codex-instructions-meta.json` (ETag + release tag for Codex instructions)
|
||||
- `opencode-codex.txt` (OpenCode system prompt from GitHub, for verification)
|
||||
- `opencode-codex-meta.json` (ETag for OpenCode prompt)
|
||||
- **Debug logs**: `~/.opencode/logs/codex-plugin/` (when `ENABLE_PLUGIN_REQUEST_LOGGING=1`)
|
||||
- **OAuth callback**: `http://localhost:1455/auth/callback`
|
||||
|
||||
The original `fetch()` was 148 lines. Breaking it into 10 focused functions:
|
||||
- Reduces cognitive load (each function < 40 lines)
|
||||
- Enables isolated testing
|
||||
- Makes modifications surgical instead of risky
|
||||
- Allows LLMs to load only relevant context
|
||||
- Documents the flow through function names
|
||||
## Environment Variables
|
||||
|
||||
### Why Centralized Constants?
|
||||
- `CODEX_MODE`: Override config file (1=enable, 0=disable)
|
||||
- `ENABLE_PLUGIN_REQUEST_LOGGING`: Enable detailed request logging (1=enable)
|
||||
|
||||
Having all magic values in `lib/constants.ts`:
|
||||
- Single source of truth
|
||||
- Easy to find and modify
|
||||
- Type-safe access
|
||||
- Reveals all configurable values at a glance
|
||||
- Reduces context needed to understand strings/values
|
||||
## TypeScript Configuration
|
||||
|
||||
### Why Discriminated Unions for Results?
|
||||
- Target: ES2022
|
||||
- Module: ES2022 with bundler resolution
|
||||
- Output: `./dist/`
|
||||
- Strict mode enabled
|
||||
- Declaration files generated
|
||||
- Source maps enabled
|
||||
- Excludes: `test/`, `node_modules/`, `dist/`
|
||||
|
||||
Pattern like `{ success: true; data } | { success: false; error }`:
|
||||
- Type-safe error handling
|
||||
- Forces consumers to handle errors
|
||||
- No unexpected exceptions
|
||||
- Clear success/failure paths
|
||||
- Better than throwing exceptions
|
||||
## Dependencies
|
||||
|
||||
### Why Separate Test Files?
|
||||
**Production**:
|
||||
- `@openauthjs/openauth` (OAuth PKCE implementation)
|
||||
|
||||
Each module has its own test file:
|
||||
- Tests colocated with functionality conceptually
|
||||
- Easy to find relevant tests
|
||||
- Faster test execution (can run subset)
|
||||
- Clear coverage per module
|
||||
- Easier to maintain
|
||||
**Development**:
|
||||
- `@opencode-ai/plugin` (peer dependency)
|
||||
- `vitest` (testing framework)
|
||||
- TypeScript
|
||||
|
||||
## 🔮 Future Extension Points
|
||||
|
||||
The architecture makes these additions easy:
|
||||
|
||||
1. **New authentication method** → Add to `lib/auth.ts`, follow OAuth pattern
|
||||
2. **New model variant** → Update `config/` examples, no code changes needed
|
||||
3. **New configuration option** → Update `lib/types.ts` and `lib/request-transformer.ts`
|
||||
4. **New platform support** → Add to `lib/browser.ts` and `lib/constants.ts`
|
||||
5. **Additional headers** → Modify `createCodexHeaders()` in `lib/fetch-helpers.ts`
|
||||
6. **Different response format** → Add handler in `lib/response-handler.ts`
|
||||
7. **Enhanced logging** → Update `lib/logger.ts`
|
||||
8. **Rate limiting** → Add helper in `lib/fetch-helpers.ts`, integrate in step flow
|
||||
|
||||
## 📚 Additional Resources
|
||||
|
||||
- **config/README.md** - Configuration examples and documentation
|
||||
- **test/** - Reference implementations showing how to use each module
|
||||
- **lib/types.ts** - Complete type definitions for the plugin
|
||||
|
||||
## 🤝 Maintenance Philosophy
|
||||
|
||||
This codebase is optimized for AI agent maintenance. When making changes:
|
||||
|
||||
1. **Read first, write second** - Understand existing patterns before modifying
|
||||
2. **Follow established patterns** - Consistency reduces cognitive load
|
||||
3. **Test everything** - Tests are documentation and safety net
|
||||
4. **Keep functions small** - < 70 lines, single responsibility
|
||||
5. **Document intent** - JSDoc explains the "why", code shows the "how"
|
||||
6. **Use type system** - Let TypeScript catch errors early
|
||||
7. **Centralize configuration** - DRY principle for all constants
|
||||
|
||||
Remember: The goal is maintainability by AI assistants. Every change should make the codebase easier to understand, modify, and extend.
|
||||
**Zero external runtime dependencies** - only uses Node.js built-ins for file I/O, HTTP, crypto.
|
||||
|
||||
@@ -16,10 +16,10 @@ Follow me on [X @nummanthinks](https://x.com/nummanthinks) for future updates an
|
||||
- ✅ **Auto-refreshing tokens** - Handles token expiration automatically
|
||||
- ✅ **Smart auto-updating Codex instructions** - Tracks latest stable release with ETag caching
|
||||
- ✅ **Full tool support** - write, edit, bash, grep, glob, and more
|
||||
- ✅ **CODEX_MODE** - Codex-OpenCode bridge prompt for CLI parity (enabled by default)
|
||||
- ✅ **CODEX_MODE** - Codex-OpenCode bridge prompt with Task tool & MCP awareness (enabled by default)
|
||||
- ✅ **Automatic tool remapping** - Codex tools → opencode tools
|
||||
- ✅ **Configurable reasoning** - Control effort, summary verbosity, and text output
|
||||
- ✅ **Type-safe & tested** - Strict TypeScript with 123 comprehensive tests
|
||||
- ✅ **Type-safe & tested** - Strict TypeScript with 129 comprehensive tests
|
||||
- ✅ **Modular architecture** - Easy to maintain and extend
|
||||
|
||||
## Installation
|
||||
@@ -156,9 +156,12 @@ For a simpler setup (uses plugin defaults: medium reasoning, auto summaries):
|
||||
|
||||
2. **That's it!** opencode will auto-install the plugin on first run.
|
||||
|
||||
> **Note on Updates**: Sometimes opencode does NOT automatically update plugins to new versions. To update:
|
||||
> - Pin to a specific version: `"[email protected]"` and change the number when updating
|
||||
> - Or clear the plugin cache: `rm -rf ~/.cache/opencode/node_modules/opencode-openai-codex-auth`
|
||||
> **Note on Updates**: opencode does NOT automatically update plugins. To force a fresh install of the latest version:
|
||||
> ```bash
|
||||
> # Remove cached plugin entry and folder
|
||||
> sed -i.bak '/"opencode-openai-codex-auth"/d' ~/.cache/opencode/package.json && rm -rf ~/.cache/opencode/node_modules/opencode-openai-codex-auth
|
||||
> ```
|
||||
> Then run `opencode` - it will reinstall the latest version.
|
||||
>
|
||||
> Check [releases](https://github.com/numman-ali/opencode-openai-codex-auth/releases) for the latest version.
|
||||
|
||||
@@ -227,6 +230,52 @@ When using [`config/full-opencode.json`](./config/full-opencode.json), you get t
|
||||
|
||||
All accessed via your ChatGPT Plus/Pro subscription.
|
||||
|
||||
### Using Model Variants in Custom Commands & Agents
|
||||
|
||||
When using model aliases in **custom command frontmatter** or **agent configurations**, you **must include the provider prefix** (`openai/`):
|
||||
|
||||
#### ✅ Correct - Custom Command Example
|
||||
|
||||
```yaml
|
||||
---
|
||||
description: Create a git commit message
|
||||
agent: build
|
||||
model: openai/GPT 5 Codex Low (ChatGPT Subscription)
|
||||
---
|
||||
|
||||
Create a commit message for the staged changes
|
||||
```
|
||||
|
||||
#### ❌ Incorrect - Missing Provider Prefix
|
||||
|
||||
```yaml
|
||||
---
|
||||
description: Create a git commit message
|
||||
agent: build
|
||||
model: GPT 5 Codex Low (ChatGPT Subscription) # ❌ Will fail - missing "openai/"
|
||||
---
|
||||
```
|
||||
|
||||
#### ✅ Correct - Agent Configuration Example
|
||||
|
||||
```json
|
||||
{
|
||||
"agent": {
|
||||
"commit": {
|
||||
"model": "openai/GPT 5 Codex Low (ChatGPT Subscription)",
|
||||
"prompt": "You are a git commit message expert..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why?** The `--model` CLI flag, command frontmatter, and agent configs all use the same model resolution format: `provider/model`. Model aliases defined in your config are indexed under the provider namespace.
|
||||
|
||||
**Alternative**: Instead of specifying the model in each command, you can:
|
||||
1. Set a default model in your agent configuration
|
||||
2. Use the agent's model setting instead of specifying per-command
|
||||
3. Rely on the global default model in your opencode config
|
||||
|
||||
### Plugin Defaults
|
||||
|
||||
When no configuration is specified, the plugin uses these defaults for all GPT-5 models:
|
||||
@@ -371,8 +420,8 @@ The plugin implements a 7-step fetch flow in TypeScript:
|
||||
- Model normalization (`gpt-5-codex` variants → `gpt-5-codex`, `gpt-5` variants → `gpt-5`)
|
||||
- Injects Codex instructions from latest [openai/codex](https://github.com/openai/codex) release
|
||||
- Applies reasoning configuration (effort, summary, verbosity)
|
||||
- Adds Codex-OpenCode bridge prompt (CODEX_MODE=true, default) or tool remap message (CODEX_MODE=false)
|
||||
- Filters OpenCode system prompts when in CODEX_MODE
|
||||
- Adds Codex-OpenCode bridge prompt with Task tool & MCP awareness (CODEX_MODE=true, default) or tool remap message (CODEX_MODE=false)
|
||||
- Filters OpenCode system prompts when in CODEX_MODE (verified against cached OpenCode prompt for accuracy)
|
||||
- Filters conversation history (removes stored IDs for stateless operation)
|
||||
4. **Headers**: Adds OAuth token and ChatGPT account ID headers
|
||||
5. **Request Execution**: Sends to Codex backend API
|
||||
@@ -383,10 +432,10 @@ The plugin implements a 7-step fetch flow in TypeScript:
|
||||
|
||||
- **Modular Design**: 10 focused helper functions, each < 40 lines
|
||||
- **Type-Safe**: Strict TypeScript with comprehensive type definitions
|
||||
- **Tested**: 123 tests covering all functionality including CODEX_MODE
|
||||
- **Tested**: 129 tests covering all functionality including CODEX_MODE
|
||||
- **Zero Dependencies**: Only uses @openauthjs/openauth
|
||||
- **Codex Instructions**: ETag-cached from GitHub, auto-updates on new releases
|
||||
- **CODEX_MODE**: Configurable bridge prompt for Codex CLI parity (enabled by default)
|
||||
- **CODEX_MODE**: Configurable bridge prompt for Codex CLI parity with Task tool & MCP awareness (enabled by default)
|
||||
- **Stateless Operation**: Uses `store: false` with encrypted reasoning content for multi-turn conversations
|
||||
|
||||
## Limitations
|
||||
@@ -405,7 +454,7 @@ The plugin implements a 7-step fetch flow in TypeScript:
|
||||
|
||||
- Verify plugin is loaded in `opencode.json`
|
||||
- Check that model is set to `openai/gpt-5-codex`
|
||||
- Check `~/.opencode/cache/` for cached instructions (auto-downloads from GitHub)
|
||||
- Check `~/.opencode/cache/` for cached instructions and OpenCode prompts (auto-downloads from GitHub)
|
||||
|
||||
### Request Errors
|
||||
|
||||
@@ -413,6 +462,29 @@ The plugin implements a 7-step fetch flow in TypeScript:
|
||||
- **400 Bad Request**: Check console output for specific error details
|
||||
- **403 Forbidden**: Subscription may be expired or invalid
|
||||
|
||||
### Model Not Found in Custom Commands/Agents
|
||||
|
||||
If you get a "model not found" error when using custom commands or agents with model aliases:
|
||||
|
||||
**Problem**: Model aliases in command frontmatter or agent configs require the provider prefix.
|
||||
|
||||
```yaml
|
||||
# ❌ This will fail
|
||||
model: GPT 5 Codex Low (ChatGPT Subscription)
|
||||
|
||||
# ✅ This works
|
||||
model: openai/GPT 5 Codex Low (ChatGPT Subscription)
|
||||
```
|
||||
|
||||
**Why?** The `--model` CLI flag, custom commands, and agent configurations all use the `provider/model` format. Bare aliases (without `openai/`) are not automatically resolved to the provider namespace.
|
||||
|
||||
**Solution**: Always include the provider prefix when using model aliases in:
|
||||
- Custom command frontmatter (`model: openai/...`)
|
||||
- Agent configurations (`"model": "openai/..."`)
|
||||
- CLI `--model` flag (`--model=openai/...`)
|
||||
|
||||
See [Using Model Variants in Custom Commands & Agents](#using-model-variants-in-custom-commands--agents) for detailed examples.
|
||||
|
||||
### Plugin Issues
|
||||
|
||||
If you encounter issues with the latest version, you can pin to a specific stable release:
|
||||
@@ -457,22 +529,28 @@ Each request generates 3-4 JSON files:
|
||||
opencode-openai-codex-auth/
|
||||
├── index.ts # Main plugin entry point
|
||||
├── lib/
|
||||
│ ├── auth.ts # OAuth authentication logic
|
||||
│ ├── codex.ts # Codex instructions & tool remapping
|
||||
│ ├── server.ts # Local OAuth callback server
|
||||
│ ├── browser.ts # Platform-specific browser opening
|
||||
│ ├── logger.ts # Request logging (debug mode)
|
||||
│ ├── request-transformer.ts # Request body transformations
|
||||
│ ├── response-handler.ts # SSE to JSON conversion
|
||||
│ ├── fetch-helpers.ts # Focused helper functions
|
||||
│ ├── constants.ts # All magic values and URLs
|
||||
│ └── types.ts # TypeScript type definitions
|
||||
│ ├── auth/ # OAuth authentication modules
|
||||
│ │ ├── auth.ts # OAuth flow, PKCE, token exchange
|
||||
│ │ ├── server.ts # Local OAuth callback server
|
||||
│ │ └── browser.ts # Platform-specific browser opening
|
||||
│ ├── prompts/ # Prompt management modules
|
||||
│ │ ├── codex.ts # Codex instructions fetching/caching
|
||||
│ │ ├── opencode-codex.ts # OpenCode prompt verification cache
|
||||
│ │ └── codex-opencode-bridge.ts # Bridge prompt with Task/MCP awareness
|
||||
│ ├── request/ # Request handling modules
|
||||
│ │ ├── request-transformer.ts # Request body transformations
|
||||
│ │ ├── response-handler.ts # SSE to JSON conversion
|
||||
│ │ └── fetch-helpers.ts # 10 focused helper functions
|
||||
│ ├── config.ts # Plugin configuration & CODEX_MODE
|
||||
│ ├── logger.ts # Request logging (debug mode)
|
||||
│ ├── constants.ts # All magic values and URLs
|
||||
│ └── types.ts # TypeScript type definitions
|
||||
├── config/
|
||||
│ ├── full-opencode.json # Complete config with all variants
|
||||
│ ├── minimal-opencode.json # Minimal config example
|
||||
│ └── README.md # Configuration documentation
|
||||
├── test/ # Comprehensive test suite (93 tests)
|
||||
├── AGENTS.md # AI agent maintenance guide
|
||||
│ ├── full-opencode.json # Complete config with all variants
|
||||
│ ├── minimal-opencode.json # Minimal config example
|
||||
│ └── README.md # Configuration documentation
|
||||
├── test/ # Comprehensive test suite (129 tests)
|
||||
├── AGENTS.md # Agent coding guidance
|
||||
├── package.json
|
||||
├── README.md
|
||||
└── LICENSE
|
||||
@@ -481,14 +559,17 @@ opencode-openai-codex-auth/
|
||||
### Module Overview
|
||||
|
||||
- **index.ts**: Main plugin export and fetch() orchestration (7-step flow)
|
||||
- **lib/auth.ts**: OAuth flow, PKCE, token exchange, JWT decoding
|
||||
- **lib/codex.ts**: Fetches/caches Codex instructions from GitHub, tool remapping
|
||||
- **lib/server.ts**: Local HTTP server for OAuth callback handling
|
||||
- **lib/browser.ts**: Platform detection and browser opening
|
||||
- **lib/auth/auth.ts**: OAuth flow, PKCE, token exchange, JWT decoding
|
||||
- **lib/auth/server.ts**: Local HTTP server for OAuth callback handling
|
||||
- **lib/auth/browser.ts**: Platform detection and browser opening
|
||||
- **lib/prompts/codex.ts**: Fetches/caches Codex instructions from GitHub, tool remapping
|
||||
- **lib/prompts/opencode-codex.ts**: Fetches/caches OpenCode system prompts for verification
|
||||
- **lib/prompts/codex-opencode-bridge.ts**: Bridge prompt with Task tool & MCP awareness
|
||||
- **lib/request/request-transformer.ts**: Request transformations (model normalization, reasoning config, prompt filtering)
|
||||
- **lib/request/response-handler.ts**: Response handling (SSE to JSON conversion)
|
||||
- **lib/request/fetch-helpers.ts**: 10 focused helper functions for main fetch flow
|
||||
- **lib/config.ts**: Plugin configuration loading and CODEX_MODE determination
|
||||
- **lib/logger.ts**: Debug logging (controlled by environment variable)
|
||||
- **lib/request-transformer.ts**: Request transformations (model normalization, reasoning config)
|
||||
- **lib/response-handler.ts**: Response handling (SSE to JSON conversion)
|
||||
- **lib/fetch-helpers.ts**: 10 focused helper functions for main fetch flow
|
||||
- **lib/constants.ts**: Centralized constants, URLs, error messages
|
||||
- **lib/types.ts**: TypeScript type definitions
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
|
||||
const url = rewriteUrlForCodex(originalUrl);
|
||||
|
||||
// Step 3: Transform request body with Codex instructions
|
||||
const transformation = transformRequestForCodex(init, url, CODEX_INSTRUCTIONS, userConfig, codexMode);
|
||||
const transformation = await transformRequestForCodex(init, url, CODEX_INSTRUCTIONS, userConfig, codexMode);
|
||||
const hasTools = transformation?.body.tools !== undefined;
|
||||
const requestInit = transformation?.updatedInit ?? init;
|
||||
|
||||
|
||||
@@ -85,6 +85,20 @@ If ANY answer is NO → STOP and correct before proceeding.
|
||||
**Testing:**
|
||||
- If tests exist: Start specific to your changes, then broader validation
|
||||
|
||||
## Advanced Tools
|
||||
|
||||
**Task Tool (Sub-Agents):**
|
||||
- Specialized agents are available via the Task tool
|
||||
- Check the Task tool description for current agent types and their capabilities
|
||||
- Useful for complex analysis, specialized workflows, or tasks requiring isolated context
|
||||
- The agent list is dynamically generated - refer to tool schema for available agents
|
||||
|
||||
**MCP Tools:**
|
||||
- Model Context Protocol servers provide additional capabilities
|
||||
- MCP tools are prefixed: \`mcp__<server-name>__<tool-name>\`
|
||||
- Check your available tools for MCP integrations
|
||||
- Use when the tool's functionality matches your task needs
|
||||
|
||||
## What Remains from Codex
|
||||
|
||||
Sandbox policies, approval mechanisms, final answer formatting, git commit protocols, and file reference formats all follow Codex instructions.`;
|
||||
@@ -98,12 +112,14 @@ export interface CodexOpenCodeBridgeMeta {
|
||||
}
|
||||
|
||||
export const CODEX_OPENCODE_BRIDGE_META: CodexOpenCodeBridgeMeta = {
|
||||
estimatedTokens: 450,
|
||||
reductionVsCurrent: "90%",
|
||||
reductionVsToolRemap: "20%",
|
||||
estimatedTokens: 550,
|
||||
reductionVsCurrent: "88%",
|
||||
reductionVsToolRemap: "10%",
|
||||
protects: [
|
||||
"Tool name confusion (apply_patch/update_plan)",
|
||||
"Missing tool awareness",
|
||||
"Task tool / sub-agent awareness",
|
||||
"MCP tool awareness",
|
||||
"Premature yielding to user",
|
||||
"Over-modification of existing code",
|
||||
"Environment confusion",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* OpenCode Codex Prompt Fetcher
|
||||
*
|
||||
* Fetches and caches the codex.txt system prompt from OpenCode's GitHub repository.
|
||||
* Uses ETag-based caching to efficiently track updates.
|
||||
*/
|
||||
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
const OPENCODE_CODEX_URL =
|
||||
"https://raw.githubusercontent.com/sst/opencode/main/packages/opencode/src/session/prompt/codex.txt";
|
||||
const CACHE_DIR = join(homedir(), ".opencode", "cache");
|
||||
const CACHE_FILE = join(CACHE_DIR, "opencode-codex.txt");
|
||||
const CACHE_META_FILE = join(CACHE_DIR, "opencode-codex-meta.json");
|
||||
|
||||
interface CacheMeta {
|
||||
etag: string;
|
||||
lastFetch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch OpenCode's codex.txt prompt with ETag-based caching
|
||||
* @returns The codex.txt content
|
||||
*/
|
||||
export async function getOpenCodeCodexPrompt(): Promise<string> {
|
||||
await mkdir(CACHE_DIR, { recursive: true });
|
||||
|
||||
// Try to load cached content and metadata
|
||||
let cachedContent: string | null = null;
|
||||
let cachedMeta: CacheMeta | null = null;
|
||||
|
||||
try {
|
||||
cachedContent = await readFile(CACHE_FILE, "utf-8");
|
||||
const metaContent = await readFile(CACHE_META_FILE, "utf-8");
|
||||
cachedMeta = JSON.parse(metaContent);
|
||||
} catch {
|
||||
// Cache doesn't exist or is invalid, will fetch fresh
|
||||
}
|
||||
|
||||
// Fetch from GitHub with conditional request
|
||||
const headers: Record<string, string> = {};
|
||||
if (cachedMeta?.etag) {
|
||||
headers["If-None-Match"] = cachedMeta.etag;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(OPENCODE_CODEX_URL, { headers });
|
||||
|
||||
// 304 Not Modified - cache is still valid
|
||||
if (response.status === 304 && cachedContent) {
|
||||
return cachedContent;
|
||||
}
|
||||
|
||||
// 200 OK - new content available
|
||||
if (response.ok) {
|
||||
const content = await response.text();
|
||||
const etag = response.headers.get("etag") || "";
|
||||
|
||||
// Save to cache
|
||||
await writeFile(CACHE_FILE, content, "utf-8");
|
||||
await writeFile(
|
||||
CACHE_META_FILE,
|
||||
JSON.stringify(
|
||||
{
|
||||
etag,
|
||||
lastFetch: new Date().toISOString(),
|
||||
} satisfies CacheMeta,
|
||||
null,
|
||||
2
|
||||
),
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
// Fallback to cache if available
|
||||
if (cachedContent) {
|
||||
return cachedContent;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch OpenCode codex.txt: ${response.status}`);
|
||||
} catch (error) {
|
||||
// Network error - fallback to cache
|
||||
if (cachedContent) {
|
||||
return cachedContent;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to fetch OpenCode codex.txt and no cache available: ${error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get first N characters of the cached OpenCode prompt for verification
|
||||
* @param chars Number of characters to get (default: 50)
|
||||
* @returns First N characters or null if not cached
|
||||
*/
|
||||
export async function getCachedPromptPrefix(chars = 50): Promise<string | null> {
|
||||
try {
|
||||
const content = await readFile(CACHE_FILE, "utf-8");
|
||||
return content.substring(0, chars);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -26,11 +26,7 @@ import {
|
||||
* @returns True if token is expired or invalid
|
||||
*/
|
||||
export function shouldRefreshToken(auth: Auth): boolean {
|
||||
return (
|
||||
auth.type !== "oauth" ||
|
||||
!auth.access ||
|
||||
auth.expires < Date.now()
|
||||
);
|
||||
return auth.type !== "oauth" || !auth.access || auth.expires < Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,8 +37,10 @@ export function shouldRefreshToken(auth: Auth): boolean {
|
||||
*/
|
||||
export async function refreshAndUpdateToken(
|
||||
currentAuth: Auth,
|
||||
client: OpencodeClient
|
||||
): Promise<{ success: true; auth: Auth } | { success: false; response: Response }> {
|
||||
client: OpencodeClient,
|
||||
): Promise<
|
||||
{ success: true; auth: Auth } | { success: false; response: Response }
|
||||
> {
|
||||
const refreshToken = currentAuth.type === "oauth" ? currentAuth.refresh : "";
|
||||
const refreshResult = await refreshAccessToken(refreshToken);
|
||||
|
||||
@@ -52,7 +50,7 @@ export async function refreshAndUpdateToken(
|
||||
success: false,
|
||||
response: new Response(
|
||||
JSON.stringify({ error: "Token refresh failed" }),
|
||||
{ status: HTTP_STATUS.UNAUTHORIZED }
|
||||
{ status: HTTP_STATUS.UNAUTHORIZED },
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -107,13 +105,13 @@ export function rewriteUrlForCodex(url: string): string {
|
||||
* @param codexMode - Enable CODEX_MODE (bridge prompt instead of tool remap)
|
||||
* @returns Transformed body and updated init, or undefined if no body
|
||||
*/
|
||||
export function transformRequestForCodex(
|
||||
export async function transformRequestForCodex(
|
||||
init: RequestInit | undefined,
|
||||
url: string,
|
||||
codexInstructions: string,
|
||||
userConfig: UserConfig,
|
||||
codexMode: boolean = true
|
||||
): { body: RequestBody; updatedInit: RequestInit } | undefined {
|
||||
codexMode = true,
|
||||
): Promise<{ body: RequestBody; updatedInit: RequestInit } | undefined> {
|
||||
if (!init?.body) return undefined;
|
||||
|
||||
try {
|
||||
@@ -133,7 +131,12 @@ export function transformRequestForCodex(
|
||||
});
|
||||
|
||||
// Transform request body
|
||||
const transformedBody = transformRequestBody(body, codexInstructions, userConfig, codexMode);
|
||||
const transformedBody = await transformRequestBody(
|
||||
body,
|
||||
codexInstructions,
|
||||
userConfig,
|
||||
codexMode,
|
||||
);
|
||||
|
||||
// Log transformed request
|
||||
logRequest(LOG_STAGES.AFTER_TRANSFORM, {
|
||||
@@ -169,7 +172,7 @@ export function transformRequestForCodex(
|
||||
export function createCodexHeaders(
|
||||
init: RequestInit | undefined,
|
||||
accountId: string,
|
||||
accessToken: string
|
||||
accessToken: string,
|
||||
): Headers {
|
||||
const headers = new Headers(init?.headers ?? {});
|
||||
headers.delete("x-api-key"); // Remove any existing API key
|
||||
@@ -186,7 +189,9 @@ export function createCodexHeaders(
|
||||
* @param response - Error response from API
|
||||
* @returns Response with error details
|
||||
*/
|
||||
export async function handleErrorResponse(response: Response): Promise<Response> {
|
||||
export async function handleErrorResponse(
|
||||
response: Response,
|
||||
): Promise<Response> {
|
||||
const text = await response.text();
|
||||
console.error(`[${PLUGIN_NAME}] ${response.status} error:`, text);
|
||||
|
||||
@@ -211,7 +216,7 @@ export async function handleErrorResponse(response: Response): Promise<Response>
|
||||
*/
|
||||
export async function handleSuccessResponse(
|
||||
response: Response,
|
||||
hasTools: boolean
|
||||
hasTools: boolean,
|
||||
): Promise<Response> {
|
||||
const responseHeaders = ensureContentType(response.headers);
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { TOOL_REMAP_MESSAGE } from "../prompts/codex.js";
|
||||
import { CODEX_OPENCODE_BRIDGE } from "../prompts/codex-opencode-bridge.js";
|
||||
import type { UserConfig, ConfigOptions, ReasoningConfig, RequestBody, InputItem } from "../types.js";
|
||||
import { getOpenCodeCodexPrompt } from "../prompts/opencode-codex.js";
|
||||
import type {
|
||||
UserConfig,
|
||||
ConfigOptions,
|
||||
ReasoningConfig,
|
||||
RequestBody,
|
||||
InputItem,
|
||||
} from "../types.js";
|
||||
|
||||
/**
|
||||
* Normalize model name to Codex-supported variants
|
||||
@@ -27,7 +34,10 @@ export function normalizeModel(model: string | undefined): string {
|
||||
* @param userConfig - Full user configuration object
|
||||
* @returns Merged configuration for this model
|
||||
*/
|
||||
export function getModelConfig(modelName: string, userConfig: UserConfig = { global: {}, models: {} }): ConfigOptions {
|
||||
export function getModelConfig(
|
||||
modelName: string,
|
||||
userConfig: UserConfig = { global: {}, models: {} },
|
||||
): ConfigOptions {
|
||||
const globalOptions = userConfig.global || {};
|
||||
const modelOptions = userConfig.models?.[modelName]?.options || {};
|
||||
|
||||
@@ -47,13 +57,18 @@ export function getModelConfig(modelName: string, userConfig: UserConfig = { glo
|
||||
* @param userConfig - User configuration object
|
||||
* @returns Reasoning configuration
|
||||
*/
|
||||
export function getReasoningConfig(originalModel: string | undefined, userConfig: ConfigOptions = {}): ReasoningConfig {
|
||||
export function getReasoningConfig(
|
||||
originalModel: string | undefined,
|
||||
userConfig: ConfigOptions = {},
|
||||
): ReasoningConfig {
|
||||
const isLightweight =
|
||||
originalModel?.includes("nano") || originalModel?.includes("mini");
|
||||
const isCodex = originalModel?.includes("codex");
|
||||
|
||||
// Default based on model type (Codex CLI defaults)
|
||||
const defaultEffort: "minimal" | "low" | "medium" | "high" = isLightweight ? "minimal" : "medium";
|
||||
const defaultEffort: "minimal" | "low" | "medium" | "high" = isLightweight
|
||||
? "minimal"
|
||||
: "medium";
|
||||
|
||||
// Get user-requested effort
|
||||
let effort = userConfig.reasoningEffort || defaultEffort;
|
||||
@@ -76,7 +91,9 @@ export function getReasoningConfig(originalModel: string | undefined, userConfig
|
||||
* @param input - Original input array
|
||||
* @returns Filtered input array
|
||||
*/
|
||||
export function filterInput(input: InputItem[] | undefined): InputItem[] | undefined {
|
||||
export function filterInput(
|
||||
input: InputItem[] | undefined,
|
||||
): InputItem[] | undefined {
|
||||
if (!Array.isArray(input)) return input;
|
||||
|
||||
return input.filter((item) => {
|
||||
@@ -90,26 +107,52 @@ export function filterInput(input: InputItem[] | undefined): InputItem[] | undef
|
||||
|
||||
/**
|
||||
* Check if an input item is the OpenCode system prompt
|
||||
* Uses cached OpenCode codex.txt for verification with fallback to text matching
|
||||
* @param item - Input item to check
|
||||
* @param cachedPrompt - Cached OpenCode codex.txt content
|
||||
* @returns True if this is the OpenCode system prompt
|
||||
*/
|
||||
export function isOpenCodeSystemPrompt(item: InputItem): boolean {
|
||||
export function isOpenCodeSystemPrompt(
|
||||
item: InputItem,
|
||||
cachedPrompt: string | null,
|
||||
): boolean {
|
||||
const isSystemRole = item.role === "developer" || item.role === "system";
|
||||
if (!isSystemRole) return false;
|
||||
|
||||
// Check string content
|
||||
if (typeof item.content === "string") {
|
||||
return item.content.startsWith("You are a coding agent running in");
|
||||
const getContentText = (item: InputItem): string => {
|
||||
if (typeof item.content === "string") {
|
||||
return item.content;
|
||||
}
|
||||
if (Array.isArray(item.content)) {
|
||||
return item.content
|
||||
.filter((c) => c.type === "input_text" && c.text)
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const contentText = getContentText(item);
|
||||
if (!contentText) return false;
|
||||
|
||||
// Primary check: Compare against cached OpenCode prompt
|
||||
if (cachedPrompt) {
|
||||
// Exact match (trim whitespace for comparison)
|
||||
if (contentText.trim() === cachedPrompt.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Partial match: Check if first 200 chars match (handles minor variations)
|
||||
const contentPrefix = contentText.trim().substring(0, 200);
|
||||
const cachedPrefix = cachedPrompt.trim().substring(0, 200);
|
||||
if (contentPrefix === cachedPrefix) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check array content
|
||||
if (Array.isArray(item.content)) {
|
||||
return item.content.some(
|
||||
(c) => c.type === "input_text" && c.text?.startsWith("You are a coding agent running in")
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
// Fallback check: Known OpenCode prompt signature (for safety)
|
||||
// This catches the prompt even if cache fails
|
||||
return contentText.startsWith("You are a coding agent running in");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,14 +161,25 @@ export function isOpenCodeSystemPrompt(item: InputItem): boolean {
|
||||
* @param input - Input array
|
||||
* @returns Input array without OpenCode system prompts
|
||||
*/
|
||||
export function filterOpenCodeSystemPrompts(input: InputItem[] | undefined): InputItem[] | undefined {
|
||||
export async function filterOpenCodeSystemPrompts(
|
||||
input: InputItem[] | undefined,
|
||||
): Promise<InputItem[] | undefined> {
|
||||
if (!Array.isArray(input)) return input;
|
||||
|
||||
// Fetch cached OpenCode prompt for verification
|
||||
let cachedPrompt: string | null = null;
|
||||
try {
|
||||
cachedPrompt = await getOpenCodeCodexPrompt();
|
||||
} catch {
|
||||
// If fetch fails, fallback to text-based detection only
|
||||
// This is safe because we still have the "starts with" check
|
||||
}
|
||||
|
||||
return input.filter((item) => {
|
||||
// Keep user messages
|
||||
if (item.role === "user") return true;
|
||||
// Filter out OpenCode system prompts
|
||||
return !isOpenCodeSystemPrompt(item);
|
||||
return !isOpenCodeSystemPrompt(item, cachedPrompt);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,7 +189,10 @@ export function filterOpenCodeSystemPrompts(input: InputItem[] | undefined): Inp
|
||||
* @param hasTools - Whether tools are present in request
|
||||
* @returns Input array with bridge message prepended if needed
|
||||
*/
|
||||
export function addCodexBridgeMessage(input: InputItem[] | undefined, hasTools: boolean): InputItem[] | undefined {
|
||||
export function addCodexBridgeMessage(
|
||||
input: InputItem[] | undefined,
|
||||
hasTools: boolean,
|
||||
): InputItem[] | undefined {
|
||||
if (!hasTools || !Array.isArray(input)) return input;
|
||||
|
||||
const bridgeMessage: InputItem = {
|
||||
@@ -158,7 +215,10 @@ export function addCodexBridgeMessage(input: InputItem[] | undefined, hasTools:
|
||||
* @param hasTools - Whether tools are present in request
|
||||
* @returns Input array with tool remap message prepended if needed
|
||||
*/
|
||||
export function addToolRemapMessage(input: InputItem[] | undefined, hasTools: boolean): InputItem[] | undefined {
|
||||
export function addToolRemapMessage(
|
||||
input: InputItem[] | undefined,
|
||||
hasTools: boolean,
|
||||
): InputItem[] | undefined {
|
||||
if (!hasTools || !Array.isArray(input)) return input;
|
||||
|
||||
const toolRemapMessage: InputItem = {
|
||||
@@ -189,12 +249,12 @@ export function addToolRemapMessage(input: InputItem[] | undefined, hasTools: bo
|
||||
* @param codexMode - Enable CODEX_MODE (bridge prompt instead of tool remap) - defaults to true
|
||||
* @returns Transformed request body
|
||||
*/
|
||||
export function transformRequestBody(
|
||||
export async function transformRequestBody(
|
||||
body: RequestBody,
|
||||
codexInstructions: string,
|
||||
userConfig: UserConfig = { global: {}, models: {} },
|
||||
codexMode: boolean = true
|
||||
): RequestBody {
|
||||
codexMode = true,
|
||||
): Promise<RequestBody> {
|
||||
const originalModel = body.model;
|
||||
const normalizedModel = normalizeModel(body.model);
|
||||
|
||||
@@ -215,7 +275,7 @@ export function transformRequestBody(
|
||||
|
||||
if (codexMode) {
|
||||
// CODEX_MODE: Remove OpenCode system prompt, add bridge prompt
|
||||
body.input = filterOpenCodeSystemPrompts(body.input);
|
||||
body.input = await filterOpenCodeSystemPrompts(body.input);
|
||||
body.input = addCodexBridgeMessage(body.input, !!body.tools);
|
||||
} else {
|
||||
// DEFAULT MODE: Keep original behavior with tool remap message
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "opencode-openai-codex-auth",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"description": "OpenAI ChatGPT (Codex backend) OAuth auth plugin for opencode - use your ChatGPT Plus/Pro subscription instead of API credits",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -14,36 +14,36 @@ import type { RequestBody, UserConfig, InputItem } from '../lib/types.js';
|
||||
|
||||
describe('Request Transformer Module', () => {
|
||||
describe('normalizeModel', () => {
|
||||
it('should normalize gpt-5-codex', () => {
|
||||
it('should normalize gpt-5-codex', async () => {
|
||||
expect(normalizeModel('gpt-5-codex')).toBe('gpt-5-codex');
|
||||
});
|
||||
|
||||
it('should normalize gpt-5', () => {
|
||||
it('should normalize gpt-5', async () => {
|
||||
expect(normalizeModel('gpt-5')).toBe('gpt-5');
|
||||
});
|
||||
|
||||
it('should normalize variants containing "codex"', () => {
|
||||
it('should normalize variants containing "codex"', async () => {
|
||||
expect(normalizeModel('openai/gpt-5-codex')).toBe('gpt-5-codex');
|
||||
expect(normalizeModel('custom-gpt-5-codex-variant')).toBe('gpt-5-codex');
|
||||
});
|
||||
|
||||
it('should normalize variants containing "gpt-5"', () => {
|
||||
it('should normalize variants containing "gpt-5"', async () => {
|
||||
expect(normalizeModel('gpt-5-mini')).toBe('gpt-5');
|
||||
expect(normalizeModel('gpt-5-nano')).toBe('gpt-5');
|
||||
});
|
||||
|
||||
it('should return gpt-5 as default for unknown models', () => {
|
||||
it('should return gpt-5 as default for unknown models', async () => {
|
||||
expect(normalizeModel('unknown-model')).toBe('gpt-5');
|
||||
expect(normalizeModel('gpt-4')).toBe('gpt-5');
|
||||
});
|
||||
|
||||
it('should return gpt-5 for undefined', () => {
|
||||
it('should return gpt-5 for undefined', async () => {
|
||||
expect(normalizeModel(undefined)).toBe('gpt-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterInput', () => {
|
||||
it('should keep items without IDs', () => {
|
||||
it('should keep items without IDs', async () => {
|
||||
const input: InputItem[] = [
|
||||
{ type: 'message', role: 'user', content: 'hello' },
|
||||
];
|
||||
@@ -51,7 +51,7 @@ describe('Request Transformer Module', () => {
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should remove items with rs_ IDs', () => {
|
||||
it('should remove items with rs_ IDs', async () => {
|
||||
const input: InputItem[] = [
|
||||
{ id: 'rs_123', type: 'message', role: 'user', content: 'hello' },
|
||||
{ id: 'msg_456', type: 'message', role: 'user', content: 'world' },
|
||||
@@ -61,7 +61,7 @@ describe('Request Transformer Module', () => {
|
||||
expect(result![0].id).toBe('msg_456');
|
||||
});
|
||||
|
||||
it('should handle mixed items', () => {
|
||||
it('should handle mixed items', async () => {
|
||||
const input: InputItem[] = [
|
||||
{ type: 'message', role: 'user', content: '1' },
|
||||
{ id: 'rs_stored', type: 'message', role: 'assistant', content: '2' },
|
||||
@@ -73,18 +73,18 @@ describe('Request Transformer Module', () => {
|
||||
expect(result![1].content).toBe('3');
|
||||
});
|
||||
|
||||
it('should return undefined for undefined input', () => {
|
||||
it('should return undefined for undefined input', async () => {
|
||||
expect(filterInput(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return non-array input as-is', () => {
|
||||
it('should return non-array input as-is', async () => {
|
||||
const notArray = { notAnArray: true };
|
||||
expect(filterInput(notArray as any)).toBe(notArray);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addToolRemapMessage', () => {
|
||||
it('should prepend tool remap message when tools present', () => {
|
||||
it('should prepend tool remap message when tools present', async () => {
|
||||
const input: InputItem[] = [
|
||||
{ type: 'message', role: 'user', content: 'hello' },
|
||||
];
|
||||
@@ -96,7 +96,7 @@ describe('Request Transformer Module', () => {
|
||||
expect((result![0].content as any)[0].text).toContain('apply_patch');
|
||||
});
|
||||
|
||||
it('should not modify input when tools not present', () => {
|
||||
it('should not modify input when tools not present', async () => {
|
||||
const input: InputItem[] = [
|
||||
{ type: 'message', role: 'user', content: 'hello' },
|
||||
];
|
||||
@@ -104,27 +104,27 @@ describe('Request Transformer Module', () => {
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should return undefined for undefined input', () => {
|
||||
it('should return undefined for undefined input', async () => {
|
||||
expect(addToolRemapMessage(undefined, true)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle non-array input', () => {
|
||||
it('should handle non-array input', async () => {
|
||||
const notArray = { notAnArray: true };
|
||||
expect(addToolRemapMessage(notArray as any, true)).toBe(notArray);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOpenCodeSystemPrompt', () => {
|
||||
it('should detect OpenCode system prompt with string content', () => {
|
||||
it('should detect OpenCode system prompt with string content', async () => {
|
||||
const item: InputItem = {
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
content: 'You are a coding agent running in OpenCode',
|
||||
};
|
||||
expect(isOpenCodeSystemPrompt(item)).toBe(true);
|
||||
expect(isOpenCodeSystemPrompt(item, null)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect OpenCode system prompt with array content', () => {
|
||||
it('should detect OpenCode system prompt with array content', async () => {
|
||||
const item: InputItem = {
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
@@ -135,39 +135,79 @@ describe('Request Transformer Module', () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(isOpenCodeSystemPrompt(item)).toBe(true);
|
||||
expect(isOpenCodeSystemPrompt(item, null)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect with system role', () => {
|
||||
it('should detect with system role', async () => {
|
||||
const item: InputItem = {
|
||||
type: 'message',
|
||||
role: 'system',
|
||||
content: 'You are a coding agent running in OpenCode',
|
||||
};
|
||||
expect(isOpenCodeSystemPrompt(item)).toBe(true);
|
||||
expect(isOpenCodeSystemPrompt(item, null)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect non-system roles', () => {
|
||||
it('should not detect non-system roles', async () => {
|
||||
const item: InputItem = {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: 'You are a coding agent running in OpenCode',
|
||||
};
|
||||
expect(isOpenCodeSystemPrompt(item)).toBe(false);
|
||||
expect(isOpenCodeSystemPrompt(item, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not detect different content', () => {
|
||||
it('should not detect different content', async () => {
|
||||
const item: InputItem = {
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
content: 'Different message',
|
||||
};
|
||||
expect(isOpenCodeSystemPrompt(item)).toBe(false);
|
||||
expect(isOpenCodeSystemPrompt(item, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect AGENTS.md content', async () => {
|
||||
const item: InputItem = {
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
content: '# Project Guidelines\n\nThis is custom AGENTS.md content for the project.',
|
||||
};
|
||||
expect(isOpenCodeSystemPrompt(item, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect environment info concatenated with AGENTS.md', async () => {
|
||||
const item: InputItem = {
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
content: 'Environment: /path/to/project\nDate: 2025-01-01\n\n# AGENTS.md\n\nCustom instructions here.',
|
||||
};
|
||||
expect(isOpenCodeSystemPrompt(item, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT detect content with codex signature in the middle', async () => {
|
||||
const cachedPrompt = 'You are a coding agent running in OpenCode.';
|
||||
const item: InputItem = {
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
// Has codex.txt content but with environment prepended (like OpenCode does)
|
||||
content: 'Environment info here\n\nYou are a coding agent running in OpenCode.',
|
||||
};
|
||||
// First 200 chars won't match because of prepended content
|
||||
expect(isOpenCodeSystemPrompt(item, cachedPrompt)).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect with cached prompt exact match', async () => {
|
||||
const cachedPrompt = 'You are a coding agent running in OpenCode';
|
||||
const item: InputItem = {
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
content: 'You are a coding agent running in OpenCode',
|
||||
};
|
||||
expect(isOpenCodeSystemPrompt(item, cachedPrompt)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterOpenCodeSystemPrompts', () => {
|
||||
it('should filter out OpenCode system prompts', () => {
|
||||
it('should filter out OpenCode system prompts', async () => {
|
||||
const input: InputItem[] = [
|
||||
{
|
||||
type: 'message',
|
||||
@@ -176,36 +216,79 @@ describe('Request Transformer Module', () => {
|
||||
},
|
||||
{ type: 'message', role: 'user', content: 'hello' },
|
||||
];
|
||||
const result = filterOpenCodeSystemPrompts(input);
|
||||
const result = await filterOpenCodeSystemPrompts(input);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].role).toBe('user');
|
||||
});
|
||||
|
||||
it('should keep user messages', () => {
|
||||
it('should keep user messages', async () => {
|
||||
const input: InputItem[] = [
|
||||
{ type: 'message', role: 'user', content: 'message 1' },
|
||||
{ type: 'message', role: 'user', content: 'message 2' },
|
||||
];
|
||||
const result = filterOpenCodeSystemPrompts(input);
|
||||
const result = await filterOpenCodeSystemPrompts(input);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should keep non-OpenCode developer messages', () => {
|
||||
it('should keep non-OpenCode developer messages', async () => {
|
||||
const input: InputItem[] = [
|
||||
{ type: 'message', role: 'developer', content: 'Custom instruction' },
|
||||
{ type: 'message', role: 'user', content: 'hello' },
|
||||
];
|
||||
const result = filterOpenCodeSystemPrompts(input);
|
||||
const result = await filterOpenCodeSystemPrompts(input);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return undefined for undefined input', () => {
|
||||
expect(filterOpenCodeSystemPrompts(undefined)).toBeUndefined();
|
||||
it('should keep AGENTS.md content (not filter it)', async () => {
|
||||
const input: InputItem[] = [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
content: 'You are a coding agent running in OpenCode', // This is codex.txt
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
content: '# Project Guidelines\n\nThis is AGENTS.md content.', // This is AGENTS.md
|
||||
},
|
||||
{ type: 'message', role: 'user', content: 'hello' },
|
||||
];
|
||||
const result = await filterOpenCodeSystemPrompts(input);
|
||||
// Should filter codex.txt but keep AGENTS.md
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result![0].content).toContain('AGENTS.md');
|
||||
expect(result![1].role).toBe('user');
|
||||
});
|
||||
|
||||
it('should keep environment+AGENTS.md concatenated message', async () => {
|
||||
const input: InputItem[] = [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
content: 'You are a coding agent running in OpenCode', // codex.txt alone
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'developer',
|
||||
// environment + AGENTS.md joined (like OpenCode does)
|
||||
content: 'Working directory: /path/to/project\nDate: 2025-01-01\n\n# AGENTS.md\n\nCustom instructions.',
|
||||
},
|
||||
{ type: 'message', role: 'user', content: 'hello' },
|
||||
];
|
||||
const result = await filterOpenCodeSystemPrompts(input);
|
||||
// Should filter first message (codex.txt) but keep second (env+AGENTS.md)
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result![0].content).toContain('AGENTS.md');
|
||||
expect(result![1].role).toBe('user');
|
||||
});
|
||||
|
||||
it('should return undefined for undefined input', async () => {
|
||||
expect(await filterOpenCodeSystemPrompts(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('addCodexBridgeMessage', () => {
|
||||
it('should prepend bridge message when tools present', () => {
|
||||
it('should prepend bridge message when tools present', async () => {
|
||||
const input: InputItem[] = [
|
||||
{ type: 'message', role: 'user', content: 'hello' },
|
||||
];
|
||||
@@ -217,7 +300,7 @@ describe('Request Transformer Module', () => {
|
||||
expect((result![0].content as any)[0].text).toContain('Codex Running in OpenCode');
|
||||
});
|
||||
|
||||
it('should not modify input when tools not present', () => {
|
||||
it('should not modify input when tools not present', async () => {
|
||||
const input: InputItem[] = [
|
||||
{ type: 'message', role: 'user', content: 'hello' },
|
||||
];
|
||||
@@ -225,7 +308,7 @@ describe('Request Transformer Module', () => {
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should return undefined for undefined input', () => {
|
||||
it('should return undefined for undefined input', async () => {
|
||||
expect(addCodexBridgeMessage(undefined, true)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -233,39 +316,39 @@ describe('Request Transformer Module', () => {
|
||||
describe('transformRequestBody', () => {
|
||||
const codexInstructions = 'Test Codex Instructions';
|
||||
|
||||
it('should set required Codex fields', () => {
|
||||
it('should set required Codex fields', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions);
|
||||
const result = await transformRequestBody(body, codexInstructions);
|
||||
|
||||
expect(result.store).toBe(false);
|
||||
expect(result.stream).toBe(true);
|
||||
expect(result.instructions).toBe(codexInstructions);
|
||||
});
|
||||
|
||||
it('should normalize model name', () => {
|
||||
it('should normalize model name', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5-mini',
|
||||
input: [],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions);
|
||||
const result = await transformRequestBody(body, codexInstructions);
|
||||
expect(result.model).toBe('gpt-5');
|
||||
});
|
||||
|
||||
it('should apply default reasoning config', () => {
|
||||
it('should apply default reasoning config', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions);
|
||||
const result = await transformRequestBody(body, codexInstructions);
|
||||
|
||||
expect(result.reasoning?.effort).toBe('medium');
|
||||
expect(result.reasoning?.summary).toBe('auto');
|
||||
});
|
||||
|
||||
it('should apply user reasoning config', () => {
|
||||
it('should apply user reasoning config', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [],
|
||||
@@ -277,22 +360,22 @@ describe('Request Transformer Module', () => {
|
||||
},
|
||||
models: {},
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions, userConfig);
|
||||
const result = await transformRequestBody(body, codexInstructions, userConfig);
|
||||
|
||||
expect(result.reasoning?.effort).toBe('high');
|
||||
expect(result.reasoning?.summary).toBe('detailed');
|
||||
});
|
||||
|
||||
it('should apply default text verbosity', () => {
|
||||
it('should apply default text verbosity', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions);
|
||||
const result = await transformRequestBody(body, codexInstructions);
|
||||
expect(result.text?.verbosity).toBe('medium');
|
||||
});
|
||||
|
||||
it('should apply user text verbosity', () => {
|
||||
it('should apply user text verbosity', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [],
|
||||
@@ -301,20 +384,20 @@ describe('Request Transformer Module', () => {
|
||||
global: { textVerbosity: 'low' },
|
||||
models: {},
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions, userConfig);
|
||||
const result = await transformRequestBody(body, codexInstructions, userConfig);
|
||||
expect(result.text?.verbosity).toBe('low');
|
||||
});
|
||||
|
||||
it('should set default include for encrypted reasoning', () => {
|
||||
it('should set default include for encrypted reasoning', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions);
|
||||
const result = await transformRequestBody(body, codexInstructions);
|
||||
expect(result.include).toEqual(['reasoning.encrypted_content']);
|
||||
});
|
||||
|
||||
it('should use user-configured include', () => {
|
||||
it('should use user-configured include', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [],
|
||||
@@ -323,11 +406,11 @@ describe('Request Transformer Module', () => {
|
||||
global: { include: ['custom_field', 'reasoning.encrypted_content'] },
|
||||
models: {},
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions, userConfig);
|
||||
const result = await transformRequestBody(body, codexInstructions, userConfig);
|
||||
expect(result.include).toEqual(['custom_field', 'reasoning.encrypted_content']);
|
||||
});
|
||||
|
||||
it('should filter input array', () => {
|
||||
it('should filter input array', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
@@ -335,43 +418,43 @@ describe('Request Transformer Module', () => {
|
||||
{ type: 'message', role: 'user', content: 'new' },
|
||||
],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions);
|
||||
const result = await transformRequestBody(body, codexInstructions);
|
||||
expect(result.input).toHaveLength(1);
|
||||
expect(result.input![0].content).toBe('new');
|
||||
});
|
||||
|
||||
it('should add tool remap message when tools present', () => {
|
||||
it('should add tool remap message when tools present', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [{ type: 'message', role: 'user', content: 'hello' }],
|
||||
tools: [{ name: 'test_tool' }],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions);
|
||||
const result = await transformRequestBody(body, codexInstructions);
|
||||
expect(result.input![0].role).toBe('developer');
|
||||
});
|
||||
|
||||
it('should not add tool remap message when tools absent', () => {
|
||||
it('should not add tool remap message when tools absent', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [{ type: 'message', role: 'user', content: 'hello' }],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions);
|
||||
const result = await transformRequestBody(body, codexInstructions);
|
||||
expect(result.input![0].role).toBe('user');
|
||||
});
|
||||
|
||||
it('should remove unsupported parameters', () => {
|
||||
it('should remove unsupported parameters', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [],
|
||||
max_output_tokens: 1000,
|
||||
max_completion_tokens: 2000,
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions);
|
||||
const result = await transformRequestBody(body, codexInstructions);
|
||||
expect(result.max_output_tokens).toBeUndefined();
|
||||
expect(result.max_completion_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should normalize minimal to low for gpt-5-codex', () => {
|
||||
it('should normalize minimal to low for gpt-5-codex', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5-codex',
|
||||
input: [],
|
||||
@@ -380,11 +463,11 @@ describe('Request Transformer Module', () => {
|
||||
global: { reasoningEffort: 'minimal' },
|
||||
models: {},
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions, userConfig);
|
||||
const result = await transformRequestBody(body, codexInstructions, userConfig);
|
||||
expect(result.reasoning?.effort).toBe('low');
|
||||
});
|
||||
|
||||
it('should preserve minimal for non-codex models', () => {
|
||||
it('should preserve minimal for non-codex models', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [],
|
||||
@@ -393,34 +476,34 @@ describe('Request Transformer Module', () => {
|
||||
global: { reasoningEffort: 'minimal' },
|
||||
models: {},
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions, userConfig);
|
||||
const result = await transformRequestBody(body, codexInstructions, userConfig);
|
||||
expect(result.reasoning?.effort).toBe('minimal');
|
||||
});
|
||||
|
||||
it('should use minimal effort for lightweight models', () => {
|
||||
it('should use minimal effort for lightweight models', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5-nano',
|
||||
input: [],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions);
|
||||
const result = await transformRequestBody(body, codexInstructions);
|
||||
expect(result.reasoning?.effort).toBe('minimal');
|
||||
});
|
||||
|
||||
describe('CODEX_MODE parameter', () => {
|
||||
it('should use bridge message when codexMode=true and tools present (default)', () => {
|
||||
it('should use bridge message when codexMode=true and tools present (default)', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [{ type: 'message', role: 'user', content: 'hello' }],
|
||||
tools: [{ name: 'test_tool' }],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions, undefined, true);
|
||||
const result = await transformRequestBody(body, codexInstructions, undefined, true);
|
||||
|
||||
expect(result.input).toHaveLength(2);
|
||||
expect(result.input![0].role).toBe('developer');
|
||||
expect((result.input![0].content as any)[0].text).toContain('Codex Running in OpenCode');
|
||||
});
|
||||
|
||||
it('should filter OpenCode prompts when codexMode=true', () => {
|
||||
it('should filter OpenCode prompts when codexMode=true', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
@@ -433,7 +516,7 @@ describe('Request Transformer Module', () => {
|
||||
],
|
||||
tools: [{ name: 'test_tool' }],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions, undefined, true);
|
||||
const result = await transformRequestBody(body, codexInstructions, undefined, true);
|
||||
|
||||
// Should have bridge message + user message (OpenCode prompt filtered out)
|
||||
expect(result.input).toHaveLength(2);
|
||||
@@ -442,31 +525,31 @@ describe('Request Transformer Module', () => {
|
||||
expect(result.input![1].role).toBe('user');
|
||||
});
|
||||
|
||||
it('should not add bridge message when codexMode=true but no tools', () => {
|
||||
it('should not add bridge message when codexMode=true but no tools', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [{ type: 'message', role: 'user', content: 'hello' }],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions, undefined, true);
|
||||
const result = await transformRequestBody(body, codexInstructions, undefined, true);
|
||||
|
||||
expect(result.input).toHaveLength(1);
|
||||
expect(result.input![0].role).toBe('user');
|
||||
});
|
||||
|
||||
it('should use tool remap message when codexMode=false', () => {
|
||||
it('should use tool remap message when codexMode=false', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [{ type: 'message', role: 'user', content: 'hello' }],
|
||||
tools: [{ name: 'test_tool' }],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions, undefined, false);
|
||||
const result = await transformRequestBody(body, codexInstructions, undefined, false);
|
||||
|
||||
expect(result.input).toHaveLength(2);
|
||||
expect(result.input![0].role).toBe('developer');
|
||||
expect((result.input![0].content as any)[0].text).toContain('apply_patch');
|
||||
});
|
||||
|
||||
it('should not filter OpenCode prompts when codexMode=false', () => {
|
||||
it('should not filter OpenCode prompts when codexMode=false', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
@@ -479,7 +562,7 @@ describe('Request Transformer Module', () => {
|
||||
],
|
||||
tools: [{ name: 'test_tool' }],
|
||||
};
|
||||
const result = transformRequestBody(body, codexInstructions, undefined, false);
|
||||
const result = await transformRequestBody(body, codexInstructions, undefined, false);
|
||||
|
||||
// Should have tool remap + opencode prompt + user message
|
||||
expect(result.input).toHaveLength(3);
|
||||
@@ -489,14 +572,14 @@ describe('Request Transformer Module', () => {
|
||||
expect(result.input![2].role).toBe('user');
|
||||
});
|
||||
|
||||
it('should default to codexMode=true when parameter not provided', () => {
|
||||
it('should default to codexMode=true when parameter not provided', async () => {
|
||||
const body: RequestBody = {
|
||||
model: 'gpt-5',
|
||||
input: [{ type: 'message', role: 'user', content: 'hello' }],
|
||||
tools: [{ name: 'test_tool' }],
|
||||
};
|
||||
// Not passing codexMode parameter - should default to true
|
||||
const result = transformRequestBody(body, codexInstructions);
|
||||
const result = await transformRequestBody(body, codexInstructions);
|
||||
|
||||
// Should use bridge message (codexMode=true by default)
|
||||
expect(result.input![0].role).toBe('developer');
|
||||
|
||||
Reference in New Issue
Block a user