feat: Major TypeScript rewrite with enhanced configuration and OAuth UI

## 🎯 Overview

Complete TypeScript rewrite with comprehensive testing, modular architecture,
and enhanced user experience. Adds 9 pre-configured model variants matching
Codex CLI presets and a beautiful OAuth success page.

## ✨ New Features

### Configuration System
- **9 Model Variants**: Pre-configured reasoning levels (Low/Medium/High) for
  both gpt-5-codex and gpt-5, plus Minimal variants
- **Full Config Example**: `config/full-opencode.json` with all variants ready
  to use
- **Minimal Config Example**: `config/minimal-opencode.json` for basic setup
- **Config Documentation**: Comprehensive guide in `config/README.md`

### OAuth Enhancement
- **Animated Success Page**: Beautiful terminal-style UI with matrix rain
  effect, animated checkmark, and glowing effects
- **Interactive Elements**: Mouse-responsive matrix rain and parallax effects
- **Professional Branding**: OpenCode logo with pulse animations

### Developer Experience
- **TypeScript Rewrite**: Complete migration from .mjs to .ts with strict typing
- **93 Comprehensive Tests**: Full test coverage using Vitest
- **Modular Architecture**: 10 focused helper functions, each < 40 lines
- **Centralized Constants**: All magic values in `lib/constants.ts`
- **CI/CD Pipeline**: GitHub Actions workflow for automated testing

### Documentation
- **AGENTS.md**: Complete guide for AI assistants maintaining the codebase
- **Enhanced README**: Updated with new configuration options and examples
- **Inline JSDoc**: Comprehensive documentation for all public functions

## 🏗️ Technical Changes

### Architecture Improvements
- Extracted 10 focused helper functions from monolithic fetch()
- Centralized all constants and error messages
- Separated concerns: auth, browser, codex, server, etc.
- Type-safe discriminated unions for result types

### Build System
- TypeScript compilation with declarations and source maps
- Automatic HTML asset copying in build process
- Test files excluded from distribution bundle
- Clean dist/ output with only production code

### Testing Infrastructure
- Vitest test runner with UI mode
- 93 tests across 7 test files
- Mock-based testing for external dependencies
- Coverage reporting capability

## 📁 New Structure

```
opencode-openai-codex-auth/
├── index.ts                     # Main plugin (TypeScript)
├── lib/
│   ├── auth.ts                 # OAuth logic
│   ├── browser.ts              # Platform-specific browser opening
│   ├── codex.ts                # Codex instructions
│   ├── server.ts               # OAuth callback server
│   ├── fetch-helpers.ts        # 10 focused helpers
│   ├── constants.ts            # All magic values
│   ├── types.ts                # TypeScript types
│   ├── oauth-success.html      # Animated success page
│   └── ...
├── config/
│   ├── full-opencode.json      # Complete config with variants
│   ├── minimal-opencode.json   # Basic config
│   └── README.md               # Config docs
├── test/                        # 93 comprehensive tests
├── AGENTS.md                    # AI maintenance guide
└── tsconfig.json               # TypeScript config
```

## 🔄 Migration Notes

### Breaking Changes
- Plugin now requires TypeScript compilation (dist/ folder)
- Configuration structure enhanced (backward compatible)
- OAuth success page replaces plain HTML response

### Upgrade Path
1. Use new config from `config/full-opencode.json` for best experience
2. Or continue with minimal config (uses plugin defaults)
3. Rebuild plugin: `npm run build`

## 📊 Metrics

- **Lines of Code**: Modular design with functions < 70 lines
- **Test Coverage**: 93 tests covering all functionality
- **Type Safety**: Strict TypeScript with no `any` types
- **Dependencies**: Still minimal (only @openauthjs/openauth)
- **Model Variants**: 9 pre-configured options for users

## 🎨 User Experience

- **Codex CLI Parity**: Match official CLI reasoning presets
- **Beautiful OAuth**: Professional animated success page
- **Clear Documentation**: Step-by-step guides and examples
- **Flexible Config**: From minimal to comprehensive setups

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
Numman Ali
2025-10-03 21:51:59 +01:00
co-authored by Hanzo Dev
parent 67fd71abf5
commit 7dae285752
34 changed files with 3611 additions and 610 deletions
+58
View File
@@ -0,0 +1,58 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
name: Test on Node.js ${{ matrix.node-version }}
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run type check
run: npm run typecheck
- name: Run tests
run: npm test
- name: Build
run: npm run build
lint:
name: Lint and Format Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run type check
run: npm run typecheck
+1 -1
View File
@@ -4,4 +4,4 @@ pnpm-lock.yaml
package-lock.json
dist/
.DS_Store
.history/
+567
View File
@@ -0,0 +1,567 @@
# AI Agent Maintenance Guide
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.
## 🎯 Core Philosophy
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
## 📁 Codebase Structure
### 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
```bash
npm test # Run all tests
npm test -- --watch # Watch mode
npm test -- --ui # Visual UI
npm test -- --coverage # Coverage report
```
**Requirement**: All 93 tests must pass before committing.
## 📝 Code Patterns to Follow
### 1. Function Naming
✅ **Good** - Verb + Noun, describes what it does:
```typescript
shouldRefreshToken()
createCodexHeaders()
extractRequestUrl()
rewriteUrlForCodex()
handleErrorResponse()
```
❌ **Bad** - Vague or unclear:
```typescript
checkToken() // Check what about token?
makeHeaders() // Make what kind?
getUrl() // Get from where?
```
### 2. JSDoc Comments
**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
}
```
### 3. Error Handling
**Pattern**: Return result objects instead of throwing
```typescript
// ✅ Good - Discriminated union return type
type Result =
| { success: true; data: Data }
| { success: false; error: Error };
export function doSomething(): Result {
if (errorCondition) {
return { success: false, error: new Error("message") };
}
return { success: true, data: result };
}
// Usage
const result = doSomething();
if (!result.success) {
return result.error;
}
// Use result.data
```
### 4. Logging
**Pattern**: Use constants and plugin name prefix
```typescript
import { PLUGIN_NAME, ERROR_MESSAGES, LOG_STAGES } from "./constants.js";
// ✅ Good
console.error(`[${PLUGIN_NAME}] ${ERROR_MESSAGES.TOKEN_REFRESH_FAILED}`);
logRequest(LOG_STAGES.RESPONSE, data);
// ❌ Bad
console.error("Failed to refresh token");
console.log("Response:", data);
```
### 5. Type Definitions
**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";
// ❌ Bad
import { Auth } from "@opencode-ai/sdk"; // Runtime import when only type is needed
```
### 6. Constants
**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()`
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
### Task: Change How URLs are Rewritten
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
### Task: Improve Error Messages
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
## ⚠️ Critical Rules
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`
## 📊 Quality Checklist
Before completing any task:
- [ ] 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
## 🎓 Understanding the Architecture
### Why 10 Helper Functions?
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
### Why Centralized Constants?
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
### Why Discriminated Unions for Results?
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
### Why Separate Test Files?
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
## 🔮 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.
+244 -119
View File
@@ -10,14 +10,16 @@ Follow me on [X @nummanthinks](https://x.com/nummanthinks) for future updates an
## Features
- ✅ ChatGPT Plus/Pro OAuth authentication
- ✅ **ChatGPT Plus/Pro OAuth authentication** - Use your existing subscription
- ✅ **9 pre-configured model variants** - Low/Medium/High reasoning for both gpt-5 and gpt-5-codex
- ✅ **Zero external dependencies** - Lightweight with only @openauthjs/openauth
- ✅ **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, etc.)
- ✅ Automatic tool remapping (Codex tools → opencode tools)
- ✅ Configurable reasoning effort and summaries (defaults: medium/auto)
- ✅ Modular architecture for easy maintenance
- ✅ **Full tool support** - write, edit, bash, grep, glob, and more
- ✅ **Automatic tool remapping** - Codex tools → opencode tools
- ✅ **Configurable reasoning** - Control effort, summary verbosity, and text output
- ✅ **Type-safe & tested** - Strict TypeScript with 93 comprehensive tests
- ✅ **Modular architecture** - Easy to maintain and extend
## Installation
@@ -25,27 +27,136 @@ Follow me on [X @nummanthinks](https://x.com/nummanthinks) for future updates an
**No npm install needed!** opencode automatically installs plugins when you add them to your config.
1. **Add plugin to your opencode configuration**:
#### Recommended: Full Configuration (Codex CLI Experience)
Edit your `opencode.json` file (create it if it doesn't exist):
For the complete experience with all reasoning variants matching the official Codex CLI:
1. **Copy the full configuration** from [`config/full-opencode.json`](./config/full-opencode.json) to your opencode config file:
```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"opencode-openai-codex-auth"
],
"provider": {
"openai": {
"options": {
"reasoningEffort": "medium",
"reasoningSummary": "auto",
"textVerbosity": "medium",
"include": [
"reasoning.encrypted_content"
]
},
"models": {
"GPT 5 Codex Low (ChatGPT Subscription)": {
"id": "gpt-5-codex",
"options": {
"reasoningEffort": "low",
"reasoningSummary": "auto",
"textVerbosity": "medium"
}
},
"GPT 5 Codex Medium (ChatGPT Subscription)": {
"id": "gpt-5-codex",
"options": {
"reasoningEffort": "medium",
"reasoningSummary": "auto",
"textVerbosity": "medium"
}
},
"GPT 5 Codex High (ChatGPT Subscription)": {
"id": "gpt-5-codex",
"options": {
"reasoningEffort": "high",
"reasoningSummary": "detailed",
"textVerbosity": "medium"
}
},
"GPT 5 Minimal (ChatGPT Subscription)": {
"id": "gpt-5",
"options": {
"reasoningEffort": "minimal",
"reasoningSummary": "auto",
"textVerbosity": "low"
}
},
"GPT 5 Low (ChatGPT Subscription)": {
"id": "gpt-5",
"options": {
"reasoningEffort": "low",
"reasoningSummary": "auto",
"textVerbosity": "low"
}
},
"GPT 5 Medium (ChatGPT Subscription)": {
"id": "gpt-5",
"options": {
"reasoningEffort": "medium",
"reasoningSummary": "auto",
"textVerbosity": "medium"
}
},
"GPT 5 High (ChatGPT Subscription)": {
"id": "gpt-5",
"options": {
"reasoningEffort": "high",
"reasoningSummary": "detailed",
"textVerbosity": "high"
}
},
"gpt-5-mini": {
"options": {
"reasoningEffort": "low",
"reasoningSummary": "auto",
"textVerbosity": "low"
}
},
"gpt-5-nano": {
"options": {
"reasoningEffort": "minimal",
"reasoningSummary": "auto",
"textVerbosity": "low"
}
}
}
}
}
}
```
**Global config**: `~/.config/opencode/opencode.json`
**Project config**: `<project>/.opencode.json`
```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"opencode-openai-codex-auth"
],
"model": "openai/gpt-5-codex"
}
```
This gives you 9 model variants with different reasoning levels:
- **GPT 5 Codex** (Low/Medium/High)
- **GPT 5** (Minimal/Low/Medium/High)
- **gpt-5-mini** and **gpt-5-nano** (these are to support the default opencode model selector)
All appear in the opencode model selector with optimal settings for each reasoning level.
> **Optional**: You can remove `(ChatGPT Subscription)` from model names if you prefer cleaner labels in the model selector.
#### Alternative: Minimal Configuration
For a simpler setup (uses plugin defaults: medium reasoning, auto summaries):
```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"opencode-openai-codex-auth"
],
"model": "openai/gpt-5-codex"
}
```
**Note**: This gives you basic functionality but you won't see the different reasoning variants in the model selector.
2. **That's it!** opencode will auto-install the plugin on first run.
> **Note on Updates**: opencode does NOT automatically update plugins to new versions. To update:
> - Pin to a specific version: `"opencode-openai-codex-auth@1.0.3"` and change the number when updating
> **Note on Updates**: Sometimes opencode does NOT automatically update plugins to new versions. To update:
> - Pin to a specific version: `"[email protected].4"` and change the number when updating
> - Or clear the plugin cache: `rm -rf ~/.cache/opencode/node_modules/opencode-openai-codex-auth`
>
> Check [releases](https://github.com/numman-ali/opencode-openai-codex-auth/releases) for the latest version.
@@ -81,17 +192,40 @@ Select "OpenAI" and choose:
## Usage
If using the full configuration, select from the model picker in opencode, or specify via command line:
```bash
# Use gpt-5-codex with plugin defaults (medium/auto/medium)
# Use different reasoning levels for gpt-5-codex
opencode run "simple task" --model="openai/GPT 5 Codex Low (ChatGPT Subscription)"
opencode run "complex task" --model="openai/GPT 5 Codex High (ChatGPT Subscription)"
# Use different reasoning levels for gpt-5
opencode run "quick question" --model="openai/GPT 5 Minimal (ChatGPT Subscription)"
opencode run "deep analysis" --model="openai/GPT 5 High (ChatGPT Subscription)"
# Or with minimal config (uses defaults)
opencode run "create a hello world file" --model=openai/gpt-5-codex
# Or use regular gpt-5 via ChatGPT subscription
opencode run "solve this complex problem" --model=openai/gpt-5
# Set as default model in opencode.json
opencode run "build a web app"
```
### Available Model Variants (Full Config)
When using [`config/full-opencode.json`](./config/full-opencode.json), you get these pre-configured variants:
| Model Variant | Reasoning Effort | Best For |
|---------------|-----------------|----------|
| **GPT 5 Codex Low** | Low | Fast code generation |
| **GPT 5 Codex Medium** | Medium | Balanced code tasks |
| **GPT 5 Codex High** | High | Complex code & tools |
| **GPT 5 Minimal** | Minimal | Quick answers, simple tasks |
| **GPT 5 Low** | Low | Faster responses with light reasoning |
| **GPT 5 Medium** | Medium | Balanced general-purpose tasks |
| **GPT 5 High** | High | Deep reasoning, complex problems |
| **gpt-5-mini** | Low | Lightweight tasks |
| **gpt-5-nano** | Minimal | Maximum speed |
All accessed via your ChatGPT Plus/Pro subscription.
### Plugin Defaults
When no configuration is specified, the plugin uses these defaults for all GPT-5 models:
@@ -112,41 +246,35 @@ These defaults match the official Codex CLI behavior and can be customized (see
## Configuration
You can customize model behavior for both `gpt-5` and `gpt-5-codex` models accessed via ChatGPT subscription.
### Recommended: Use Pre-Configured File
### Available Settings
The easiest way to get started is to use [`config/full-opencode.json`](./config/full-opencode.json), which provides:
- 9 pre-configured model variants matching Codex CLI presets
- Optimal settings for each reasoning level
- All variants visible in the opencode model selector
⚠️ **Important**: The two models have different supported values. Only use values listed in the tables below to avoid API errors.
See [Installation](#installation) for setup instructions.
#### GPT-5 Model
### Custom Configuration
| Setting | Supported Values | Plugin Default | Description |
|---------|-----------------|----------------|-------------|
| `reasoningEffort` | `minimal`, `low`, `medium`, `high` | **`medium`** | Computational effort for reasoning |
| `reasoningSummary` | `auto`, `detailed` | **`auto`** | Verbosity of reasoning summaries |
| `textVerbosity` | `low`, `medium`, `high` | **`medium`** | Output length and detail level |
If you want to customize settings yourself, you can configure options at provider or model level.
#### GPT-5-Codex Model
#### Available Settings
| Setting | Supported Values | Plugin Default | Description |
|---------|-----------------|----------------|-------------|
| `reasoningEffort` | `minimal`*, `low`, `medium`, `high` | **`medium`** | Computational effort for reasoning |
| `reasoningSummary` | `auto`, `detailed` | **`auto`** | Verbosity of reasoning summaries |
| `textVerbosity` | `medium` only | **`medium`** | Output length (codex only supports medium) |
⚠️ **Important**: The two base models have different supported values.
\* `minimal` is auto-normalized to `low` for gpt-5-codex
| Setting | GPT-5 Values | GPT-5-Codex Values | Plugin Default |
|---------|-------------|-------------------|----------------|
| `reasoningEffort` | `minimal`, `low`, `medium`, `high` | `low`, `medium`, `high` | `medium` |
| `reasoningSummary` | `auto`, `detailed` | `auto`, `detailed` | `auto` |
| `textVerbosity` | `low`, `medium`, `high` | `medium` only | `medium` |
| `include` | Array of strings | Array of strings | `["reasoning.encrypted_content"]` |
#### Shared Settings (Both Models)
> **Note**: `minimal` effort is auto-normalized to `low` for gpt-5-codex (not supported by the API).
| Setting | Values | Plugin Default | Description |
|---------|--------|----------------|-------------|
| `include` | Array of strings | `["reasoning.encrypted_content"]` | Additional response fields (for stateless reasoning) |
#### Global Configuration Example
### Configuration Examples
#### Global Configuration
Apply the same settings to all GPT-5 models:
Apply settings to all models:
```json
{
@@ -157,17 +285,16 @@ Apply the same settings to all GPT-5 models:
"openai": {
"options": {
"reasoningEffort": "high",
"reasoningSummary": "detailed",
"textVerbosity": "medium"
"reasoningSummary": "detailed"
}
}
}
}
```
#### Per-Model Configuration
#### Custom Model Variants Example
Different settings for different models:
Create your own named variants in the model selector:
```json
{
@@ -176,18 +303,17 @@ Different settings for different models:
"provider": {
"openai": {
"models": {
"gpt-5-codex": {
"My Fast Codex": {
"id": "gpt-5-codex",
"options": {
"reasoningEffort": "high",
"reasoningSummary": "detailed",
"textVerbosity": "medium"
"reasoningEffort": "low"
}
},
"gpt-5": {
"My Smart GPT-5": {
"id": "gpt-5",
"options": {
"reasoningEffort": "high",
"reasoningSummary": "detailed",
"textVerbosity": "low"
"textVerbosity": "high"
}
}
}
@@ -196,58 +322,43 @@ Different settings for different models:
}
```
#### Mixed Configuration
The `id` field determines which base model is used (`gpt-5-codex` or `gpt-5`), and the name is what appears in the model selector.
Global defaults with per-model overrides:
### Configuration Files
```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-openai-codex-auth"],
"model": "openai/gpt-5-codex",
"provider": {
"openai": {
"options": {
"reasoningEffort": "medium",
"reasoningSummary": "auto",
"textVerbosity": "medium"
},
"models": {
"gpt-5-codex": {
"options": {
"reasoningSummary": "detailed"
}
}
}
}
}
}
```
This repository includes ready-to-use configuration examples:
In this example:
- `gpt-5-codex` uses: `reasoningEffort: "medium"`, `reasoningSummary: "detailed"` (overridden), `textVerbosity: "medium"`
- `gpt-5` uses all global defaults: `reasoningEffort: "medium"`, `reasoningSummary: "auto"`, `textVerbosity: "medium"`
- **[`config/full-opencode.json`](./config/full-opencode.json)** - Complete configuration with 9 model variants (recommended)
- **[`config/minimal-opencode.json`](./config/minimal-opencode.json)** - Minimal configuration for basic usage
- **[`config/README.md`](./config/README.md)** - Detailed configuration documentation
Copy the appropriate file to your opencode configuration location and customize as needed.
## How It Works
The plugin:
The plugin implements a 7-step fetch flow in TypeScript:
1. **Authentication**: Uses ChatGPT OAuth flow with PKCE for secure authentication
2. **Token Management**: Native token refresh implementation (no external dependencies)
3. **Codex Instructions**: Automatically fetches from the latest stable release of [openai/codex](https://github.com/openai/codex)
- Tracks latest release tag (not main branch) for stability
- Uses ETag-based caching for efficient updates (only downloads when content changes)
- Cached locally in `~/.opencode/cache/`
- Auto-updates when OpenAI publishes new releases
- Falls back to bundled version if GitHub is unavailable
4. **Request Transformation**: Routes requests to `https://chatgpt.com/backend-api/codex/responses`
5. **Model Normalization**: Maps all model names to `gpt-5-codex` (the Codex backend model)
6. **Tool Remapping**: Injects instructions to map Codex tools to opencode tools:
- `apply_patch` → `edit`
- `update_plan` → `todowrite`
7. **Reasoning Configuration**: Defaults to medium effort and auto summaries (configurable per-model)
8. **Encrypted Reasoning**: Includes encrypted reasoning content for stateless multi-turn conversations
9. **History Filtering**: Removes stored conversation IDs since Codex uses `store: false`
1. **Token Management**: Checks and refreshes OAuth tokens if needed
2. **URL Rewriting**: Transforms requests to `https://chatgpt.com/backend-api/codex/responses`
3. **Request Transformation**:
- 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 tool remapping instructions (`apply_patch` → `edit`, `update_plan` → `todowrite`)
- 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
6. **Response Logging**: Optional debug logging (when `ENABLE_PLUGIN_REQUEST_LOGGING=1`)
7. **Response Handling**: Converts SSE to JSON or returns error details
### Key Features
- **Modular Design**: 10 focused helper functions, each < 40 lines
- **Type-Safe**: Strict TypeScript with comprehensive type definitions
- **Tested**: 93 tests covering all functionality
- **Zero Dependencies**: Only uses @openauthjs/openauth
- **Codex Instructions**: ETag-cached from GitHub, auto-updates on new releases
- **Stateless Operation**: Uses `store: false` with encrypted reasoning content for multi-turn conversations
## Limitations
@@ -315,14 +426,24 @@ Each request generates 3-4 JSON files:
```
opencode-openai-codex-auth/
├── index.mjs # Main plugin entry point
├── index.ts # Main plugin entry point
├── lib/
│ ├── auth.mjs # OAuth authentication logic
│ ├── codex.mjs # Codex instructions & tool remapping
│ ├── server.mjs # Local OAuth callback server
│ ├── logger.mjs # Request logging (debug mode)
│ ├── request-transformer.mjs # Request body transformations
│ └── response-handler.mjs # SSE to JSON conversion
│ ├── 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
├── 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
├── package.json
├── README.md
└── LICENSE
@@ -330,13 +451,17 @@ opencode-openai-codex-auth/
### Module Overview
- **index.mjs**: Main plugin export and request orchestration
- **lib/auth.mjs**: OAuth flow, PKCE, token exchange, JWT decoding
- **lib/codex.mjs**: Fetches/caches Codex instructions from GitHub, tool remapping
- **lib/server.mjs**: Local HTTP server for OAuth callback handling
- **lib/logger.mjs**: Debug logging functionality (controlled by environment variable)
- **lib/request-transformer.mjs**: Request body transformations (model normalization, tool remapping, reasoning config)
- **lib/response-handler.mjs**: Response handling (SSE to JSON conversion for generateText())
- **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/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
## Credits
+18
View File
@@ -0,0 +1,18 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+43
View File
@@ -0,0 +1,43 @@
# Configuration Examples
This directory contains example opencode configuration files for the OpenAI Codex OAuth plugin.
## Files
### minimal-opencode.json
The simplest possible configuration using plugin defaults.
```bash
cp config/minimal-opencode.json ~/.config/opencode/opencode.json
```
This uses default settings:
- `reasoningEffort`: "medium"
- `reasoningSummary`: "auto"
- `textVerbosity`: "medium"
- `include`: ["reasoning.encrypted_content"]
### full-opencode.json
Complete configuration example showing all model variants with custom settings.
```bash
cp config/full-opencode.json ~/.config/opencode/opencode.json
```
This demonstrates:
- Global options for all models
- Per-model configuration overrides
- All supported model variants (gpt-5-codex, gpt-5, gpt-5-mini, gpt-5-nano)
## Usage
1. Choose a configuration file based on your needs
2. Copy it to your opencode config directory:
- Global: `~/.config/opencode/opencode.json`
- Project: `<project>/.opencode.json`
3. Modify settings as needed
4. Run opencode: `opencode run "your prompt"`
## Configuration Options
See the main [README.md](../README.md#configuration) for detailed documentation of all configuration options.
+90
View File
@@ -0,0 +1,90 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"opencode-openai-codex-auth"
],
"provider": {
"openai": {
"options": {
"reasoningEffort": "medium",
"reasoningSummary": "auto",
"textVerbosity": "medium",
"include": [
"reasoning.encrypted_content"
]
},
"models": {
"GPT 5 Codex Low (ChatGPT Subscription)": {
"id": "gpt-5-codex",
"options": {
"reasoningEffort": "low",
"reasoningSummary": "auto",
"textVerbosity": "medium"
}
},
"GPT 5 Codex Medium (ChatGPT Subscription)": {
"id": "gpt-5-codex",
"options": {
"reasoningEffort": "medium",
"reasoningSummary": "auto",
"textVerbosity": "medium"
}
},
"GPT 5 Codex High (ChatGPT Subscription)": {
"id": "gpt-5-codex",
"options": {
"reasoningEffort": "high",
"reasoningSummary": "detailed",
"textVerbosity": "medium"
}
},
"GPT 5 Minimal (ChatGPT Subscription)": {
"id": "gpt-5",
"options": {
"reasoningEffort": "minimal",
"reasoningSummary": "auto",
"textVerbosity": "low"
}
},
"GPT 5 Low (ChatGPT Subscription)": {
"id": "gpt-5",
"options": {
"reasoningEffort": "low",
"reasoningSummary": "auto",
"textVerbosity": "low"
}
},
"GPT 5 Medium (ChatGPT Subscription)": {
"id": "gpt-5",
"options": {
"reasoningEffort": "medium",
"reasoningSummary": "auto",
"textVerbosity": "medium"
}
},
"GPT 5 High (ChatGPT Subscription)": {
"id": "gpt-5",
"options": {
"reasoningEffort": "high",
"reasoningSummary": "detailed",
"textVerbosity": "high"
}
},
"gpt-5-mini": {
"options": {
"reasoningEffort": "low",
"reasoningSummary": "auto",
"textVerbosity": "low"
}
},
"gpt-5-nano": {
"options": {
"reasoningEffort": "minimal",
"reasoningSummary": "auto",
"textVerbosity": "low"
}
}
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-openai-codex-auth"],
"model": "openai/gpt-5-codex"
}
-264
View File
@@ -1,264 +0,0 @@
import { spawn } from "node:child_process";
import {
createAuthorizationFlow,
exchangeAuthorizationCode,
decodeJWT,
refreshAccessToken,
REDIRECT_URI,
} from "./lib/auth.mjs";
import { getCodexInstructions } from "./lib/codex.mjs";
import { startLocalOAuthServer } from "./lib/server.mjs";
import { logRequest } from "./lib/logger.mjs";
import { transformRequestBody } from "./lib/request-transformer.mjs";
import { convertSseToJson, ensureContentType } from "./lib/response-handler.mjs";
/**
* @type {import('@opencode-ai/plugin').Plugin}
*/
export async function OpenAIAuthPlugin({ client }) {
return {
auth: {
provider: "openai",
/**
* @param {() => Promise<any>} getAuth
* @param {any} provider - Provider configuration from opencode.json
*/
async loader(getAuth, provider) {
const auth = await getAuth();
// Only handle OAuth auth type, skip API key auth
if (auth.type !== "oauth") {
return {};
}
// Extract accountId from access token JWT
const decoded = decodeJWT(auth.access);
const accountId =
decoded?.["https://api.openai.com/auth"]?.chatgpt_account_id;
if (!accountId) {
console.error(
"[openai-codex-plugin] Failed to extract accountId from token",
);
return {};
}
// Extract user configuration from provider structure
// Supports both global options and per-model options following Anthropic pattern
const userConfig = {
global: provider?.options || {},
models: provider?.models || {},
};
// Fetch Codex instructions (cached with ETag)
const CODEX_INSTRUCTIONS = await getCodexInstructions();
// Return options that will be passed to the OpenAI SDK
return {
apiKey: "chatgpt-oauth", // Dummy key - actual auth handled by fetch
baseURL: "https://chatgpt.com/backend-api",
/**
* @param {any} input
* @param {any} init
*/
async fetch(input, init) {
// Get current auth and refresh if needed
const currentAuth = await getAuth();
if (
currentAuth.type !== "oauth" ||
!currentAuth.access ||
currentAuth.expires < Date.now()
) {
// Token expired or missing, refresh it
const refreshResult = await refreshAccessToken(
currentAuth.refresh,
);
if (refreshResult.type === "failed") {
console.error(
"[openai-codex-plugin] Failed to refresh token, authentication required",
);
return new Response(
JSON.stringify({ error: "Token refresh failed" }),
{ status: 401 },
);
}
// Update stored credentials
await client.auth.set({
path: { id: "openai" },
body: {
type: "oauth",
access: refreshResult.access,
refresh: refreshResult.refresh,
expires: refreshResult.expires,
},
});
// Update current auth reference
currentAuth.access = refreshResult.access;
currentAuth.refresh = refreshResult.refresh;
currentAuth.expires = refreshResult.expires;
}
let url = typeof input === "string" ? input : input.url;
// Rewrite /responses to /codex/responses for Codex backend
url = url.replace("/responses", "/codex/responses");
// Track body for later use
let body;
// Parse and modify request body
if (init?.body) {
try {
body = JSON.parse(init.body);
const originalModel = body.model;
// Log original request
logRequest("before-transform", {
url,
originalModel,
model: body.model,
hasTools: !!body.tools,
hasInput: !!body.input,
inputLength: body.input?.length,
body,
});
// Transform request body for Codex API with user configuration
body = transformRequestBody(body, CODEX_INSTRUCTIONS, userConfig);
// Log transformed request
logRequest("after-transform", {
url,
originalModel,
normalizedModel: body.model,
hasTools: !!body.tools,
hasInput: !!body.input,
inputLength: body.input?.length,
reasoning: body.reasoning,
textVerbosity: body.text?.verbosity,
include: body.include,
body,
});
init.body = JSON.stringify(body);
} catch (e) {
console.error(
"[openai-codex-plugin] Error parsing request:",
e,
);
}
}
// Add ChatGPT OAuth headers
const headers = new Headers(init?.headers ?? {});
headers.delete("x-api-key");
headers.set("Authorization", `Bearer ${currentAuth.access}`);
headers.set("chatgpt-account-id", accountId);
headers.set("OpenAI-Beta", "responses=experimental");
headers.set("originator", "codex_cli_rs");
headers.set("session_id", crypto.randomUUID());
const response = await fetch(url, {
...init,
headers,
});
// Log response status
logRequest("response", {
status: response.status,
ok: response.ok,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
});
// Log errors
if (!response.ok) {
const text = await response.text();
console.error(
`[openai-codex-plugin] ${response.status} error:`,
text,
);
logRequest("error-response", {
status: response.status,
error: text,
});
// Return a new response with the error text so the SDK can parse it
return new Response(text, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
// Ensure response has content-type header
const responseHeaders = ensureContentType(response.headers);
// For non-tool requests (compact/summarize), convert streaming SSE to JSON
// generateText() expects a non-streaming JSON response, not SSE
if (body?.tools === undefined) {
return await convertSseToJson(response, responseHeaders);
}
// For tool requests, return stream as-is (streamText handles SSE)
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
},
};
},
methods: [
{
label: "ChatGPT Plus/Pro (Codex Subscription)",
type: "oauth",
authorize: async () => {
const { pkce, state, url } = await createAuthorizationFlow();
const serverInfo = await startLocalOAuthServer({ state });
const redirectUri = REDIRECT_URI;
try {
const opener =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
spawn(opener, [url], {
stdio: "ignore",
shell: process.platform === "win32",
});
} catch {}
return {
url: url,
method: "auto",
instructions:
"A browser window should open. Complete login to finish.",
callback: async () => {
const result = await serverInfo.waitForCode(state);
serverInfo.close();
if (!result) return { type: "failed" };
const tokens = await exchangeAuthorizationCode(
result.code,
pkce.verifier,
redirectUri,
);
return tokens?.type === "success" ? tokens : { type: "failed" };
},
};
},
},
{
provider: "openai",
label: "Manually enter API Key",
type: "api",
},
],
},
};
}
export default OpenAIAuthPlugin;
+212
View File
@@ -0,0 +1,212 @@
import type { Plugin, PluginInput } from "@opencode-ai/plugin";
import type { Auth } from "@opencode-ai/sdk";
import { createAuthorizationFlow, exchangeAuthorizationCode, decodeJWT, REDIRECT_URI } from "./lib/auth.js";
import { getCodexInstructions } from "./lib/codex.js";
import { startLocalOAuthServer } from "./lib/server.js";
import { logRequest } from "./lib/logger.js";
import { openBrowserUrl } from "./lib/browser.js";
import {
shouldRefreshToken,
refreshAndUpdateToken,
extractRequestUrl,
rewriteUrlForCodex,
transformRequestForCodex,
createCodexHeaders,
handleErrorResponse,
handleSuccessResponse,
} from "./lib/fetch-helpers.js";
import type { UserConfig } from "./lib/types.js";
import {
DUMMY_API_KEY,
CODEX_BASE_URL,
PROVIDER_ID,
JWT_CLAIM_PATH,
PLUGIN_NAME,
ERROR_MESSAGES,
LOG_STAGES,
AUTH_LABELS,
} from "./lib/constants.js";
/**
* OpenAI Codex OAuth authentication plugin for opencode
*
* This plugin enables opencode to use OpenAI's Codex backend via ChatGPT Plus/Pro
* OAuth authentication, allowing users to leverage their ChatGPT subscription
* instead of OpenAI Platform API credits.
*
* @example
* ```json
* {
* "plugin": ["opencode-openai-codex-auth"],
* "model": "openai/gpt-5-codex"
* }
* ```
*/
export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
return {
auth: {
provider: PROVIDER_ID,
/**
* Loader function that configures OAuth authentication and request handling
*
* This function:
* 1. Validates OAuth authentication
* 2. Extracts ChatGPT account ID from access token
* 3. Loads user configuration from opencode.json
* 4. Fetches Codex system instructions from GitHub (cached)
* 5. Returns SDK configuration with custom fetch implementation
*
* @param getAuth - Function to retrieve current auth state
* @param provider - Provider configuration from opencode.json
* @returns SDK configuration object or empty object for non-OAuth auth
*/
async loader(getAuth: () => Promise<Auth>, provider: unknown) {
const auth = await getAuth();
// Only handle OAuth auth type, skip API key auth
if (auth.type !== "oauth") {
return {};
}
// Extract ChatGPT account ID from JWT access token
const decoded = decodeJWT(auth.access);
const accountId = decoded?.[JWT_CLAIM_PATH]?.chatgpt_account_id;
if (!accountId) {
console.error(`[${PLUGIN_NAME}] ${ERROR_MESSAGES.NO_ACCOUNT_ID}`);
return {};
}
// Extract user configuration (global + per-model options)
const providerConfig = provider as { options?: Record<string, unknown>; models?: UserConfig["models"] } | undefined;
const userConfig: UserConfig = {
global: providerConfig?.options || {},
models: providerConfig?.models || {},
};
// Fetch Codex system instructions (cached with ETag for efficiency)
const CODEX_INSTRUCTIONS = await getCodexInstructions();
// Return SDK configuration
return {
apiKey: DUMMY_API_KEY,
baseURL: CODEX_BASE_URL,
/**
* Custom fetch implementation for Codex API
*
* Handles:
* - Token refresh when expired
* - URL rewriting for Codex backend
* - Request body transformation
* - OAuth header injection
* - SSE to JSON conversion for non-tool requests
* - Error handling and logging
*
* @param input - Request URL or Request object
* @param init - Request options
* @returns Response from Codex API
*/
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: response.status,
ok: response.ok,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
});
// Step 7: Handle error or success response
if (!response.ok) {
return await handleErrorResponse(response);
}
return await handleSuccessResponse(response, hasTools);
},
};
},
methods: [
{
label: AUTH_LABELS.OAUTH,
type: "oauth" as const,
/**
* OAuth authorization flow
*
* Steps:
* 1. Generate PKCE challenge and state for security
* 2. Start local OAuth callback server on port 1455
* 3. Open browser to OpenAI authorization page
* 4. Wait for user to complete login
* 5. Exchange authorization code for tokens
*
* @returns Authorization flow configuration
*/
authorize: async () => {
const { pkce, state, url } = await createAuthorizationFlow();
const serverInfo = await startLocalOAuthServer({ state });
// Attempt to open browser automatically
openBrowserUrl(url);
return {
url,
method: "auto" as const,
instructions: AUTH_LABELS.INSTRUCTIONS,
callback: async () => {
const result = await serverInfo.waitForCode(state);
serverInfo.close();
if (!result) {
return { type: "failed" as const };
}
const tokens = await exchangeAuthorizationCode(
result.code,
pkce.verifier,
REDIRECT_URI,
);
return tokens?.type === "success"
? tokens
: { type: "failed" as const };
},
};
},
},
{
label: AUTH_LABELS.API_KEY,
type: "api" as const,
},
],
},
};
};
export default OpenAIAuthPlugin;
+36 -26
View File
@@ -1,5 +1,6 @@
import { generatePKCE } from "@openauthjs/openauth/pkce";
import { randomBytes } from "node:crypto";
import type { PKCEPair, AuthorizationFlow, TokenResult, ParsedAuthInput, JWTPayload } from "./types.js";
// OAuth constants (from openai/codex)
export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -10,18 +11,18 @@ export const SCOPE = "openid profile email offline_access";
/**
* Generate a random state value for OAuth flow
* @returns {string} Random hex string
* @returns Random hex string
*/
export function createState() {
export function createState(): string {
return randomBytes(16).toString("hex");
}
/**
* Parse authorization code and state from user input
* @param {string} input - User input (URL, code#state, or just code)
* @returns {{code?: string, state?: string}} Parsed authorization data
* @param input - User input (URL, code#state, or just code)
* @returns Parsed authorization data
*/
export function parseAuthorizationInput(input) {
export function parseAuthorizationInput(input: string): ParsedAuthInput {
const value = (input || "").trim();
if (!value) return {};
@@ -49,16 +50,16 @@ export function parseAuthorizationInput(input) {
/**
* Exchange authorization code for access and refresh tokens
* @param {string} code - Authorization code from OAuth flow
* @param {string} verifier - PKCE verifier
* @param {string} [redirectUri] - OAuth redirect URI
* @returns {Promise<{type: "success", access: string, refresh: string, expires: number} | {type: "failed"}>}
* @param code - Authorization code from OAuth flow
* @param verifier - PKCE verifier
* @param redirectUri - OAuth redirect URI
* @returns Token result
*/
export async function exchangeAuthorizationCode(
code,
verifier,
redirectUri = REDIRECT_URI,
) {
code: string,
verifier: string,
redirectUri: string = REDIRECT_URI,
): Promise<TokenResult> {
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -75,7 +76,11 @@ export async function exchangeAuthorizationCode(
console.error("[openai-codex-plugin] code->token failed:", res.status, text);
return { type: "failed" };
}
const json = await res.json();
const json = (await res.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (
!json?.access_token ||
!json?.refresh_token ||
@@ -94,16 +99,16 @@ export async function exchangeAuthorizationCode(
/**
* Decode a JWT token to extract payload
* @param {string} token - JWT token to decode
* @returns {any} Decoded payload or null if invalid
* @param token - JWT token to decode
* @returns Decoded payload or null if invalid
*/
export function decodeJWT(token) {
export function decodeJWT(token: string): JWTPayload | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const payload = parts[1];
const decoded = Buffer.from(payload, "base64").toString("utf-8");
return JSON.parse(decoded);
return JSON.parse(decoded) as JWTPayload;
} catch {
return null;
}
@@ -111,10 +116,10 @@ export function decodeJWT(token) {
/**
* Refresh access token using refresh token
* @param {string} refreshToken - Refresh token
* @returns {Promise<{type: "success", access: string, refresh: string, expires: number} | {type: "failed"}>}
* @param refreshToken - Refresh token
* @returns Token result
*/
export async function refreshAccessToken(refreshToken) {
export async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
try {
const response = await fetch(TOKEN_URL, {
method: "POST",
@@ -136,7 +141,11 @@ export async function refreshAccessToken(refreshToken) {
return { type: "failed" };
}
const json = await response.json();
const json = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
if (
!json?.access_token ||
!json?.refresh_token ||
@@ -156,17 +165,18 @@ export async function refreshAccessToken(refreshToken) {
expires: Date.now() + json.expires_in * 1000,
};
} catch (error) {
console.error("[openai-codex-plugin] Token refresh error:", error);
const err = error as Error;
console.error("[openai-codex-plugin] Token refresh error:", err);
return { type: "failed" };
}
}
/**
* Create OAuth authorization flow
* @returns {Promise<{pkce: any, state: string, url: string}>}
* @returns Authorization flow details
*/
export async function createAuthorizationFlow() {
const pkce = await generatePKCE();
export async function createAuthorizationFlow(): Promise<AuthorizationFlow> {
const pkce = (await generatePKCE()) as PKCEPair;
const state = createState();
const url = new URL(AUTHORIZE_URL);
+35
View File
@@ -0,0 +1,35 @@
/**
* Browser utilities for OAuth flow
* Handles platform-specific browser opening
*/
import { spawn } from "node:child_process";
import { PLATFORM_OPENERS } from "./constants.js";
/**
* Gets the platform-specific command to open a URL in the default browser
* @returns Browser opener command for the current platform
*/
export function getBrowserOpener(): string {
const platform = process.platform;
if (platform === "darwin") return PLATFORM_OPENERS.darwin;
if (platform === "win32") return PLATFORM_OPENERS.win32;
return PLATFORM_OPENERS.linux;
}
/**
* Opens a URL in the default browser
* Silently fails if browser cannot be opened (user can copy URL manually)
* @param url - URL to open
*/
export function openBrowserUrl(url: string): void {
try {
const opener = getBrowserOpener();
spawn(opener, [url], {
stdio: "ignore",
shell: process.platform === "win32",
});
} catch (error) {
// Silently fail - user can manually open the URL from instructions
}
}
+13 -11
View File
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import type { GitHubRelease, CacheMetadata } from "./types.js";
// Codex instructions constants
const GITHUB_API_RELEASES = "https://api.github.com/repos/openai/codex/releases/latest";
@@ -14,12 +15,12 @@ const __dirname = dirname(__filename);
/**
* Get the latest release tag from GitHub
* @returns {Promise<string>} Release tag name (e.g., "rust-v0.43.0")
* @returns Release tag name (e.g., "rust-v0.43.0")
*/
async function getLatestReleaseTag() {
async function getLatestReleaseTag(): Promise<string> {
const response = await fetch(GITHUB_API_RELEASES);
if (!response.ok) throw new Error(`Failed to fetch latest release: ${response.status}`);
const data = await response.json();
const data = (await response.json()) as GitHubRelease;
return data.tag_name;
}
@@ -27,19 +28,19 @@ async function getLatestReleaseTag() {
* Fetch Codex instructions from GitHub with ETag-based caching
* Uses HTTP conditional requests to efficiently check for updates
* Always fetches from the latest release tag, not main branch
* @returns {Promise<string>} Codex instructions
* @returns Codex instructions
*/
export async function getCodexInstructions() {
export async function getCodexInstructions(): Promise<string> {
try {
// Get the latest release tag
const latestTag = await getLatestReleaseTag();
const CODEX_INSTRUCTIONS_URL = `https://raw.githubusercontent.com/openai/codex/${latestTag}/codex-rs/core/gpt_5_codex_prompt.md`;
// Load cached metadata (includes ETag and tag)
let cachedETag = null;
let cachedTag = null;
let cachedETag: string | null = null;
let cachedTag: string | null = null;
if (existsSync(CACHE_METADATA_FILE)) {
const metadata = JSON.parse(readFileSync(CACHE_METADATA_FILE, "utf8"));
const metadata = JSON.parse(readFileSync(CACHE_METADATA_FILE, "utf8")) as CacheMetadata;
cachedETag = metadata.etag;
cachedTag = metadata.tag;
}
@@ -50,7 +51,7 @@ export async function getCodexInstructions() {
}
// Make conditional request with If-None-Match header
const headers = {};
const headers: Record<string, string> = {};
if (cachedETag) {
headers["If-None-Match"] = cachedETag;
}
@@ -84,7 +85,7 @@ export async function getCodexInstructions() {
tag: latestTag,
lastChecked: Date.now(),
url: CODEX_INSTRUCTIONS_URL,
}),
} satisfies CacheMetadata),
"utf8",
);
@@ -93,9 +94,10 @@ export async function getCodexInstructions() {
throw new Error(`HTTP ${response.status}`);
} catch (error) {
const err = error as Error;
console.error(
"[openai-codex-plugin] Failed to fetch instructions from GitHub:",
error.message,
err.message,
);
// Try to use cached version even if stale
+74
View File
@@ -0,0 +1,74 @@
/**
* Constants used throughout the plugin
* Centralized for easy maintenance and configuration
*/
/** Plugin identifier for logging and error messages */
export const PLUGIN_NAME = "openai-codex-plugin";
/** Base URL for ChatGPT backend API */
export const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
/** Dummy API key used for OpenAI SDK (actual auth via OAuth) */
export const DUMMY_API_KEY = "chatgpt-oauth";
/** Provider ID for opencode configuration */
export const PROVIDER_ID = "openai";
/** HTTP Status Codes */
export const HTTP_STATUS = {
OK: 200,
UNAUTHORIZED: 401,
} as const;
/** OpenAI-specific headers */
export const OPENAI_HEADERS = {
BETA: "OpenAI-Beta",
ACCOUNT_ID: "chatgpt-account-id",
ORIGINATOR: "originator",
SESSION_ID: "session_id",
} as const;
/** OpenAI-specific header values */
export const OPENAI_HEADER_VALUES = {
BETA_RESPONSES: "responses=experimental",
ORIGINATOR_CODEX: "codex_cli_rs",
} as const;
/** URL path segments */
export const URL_PATHS = {
RESPONSES: "/responses",
CODEX_RESPONSES: "/codex/responses",
} as const;
/** JWT claim path for ChatGPT account ID */
export const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const;
/** Error messages */
export const ERROR_MESSAGES = {
NO_ACCOUNT_ID: "Failed to extract accountId from token",
TOKEN_REFRESH_FAILED: "Failed to refresh token, authentication required",
REQUEST_PARSE_ERROR: "Error parsing request",
} as const;
/** Log stages for request logging */
export const LOG_STAGES = {
BEFORE_TRANSFORM: "before-transform",
AFTER_TRANSFORM: "after-transform",
RESPONSE: "response",
ERROR_RESPONSE: "error-response",
} as const;
/** Platform-specific browser opener commands */
export const PLATFORM_OPENERS = {
darwin: "open",
win32: "start",
linux: "xdg-open",
} as const;
/** OAuth authorization labels */
export const AUTH_LABELS = {
OAUTH: "ChatGPT Plus/Pro (Codex Subscription)",
API_KEY: "Manually enter API Key",
INSTRUCTIONS: "A browser window should open. Complete login to finish.",
} as const;
+227
View File
@@ -0,0 +1,227 @@
/**
* Helper functions for the custom fetch implementation
* These functions break down the complex fetch logic into manageable, testable units
*/
import type { Auth } from "@opencode-ai/sdk";
import type { OpencodeClient } from "@opencode-ai/sdk";
import { refreshAccessToken } from "./auth.js";
import { logRequest } from "./logger.js";
import { transformRequestBody } from "./request-transformer.js";
import { convertSseToJson, ensureContentType } from "./response-handler.js";
import type { UserConfig, RequestBody } from "./types.js";
import {
PLUGIN_NAME,
HTTP_STATUS,
OPENAI_HEADERS,
OPENAI_HEADER_VALUES,
URL_PATHS,
ERROR_MESSAGES,
LOG_STAGES,
} from "./constants.js";
/**
* Determines if the current auth token needs to be refreshed
* @param auth - Current authentication state
* @returns True if token is expired or invalid
*/
export function shouldRefreshToken(auth: Auth): boolean {
return (
auth.type !== "oauth" ||
!auth.access ||
auth.expires < Date.now()
);
}
/**
* Refreshes the OAuth token and updates stored credentials
* @param currentAuth - Current auth state
* @param client - Opencode client for updating stored credentials
* @returns Updated auth or error response
*/
export async function refreshAndUpdateToken(
currentAuth: Auth,
client: OpencodeClient
): Promise<{ success: true; auth: Auth } | { success: false; response: Response }> {
const refreshToken = currentAuth.type === "oauth" ? currentAuth.refresh : "";
const refreshResult = await refreshAccessToken(refreshToken);
if (refreshResult.type === "failed") {
console.error(`[${PLUGIN_NAME}] ${ERROR_MESSAGES.TOKEN_REFRESH_FAILED}`);
return {
success: false,
response: new Response(
JSON.stringify({ error: "Token refresh failed" }),
{ status: HTTP_STATUS.UNAUTHORIZED }
),
};
}
// Update stored credentials
await client.auth.set({
path: { id: "openai" },
body: {
type: "oauth",
access: refreshResult.access,
refresh: refreshResult.refresh,
expires: refreshResult.expires,
},
});
// Update current auth reference if it's OAuth type
if (currentAuth.type === "oauth") {
currentAuth.access = refreshResult.access;
currentAuth.refresh = refreshResult.refresh;
currentAuth.expires = refreshResult.expires;
}
return { success: true, auth: currentAuth };
}
/**
* Extracts URL string from various request input types
* @param input - Request input (string, URL, or Request object)
* @returns URL string
*/
export function extractRequestUrl(input: Request | string | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.toString();
return input.url;
}
/**
* Rewrites OpenAI API URLs to Codex backend URLs
* @param url - Original URL
* @returns Rewritten URL for Codex backend
*/
export function rewriteUrlForCodex(url: string): string {
return url.replace(URL_PATHS.RESPONSES, URL_PATHS.CODEX_RESPONSES);
}
/**
* Transforms request body and logs the transformation
* @param init - Request init options
* @param url - Request URL
* @param codexInstructions - Codex system instructions
* @param userConfig - User configuration
* @returns Transformed body and updated init, or undefined if no body
*/
export function transformRequestForCodex(
init: RequestInit | undefined,
url: string,
codexInstructions: string,
userConfig: UserConfig
): { body: RequestBody; updatedInit: RequestInit } | undefined {
if (!init?.body) return undefined;
try {
const body = JSON.parse(init.body as string) as RequestBody;
const originalModel = body.model;
// Log original request
logRequest(LOG_STAGES.BEFORE_TRANSFORM, {
url,
originalModel,
model: body.model,
hasTools: !!body.tools,
hasInput: !!body.input,
inputLength: body.input?.length,
body: body as unknown as Record<string, unknown>,
});
// Transform request body
const transformedBody = transformRequestBody(body, codexInstructions, userConfig);
// Log transformed request
logRequest(LOG_STAGES.AFTER_TRANSFORM, {
url,
originalModel,
normalizedModel: transformedBody.model,
hasTools: !!transformedBody.tools,
hasInput: !!transformedBody.input,
inputLength: transformedBody.input?.length,
reasoning: transformedBody.reasoning as unknown,
textVerbosity: transformedBody.text?.verbosity,
include: transformedBody.include,
body: transformedBody as unknown as Record<string, unknown>,
});
return {
body: transformedBody,
updatedInit: { ...init, body: JSON.stringify(transformedBody) },
};
} catch (e) {
console.error(`[${PLUGIN_NAME}] ${ERROR_MESSAGES.REQUEST_PARSE_ERROR}:`, e);
return undefined;
}
}
/**
* Creates headers for Codex API requests
* @param init - Request init options
* @param accountId - ChatGPT account ID
* @param accessToken - OAuth access token
* @returns Headers object with all required Codex headers
*/
export function createCodexHeaders(
init: RequestInit | undefined,
accountId: string,
accessToken: string
): Headers {
const headers = new Headers(init?.headers ?? {});
headers.delete("x-api-key"); // Remove any existing API key
headers.set("Authorization", `Bearer ${accessToken}`);
headers.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
headers.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
headers.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
headers.set(OPENAI_HEADERS.SESSION_ID, crypto.randomUUID());
return headers;
}
/**
* Handles error responses from the Codex API
* @param response - Error response from API
* @returns Response with error details
*/
export async function handleErrorResponse(response: Response): Promise<Response> {
const text = await response.text();
console.error(`[${PLUGIN_NAME}] ${response.status} error:`, text);
logRequest(LOG_STAGES.ERROR_RESPONSE, {
status: response.status,
error: text,
});
return new Response(text, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
/**
* Handles successful responses from the Codex API
* Converts SSE to JSON for non-tool requests
* @param response - Success response from API
* @param hasTools - Whether the request included tools
* @returns Processed response (SSE→JSON for non-tool, stream for tool requests)
*/
export async function handleSuccessResponse(
response: Response,
hasTools: boolean
): Promise<Response> {
const responseHeaders = ensureContentType(response.headers);
// For non-tool requests (compact/summarize), convert streaming SSE to JSON
// generateText() expects a non-streaming JSON response, not SSE
if (!hasTools) {
return await convertSseToJson(response, responseHeaders);
}
// For tool requests, return stream as-is (streamText handles SSE)
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
}
+6 -7
View File
@@ -3,7 +3,7 @@ import { join } from "node:path";
import { homedir } from "node:os";
// Logging configuration
const LOGGING_ENABLED = process.env.ENABLE_PLUGIN_REQUEST_LOGGING === "1";
export const LOGGING_ENABLED = process.env.ENABLE_PLUGIN_REQUEST_LOGGING === "1";
const LOG_DIR = join(homedir(), ".opencode", "logs", "codex-plugin");
// Log startup message about logging state
@@ -15,10 +15,10 @@ let requestCounter = 0;
/**
* Log request data to file (only when LOGGING_ENABLED is true)
* @param {string} stage - The stage of the request (e.g., "before-transform", "after-transform")
* @param {object} data - The data to log
* @param stage - The stage of the request (e.g., "before-transform", "after-transform")
* @param data - The data to log
*/
export function logRequest(stage, data) {
export function logRequest(stage: string, data: Record<string, unknown>): void {
// Only log if explicitly enabled via environment variable
if (!LOGGING_ENABLED) return;
@@ -48,8 +48,7 @@ export function logRequest(stage, data) {
);
console.log(`[openai-codex-plugin] Logged ${stage} to ${filename}`);
} catch (e) {
console.error("[openai-codex-plugin] Failed to write log:", e.message);
const error = e as Error;
console.error("[openai-codex-plugin] Failed to write log:", error.message);
}
}
export { LOGGING_ENABLED };
+712
View File
@@ -0,0 +1,712 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenCode - Authentication Successful</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&display=swap" rel="stylesheet">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "IBM Plex Mono", monospace;
font-weight: 400;
background: #0a0a0a;
color: #B7B1B1;
overflow: hidden;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
/* Matrix rain background */
.matrix {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
opacity: 0.35;
}
/* Scan lines effect */
.scanline {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(
transparent 50%,
rgba(0, 0, 0, 0.1) 50%
);
background-size: 100% 4px;
z-index: 2;
pointer-events: none;
animation: scan 8s linear infinite;
}
@keyframes scan {
0% { transform: translateY(0); }
100% { transform: translateY(4px); }
}
/* Vignette effect */
.vignette {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-shadow: inset 0 0 200px rgba(0, 0, 0, 0.8);
z-index: 2;
pointer-events: none;
}
.container {
width: 90%;
max-width: 650px;
text-align: center;
z-index: 10;
position: relative;
animation: fadeIn 0.8s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.logo {
margin-bottom: 60px;
opacity: 0;
animation: logoEntrance 1.2s cubic-bezier(0.34, 1.56, 0.64, 1) 0.3s forwards;
position: relative;
}
@keyframes logoEntrance {
0% {
opacity: 0;
transform: translateY(-50px) rotateX(90deg);
}
100% {
opacity: 1;
transform: translateY(0) rotateX(0);
}
}
.logo svg {
width: 300px;
height: auto;
filter: drop-shadow(0 0 30px rgba(241, 236, 236, 0.3));
animation: pulse 3s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
filter: drop-shadow(0 0 30px rgba(241, 236, 236, 0.3));
}
50% {
filter: drop-shadow(0 0 50px rgba(241, 236, 236, 0.5));
}
}
/* Glowing orb behind logo */
.logo::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(241, 236, 236, 0.1) 0%, transparent 70%);
border-radius: 50%;
animation: orbPulse 4s ease-in-out infinite;
z-index: -1;
}
@keyframes orbPulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.3;
}
50% {
transform: translate(-50%, -50%) scale(1.2);
opacity: 0.6;
}
}
.terminal-window {
background: rgba(26, 26, 26, 0.95);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(75, 70, 70, 0.3);
border-radius: 12px;
margin: 30px 0;
opacity: 0;
animation: boxEntrance 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.6s forwards;
position: relative;
overflow: hidden;
box-shadow:
0 0 60px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.terminal-header {
background: rgba(15, 15, 15, 0.9);
padding: 14px 18px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid rgba(75, 70, 70, 0.3);
position: relative;
}
.terminal-button {
width: 12px;
height: 12px;
border-radius: 50%;
transition: transform 0.2s;
}
.terminal-button:hover {
transform: scale(1.2);
}
.terminal-button.red {
background: #ff5f56;
box-shadow: 0 0 10px rgba(255, 95, 86, 0.3);
animation: pulse 2s infinite;
}
.terminal-button.yellow {
background: #ffbd2e;
box-shadow: 0 0 10px rgba(255, 189, 46, 0.3);
animation: pulse 2s infinite 0.3s;
}
.terminal-button.green {
background: #27c93f;
box-shadow: 0 0 10px rgba(39, 201, 63, 0.3);
animation: pulse 2s infinite 0.6s;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.terminal-title {
flex: 1;
text-align: center;
font-size: 12px;
color: #6a6565;
opacity: 0.8;
letter-spacing: 0.5px;
}
.status-box {
padding: 50px 40px;
position: relative;
}
@keyframes boxEntrance {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Animated border gradient */
.terminal-window::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(
45deg,
transparent,
rgba(241, 236, 236, 0.1),
transparent,
rgba(241, 236, 236, 0.1)
);
background-size: 400% 400%;
border-radius: 12px;
z-index: -1;
animation: gradientShift 8s ease infinite;
}
@keyframes gradientShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.checkmark-container {
width: 100px;
height: 100px;
margin: 0 auto 40px;
position: relative;
}
.checkmark-circle {
width: 100px;
height: 100px;
border-radius: 50%;
border: 4px solid transparent;
display: flex;
align-items: center;
justify-content: center;
position: relative;
animation: circleAppear 0.6s ease-out 1s both;
}
@keyframes circleAppear {
from {
transform: scale(0) rotate(-180deg);
border-color: transparent;
}
to {
transform: scale(1) rotate(0);
border-color: #4B4646;
}
}
/* Spinning rings around checkmark */
.checkmark-circle::before,
.checkmark-circle::after {
content: '';
position: absolute;
width: 120%;
height: 120%;
border: 2px solid transparent;
border-top-color: rgba(241, 236, 236, 0.2);
border-radius: 50%;
animation: rotate 3s linear infinite;
}
.checkmark-circle::after {
width: 140%;
height: 140%;
border-top-color: rgba(183, 177, 177, 0.1);
animation: rotate 4s linear infinite reverse;
}
@keyframes rotate {
to { transform: rotate(360deg); }
}
.checkmark {
width: 35px;
height: 18px;
border-left: 5px solid #F1ECEC;
border-bottom: 5px solid #F1ECEC;
transform: rotate(-45deg) scale(0);
animation: checkmarkDraw 0.5s cubic-bezier(0.65, 0, 0.45, 1) 1.3s forwards;
filter: drop-shadow(0 0 10px rgba(241, 236, 236, 0.5));
}
@keyframes checkmarkDraw {
0% {
transform: rotate(-45deg) scale(0);
}
50% {
transform: rotate(-45deg) scale(1.1);
}
100% {
transform: rotate(-45deg) scale(1);
}
}
.title {
font-size: 32px;
color: #00ff41;
margin-bottom: 20px;
font-weight: 500;
letter-spacing: 1px;
text-shadow: 0 0 20px rgba(0, 255, 65, 0.5);
opacity: 0;
animation: fadeInUp 0.6s ease-out 1.8s forwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message {
font-size: 16px;
color: #9a9595;
line-height: 1.8;
margin-bottom: 30px;
opacity: 0;
animation: fadeInUp 0.6s ease-out 2s forwards;
}
.terminal-output {
background: rgba(10, 10, 10, 0.6);
border: 1px solid rgba(75, 70, 70, 0.2);
border-radius: 8px;
padding: 25px;
text-align: left;
font-size: 14px;
line-height: 2;
margin-top: 25px;
position: relative;
overflow: hidden;
}
/* Terminal typing effect */
.terminal-output::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, #4B4646, transparent);
animation: scanProgress 2s ease-out 2.2s forwards;
}
@keyframes scanProgress {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
.terminal-line {
opacity: 0;
transform: translateX(-20px);
animation: terminalType 0.4s ease-out forwards;
display: flex;
align-items: center;
gap: 12px;
}
.terminal-line:nth-child(1) { animation-delay: 2.2s; }
.terminal-line:nth-child(2) { animation-delay: 2.4s; }
.terminal-line:nth-child(3) { animation-delay: 2.6s; }
.terminal-line:nth-child(4) { animation-delay: 2.8s; }
@keyframes terminalType {
to {
opacity: 1;
transform: translateX(0);
}
}
.prompt {
color: #6a6565;
font-weight: 500;
}
.success {
color: #B7B1B1;
}
.terminal-line::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, rgba(75, 70, 70, 0.3), transparent);
}
.footer {
margin-top: 40px;
font-size: 14px;
color: #6a6565;
opacity: 0;
animation: fadeInUp 0.6s ease-out 3.2s forwards;
text-shadow: 0 0 10px rgba(106, 101, 101, 0.5);
}
.cursor {
display: inline-block;
width: 2px;
height: 16px;
background: #8a8585;
margin-left: 6px;
animation: blink 1s infinite;
box-shadow: 0 0 8px #8a8585;
}
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
/* Floating particles */
.particle {
position: fixed;
width: 2px;
height: 2px;
background: rgba(183, 177, 177, 0.3);
border-radius: 50%;
pointer-events: none;
z-index: 5;
animation: float 8s linear infinite;
}
@keyframes float {
0% {
transform: translateY(100vh) translateX(0) scale(0);
opacity: 0;
}
10% {
opacity: 1;
transform: translateY(90vh) translateX(10px) scale(1);
}
90% {
opacity: 1;
}
100% {
transform: translateY(-10vh) translateX(-10px) scale(0);
opacity: 0;
}
}
/* Success glow effect */
@keyframes successGlow {
0%, 100% {
box-shadow:
0 0 60px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
50% {
box-shadow:
0 0 80px rgba(241, 236, 236, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
}
.terminal-window {
animation:
boxEntrance 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.6s forwards,
successGlow 4s ease-in-out 2s infinite;
}
</style>
</head>
<body>
<canvas class="matrix" id="matrix"></canvas>
<div class="scanline"></div>
<div class="vignette"></div>
<div class="container">
<div class="logo">
<svg width="300" height="54" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
</svg>
</div>
<div class="terminal-window">
<div class="terminal-header">
<div class="terminal-button red"></div>
<div class="terminal-button yellow"></div>
<div class="terminal-button green"></div>
<div class="terminal-title">[email protected] — OAuth Authentication</div>
</div>
<div class="status-box">
<div class="checkmark-container">
<div class="checkmark-circle">
<div class="checkmark"></div>
</div>
</div>
<div class="title">ACCESS GRANTED</div>
<div class="message">
OpenAI ChatGPT authentication successful<br>
Your credentials have been securely stored
</div>
<div class="terminal-output">
<div class="terminal-line">
<span class="prompt">▸</span>
<span class="success">Initializing OAuth handshake...</span>
</div>
<div class="terminal-line">
<span class="prompt">▸</span>
<span class="success">Token exchange verified</span>
</div>
<div class="terminal-line">
<span class="prompt">▸</span>
<span class="success">Credentials encrypted & stored</span>
</div>
<div class="terminal-line">
<span class="prompt">✓</span>
<span class="success">OpenCode ready for deployment</span>
</div>
</div>
</div>
</div>
<div class="footer">
Return to your terminal to continue<span class="cursor"></span>
</div>
</div>
<script>
// Enhanced Matrix rain effect with mouse interaction
const canvas = document.getElementById('matrix');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
// Mix of characters including OpenCode themed ones
const chars = 'OPENCODE▸✓▹▸◂◃◄►◀▶←→↑↓⟨⟩∧∨∩∪⊂⊃⊆⊇∈∉∀∃∄∅∞≡≠≤≥±∓⊕⊗⊙01';
const fontSize = 16;
const columns = Math.floor(canvas.width / fontSize);
const drops = Array(columns).fill(1);
const speeds = Array(columns).fill(0).map(() => Math.random() * 0.5 + 0.5);
// Track mouse position
let mouseX = -1000;
let mouseY = -1000;
canvas.addEventListener('mousemove', (e) => {
mouseX = e.clientX;
mouseY = e.clientY;
});
canvas.addEventListener('mouseleave', () => {
mouseX = -1000;
mouseY = -1000;
});
function drawMatrix() {
ctx.fillStyle = 'rgba(10, 10, 10, 0.08)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.font = fontSize + 'px "IBM Plex Mono"';
for (let i = 0; i < drops.length; i++) {
const text = chars[Math.floor(Math.random() * chars.length)];
const x = i * fontSize;
const y = drops[i] * fontSize;
// Check distance from mouse
const distanceFromMouse = Math.sqrt(
Math.pow(x - mouseX, 2) + Math.pow(y - mouseY, 2)
);
const hoverRadius = 80;
const isNearMouse = distanceFromMouse < hoverRadius;
if (isNearMouse) {
// Matrix green color when near mouse
const intensity = 1 - (distanceFromMouse / hoverRadius);
ctx.fillStyle = `rgba(0, 255, 65, ${intensity * 0.95})`;
ctx.shadowBlur = 20 * intensity;
ctx.shadowColor = '#00ff41';
} else {
// Bright white gradient by default
const gradient = ctx.createLinearGradient(0, y - fontSize * 10, 0, y);
gradient.addColorStop(0, 'rgba(200, 200, 200, 0)');
gradient.addColorStop(0.5, 'rgba(241, 236, 236, 0.8)');
gradient.addColorStop(1, 'rgba(255, 255, 255, 1)');
ctx.fillStyle = gradient;
ctx.shadowBlur = 0;
}
ctx.fillText(text, x, y);
// Randomly reset drops
if (drops[i] * fontSize > canvas.height && Math.random() > 0.98) {
drops[i] = 0;
speeds[i] = Math.random() * 0.5 + 0.5;
}
drops[i] += speeds[i];
}
// Reset shadow for next frame
ctx.shadowBlur = 0;
}
setInterval(drawMatrix, 40);
// Create floating particles
function createParticle() {
const particle = document.createElement('div');
particle.className = 'particle';
particle.style.left = Math.random() * 100 + '%';
particle.style.animationDelay = Math.random() * 8 + 's';
particle.style.animationDuration = (Math.random() * 4 + 6) + 's';
document.body.appendChild(particle);
setTimeout(() => particle.remove(), 10000);
}
// Generate particles periodically
for (let i = 0; i < 20; i++) {
setTimeout(createParticle, i * 400);
}
setInterval(() => {
if (Math.random() > 0.7) createParticle();
}, 2000);
// Resize handler
window.addEventListener('resize', () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
});
// Add subtle mouse parallax effect
document.addEventListener('mousemove', (e) => {
const moveX = (e.clientX - window.innerWidth / 2) / 50;
const moveY = (e.clientY - window.innerHeight / 2) / 50;
document.querySelector('.container').style.transform =
`translate(${moveX}px, ${moveY}px)`;
});
</script>
</body>
</html>
@@ -1,11 +1,12 @@
import { TOOL_REMAP_MESSAGE } from "./codex.mjs";
import { TOOL_REMAP_MESSAGE } from "./codex.js";
import type { UserConfig, ConfigOptions, ReasoningConfig, RequestBody, InputItem } from "./types.js";
/**
* Normalize model name to Codex-supported variants
* @param {string} model - Original model name
* @returns {string} Normalized model name
* @param model - Original model name
* @returns Normalized model name
*/
export function normalizeModel(model) {
export function normalizeModel(model: string | undefined): string {
if (!model) return "gpt-5";
if (model.includes("codex")) {
@@ -21,11 +22,11 @@ export function normalizeModel(model) {
/**
* Extract configuration for a specific model
* Merges global options with model-specific options (model-specific takes precedence)
* @param {string} modelName - Model name (e.g., "gpt-5-codex")
* @param {object} userConfig - Full user configuration object
* @returns {object} Merged configuration for this model
* @param modelName - Model name (e.g., "gpt-5-codex")
* @param userConfig - Full user configuration object
* @returns Merged configuration for this model
*/
export function getModelConfig(modelName, userConfig = {}) {
export function getModelConfig(modelName: string, userConfig: UserConfig = { global: {}, models: {} }): ConfigOptions {
const globalOptions = userConfig.global || {};
const modelOptions = userConfig.models?.[modelName]?.options || {};
@@ -41,17 +42,17 @@ export function getModelConfig(modelName, userConfig = {}) {
* - opencode explicitly excludes gpt-5-codex from automatic reasoning configuration
* - Codex CLI has been thoroughly tested against this backend
*
* @param {string} originalModel - Original model name before normalization
* @param {object} userConfig - User configuration object
* @returns {object} Reasoning configuration
* @param originalModel - Original model name before normalization
* @param userConfig - User configuration object
* @returns Reasoning configuration
*/
export function getReasoningConfig(originalModel, userConfig = {}) {
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 = isLightweight ? "minimal" : "medium";
const defaultEffort: "minimal" | "low" | "medium" | "high" = isLightweight ? "minimal" : "medium";
// Get user-requested effort
let effort = userConfig.reasoningEffort || defaultEffort;
@@ -71,10 +72,10 @@ export function getReasoningConfig(originalModel, userConfig = {}) {
/**
* Filter input array to remove stored conversation history references
* @param {Array} input - Original input array
* @returns {Array} Filtered input array
* @param input - Original input array
* @returns Filtered input array
*/
export function filterInput(input) {
export function filterInput(input: InputItem[] | undefined): InputItem[] | undefined {
if (!Array.isArray(input)) return input;
return input.filter((item) => {
@@ -88,14 +89,14 @@ export function filterInput(input) {
/**
* Add tool remapping message to input if tools are present
* @param {Array} input - Input array
* @param {boolean} hasTools - Whether tools are present in request
* @returns {Array} Input array with tool remap message prepended if needed
* @param input - Input array
* @param hasTools - Whether tools are present in request
* @returns Input array with tool remap message prepended if needed
*/
export function addToolRemapMessage(input, hasTools) {
export function addToolRemapMessage(input: InputItem[] | undefined, hasTools: boolean): InputItem[] | undefined {
if (!hasTools || !Array.isArray(input)) return input;
const toolRemapMessage = {
const toolRemapMessage: InputItem = {
type: "message",
role: "developer",
content: [
@@ -117,12 +118,16 @@ export function addToolRemapMessage(input, hasTools) {
* - opencode excludes gpt-5-codex from reasoning configuration
* - This plugin uses store=false (stateless), requiring encrypted reasoning content
*
* @param {object} body - Original request body
* @param {string} codexInstructions - Codex system instructions
* @param {object} userConfig - User configuration from loader
* @returns {object} Transformed request body
* @param body - Original request body
* @param codexInstructions - Codex system instructions
* @param userConfig - User configuration from loader
* @returns Transformed request body
*/
export function transformRequestBody(body, codexInstructions, userConfig = {}) {
export function transformRequestBody(
body: RequestBody,
codexInstructions: string,
userConfig: UserConfig = { global: {}, models: {} }
): RequestBody {
const originalModel = body.model;
const normalizedModel = normalizeModel(body.model);
@@ -1,17 +1,18 @@
import { logRequest, LOGGING_ENABLED } from "./logger.mjs";
import { logRequest, LOGGING_ENABLED } from "./logger.js";
import type { SSEEventData } from "./types.js";
/**
* Parse SSE stream to extract final response
* @param {string} sseText - Complete SSE stream text
* @returns {object|null} Final response object or null if not found
* @param sseText - Complete SSE stream text
* @returns Final response object or null if not found
*/
function parseSseStream(sseText) {
function parseSseStream(sseText: string): unknown | null {
const lines = sseText.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6));
const data = JSON.parse(line.substring(6)) as SSEEventData;
// Look for response.done event with final data
if (data.type === 'response.done' || data.type === 'response.completed') {
@@ -28,11 +29,14 @@ function parseSseStream(sseText) {
/**
* Convert SSE stream response to JSON for generateText()
* @param {Response} response - Fetch response with SSE stream
* @param {Headers} headers - Response headers
* @returns {Promise<Response>} Response with JSON body
* @param response - Fetch response with SSE stream
* @param headers - Response headers
* @returns Response with JSON body
*/
export async function convertSseToJson(response, headers) {
export async function convertSseToJson(response: Response, headers: Headers): Promise<Response> {
if (!response.body) {
throw new Error('[openai-codex-plugin] Response has no body');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
@@ -83,10 +87,10 @@ export async function convertSseToJson(response, headers) {
/**
* Ensure response has content-type header
* @param {Headers} headers - Response headers
* @returns {Headers} Headers with content-type set
* @param headers - Response headers
* @returns Headers with content-type set
*/
export function ensureContentType(headers) {
export function ensureContentType(headers: Headers): Headers {
const responseHeaders = new Headers(headers);
if (!responseHeaders.has('content-type')) {
+19 -11
View File
@@ -1,14 +1,22 @@
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { OAuthServerInfo } from "./types.js";
// Resolve path to oauth-success.html
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const successHtml = fs.readFileSync(path.join(__dirname, "oauth-success.html"), "utf-8");
/**
* Start a small local HTTP server that waits for /auth/callback and returns the code
* @param {{state: string}} options - OAuth state for validation
* @returns {Promise<{port: number, close: () => void, waitForCode: () => Promise<{code: string} | null>}>}
* @param options - OAuth state for validation
* @returns Promise that resolves to server info
*/
export function startLocalOAuthServer({ state }) {
export function startLocalOAuthServer({ state }: { state: string }): Promise<OAuthServerInfo> {
const server = http.createServer((req, res) => {
try {
const url = new URL(req.url, "http://localhost");
const url = new URL(req.url || "", "http://localhost");
if (url.pathname !== "/auth/callback") {
res.statusCode = 404;
res.end("Not found");
@@ -27,15 +35,14 @@ export function startLocalOAuthServer({ state }) {
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(
"<html><body>OpenAI ChatGPT login complete. You can close this window.</body></html>",
);
server._lastCode = code;
res.end(successHtml);
(server as http.Server & { _lastCode?: string })._lastCode = code;
} catch {
res.statusCode = 500;
res.end("Internal error");
}
});
return new Promise((resolve) => {
server
.listen(1455, "127.0.0.1", () => {
@@ -43,16 +50,17 @@ export function startLocalOAuthServer({ state }) {
port: 1455,
close: () => server.close(),
waitForCode: async () => {
const poll = () => new Promise((r) => setTimeout(r, 100));
const poll = () => new Promise<void>((r) => setTimeout(r, 100));
for (let i = 0; i < 600; i++) {
if (server._lastCode) return { code: server._lastCode };
const lastCode = (server as http.Server & { _lastCode?: string })._lastCode;
if (lastCode) return { code: lastCode };
await poll();
}
return null;
},
});
})
.on("error", (err) => {
.on("error", (err: NodeJS.ErrnoException) => {
console.error(
"[openai-codex-plugin] Failed to bind http://127.0.0.1:1455 (",
err?.code,
+158
View File
@@ -0,0 +1,158 @@
import type { Auth, Provider, Model } from "@opencode-ai/sdk";
/**
* User configuration structure from opencode.json
*/
export interface UserConfig {
global: ConfigOptions;
models: {
[modelName: string]: {
options?: ConfigOptions;
};
};
}
/**
* Configuration options for reasoning and text settings
*/
export interface ConfigOptions {
reasoningEffort?: "minimal" | "low" | "medium" | "high";
reasoningSummary?: "auto" | "concise" | "detailed";
textVerbosity?: "low" | "medium" | "high";
include?: string[];
}
/**
* Reasoning configuration for requests
*/
export interface ReasoningConfig {
effort: "minimal" | "low" | "medium" | "high";
summary: "auto" | "concise" | "detailed";
}
/**
* OAuth server information
*/
export interface OAuthServerInfo {
port: number;
close: () => void;
waitForCode: (state: string) => Promise<{ code: string } | null>;
}
/**
* PKCE challenge and verifier
*/
export interface PKCEPair {
challenge: string;
verifier: string;
}
/**
* Authorization flow result
*/
export interface AuthorizationFlow {
pkce: PKCEPair;
state: string;
url: string;
}
/**
* Token exchange success result
*/
export interface TokenSuccess {
type: "success";
access: string;
refresh: string;
expires: number;
}
/**
* Token exchange failure result
*/
export interface TokenFailure {
type: "failed";
}
/**
* Token exchange result
*/
export type TokenResult = TokenSuccess | TokenFailure;
/**
* Parsed authorization input
*/
export interface ParsedAuthInput {
code?: string;
state?: string;
}
/**
* JWT payload with ChatGPT account info
*/
export interface JWTPayload {
"https://api.openai.com/auth"?: {
chatgpt_account_id?: string;
};
[key: string]: unknown;
}
/**
* Message input item
*/
export interface InputItem {
id?: string;
type: string;
role: string;
content?: unknown;
[key: string]: unknown;
}
/**
* Request body structure
*/
export interface RequestBody {
model: string;
store?: boolean;
stream?: boolean;
instructions?: string;
input?: InputItem[];
tools?: unknown;
reasoning?: Partial<ReasoningConfig>;
text?: {
verbosity?: "low" | "medium" | "high";
};
include?: string[];
max_output_tokens?: number;
max_completion_tokens?: number;
[key: string]: unknown;
}
/**
* SSE event data structure
*/
export interface SSEEventData {
type: string;
response?: unknown;
[key: string]: unknown;
}
/**
* Cache metadata for Codex instructions
*/
export interface CacheMetadata {
etag: string | null;
tag: string;
lastChecked: number;
url: string;
}
/**
* GitHub release data
*/
export interface GitHubRelease {
tag_name: string;
[key: string]: unknown;
}
// Re-export SDK types for convenience
export type { Auth, Provider, Model };
+20 -4
View File
@@ -2,7 +2,8 @@
"name": "opencode-openai-codex-auth",
"version": "1.0.4",
"description": "OpenAI ChatGPT (Codex backend) OAuth auth plugin for opencode - use your ChatGPT Plus/Pro subscription instead of API credits",
"main": "./index.mjs",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"type": "module",
"license": "MIT",
"author": "Numman Ali",
@@ -26,18 +27,33 @@
"bugs": {
"url": "https://github.com/numman-ali/opencode-openai-codex-auth/issues"
},
"scripts": {
"build": "tsc && cp lib/oauth-success.html dist/lib/",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
},
"files": [
"index.mjs",
"lib/",
"dist/",
"README.md",
"LICENSE"
],
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"peerDependencies": {
"@opencode-ai/plugin": "^0.13.7"
},
"devDependencies": {
"@opencode-ai/plugin": "^0.13.7",
"@opencode-ai/sdk": "^0.13.9",
"@types/node": "^24.6.2",
"@vitest/ui": "^3.2.4",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
},
"dependencies": {
"@openauthjs/openauth": "^0.4.3"
}
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["file:///home/code/projects/ben-vargas/ai-opencode-openai-codex-auth/config-support"],
"model": "openai/gpt-5-codex",
"provider": {
"openai": {
"options": {
"reasoningEffort": "medium",
"reasoningSummary": "auto",
"textVerbosity": "medium"
},
"models": {
"gpt-5-codex": {
"options": {
"reasoningSummary": "concise"
}
}
}
}
}
}
-108
View File
@@ -1,108 +0,0 @@
#!/usr/bin/env node
import { getModelConfig, getReasoningConfig } from "./lib/request-transformer.mjs";
console.log("=== Testing Configuration Parsing ===\n");
// Simulate provider config from opencode.json
const providerConfig = {
options: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
models: {
"gpt-5-codex": {
options: {
reasoningSummary: "concise", // Override global
},
},
"gpt-5": {
options: {
reasoningEffort: "high", // Override global
},
},
},
};
// Build userConfig structure (same as in index.mjs)
const userConfig = {
global: providerConfig.options || {},
models: providerConfig.models || {},
};
console.log("Provider Config:");
console.log(JSON.stringify(providerConfig, null, 2));
console.log("\n");
// Test 1: gpt-5-codex (should merge global + model-specific)
console.log("Test 1: gpt-5-codex configuration");
const codexConfig = getModelConfig("gpt-5-codex", userConfig);
console.log("Merged config:", codexConfig);
console.log("Expected: reasoningEffort='medium' (global), reasoningSummary='concise' (override), textVerbosity='medium' (global)");
console.log("✓ Pass:",
codexConfig.reasoningEffort === "medium" &&
codexConfig.reasoningSummary === "concise" &&
codexConfig.textVerbosity === "medium"
);
console.log("\n");
// Test 2: gpt-5 (should merge global + model-specific)
console.log("Test 2: gpt-5 configuration");
const gpt5Config = getModelConfig("gpt-5", userConfig);
console.log("Merged config:", gpt5Config);
console.log("Expected: reasoningEffort='high' (override), reasoningSummary='auto' (global), textVerbosity='medium' (global)");
console.log("✓ Pass:",
gpt5Config.reasoningEffort === "high" &&
gpt5Config.reasoningSummary === "auto" &&
gpt5Config.textVerbosity === "medium"
);
console.log("\n");
// Test 3: Reasoning config with user settings
console.log("Test 3: Reasoning config for gpt-5-codex");
const reasoningConfig = getReasoningConfig("gpt-5-codex", codexConfig);
console.log("Reasoning config:", reasoningConfig);
console.log("Expected: effort='medium', summary='concise'");
console.log("✓ Pass:",
reasoningConfig.effort === "medium" &&
reasoningConfig.summary === "concise"
);
console.log("\n");
// Test 4: Defaults when no config provided
console.log("Test 4: Defaults with empty config");
const emptyConfig = getModelConfig("gpt-5-codex", {});
const defaultReasoning = getReasoningConfig("gpt-5-codex", emptyConfig);
console.log("Empty config:", emptyConfig);
console.log("Default reasoning:", defaultReasoning);
console.log("Expected: effort='medium' (default), summary='auto' (default)");
console.log("✓ Pass:",
defaultReasoning.effort === "medium" &&
defaultReasoning.summary === "auto"
);
console.log("\n");
// Test 5: Lightweight model defaults
console.log("Test 5: Lightweight model (gpt-5-nano)");
const nanoReasoning = getReasoningConfig("gpt-5-nano", {});
console.log("Nano reasoning:", nanoReasoning);
console.log("Expected: effort='minimal' (lightweight default), summary='auto'");
console.log("✓ Pass:",
nanoReasoning.effort === "minimal" &&
nanoReasoning.summary === "auto"
);
console.log("\n");
// Test 6: Normalize "minimal" to "low" for gpt-5-codex
console.log("Test 6: Normalize minimal→low for gpt-5-codex (Codex CLI doesn't support minimal)");
const codexMinimalConfig = { reasoningEffort: "minimal" };
const codexMinimalReasoning = getReasoningConfig("gpt-5-codex", codexMinimalConfig);
console.log("Config:", codexMinimalConfig);
console.log("Reasoning result:", codexMinimalReasoning);
console.log("Expected: effort='low' (normalized from minimal), summary='auto'");
console.log("✓ Pass:",
codexMinimalReasoning.effort === "low" &&
codexMinimalReasoning.summary === "auto"
);
console.log("\n=== All Tests Complete ===");
+105
View File
@@ -0,0 +1,105 @@
# Test Suite
This directory contains the comprehensive test suite for the OpenAI Codex OAuth plugin.
## Test Structure
```
test/
├── README.md # This file
├── auth.test.ts # OAuth authentication tests
├── config.test.ts # Configuration parsing tests
├── logger.test.ts # Logging functionality tests
├── request-transformer.test.ts # Request transformation tests
└── response-handler.test.ts # Response handling tests
```
## Running Tests
```bash
# Run all tests once
npm test
# Watch mode (re-run on file changes)
npm run test:watch
# Visual test UI
npm run test:ui
# Generate coverage report
npm run test:coverage
```
## Test Coverage
### auth.test.ts (16 tests)
Tests OAuth authentication functionality:
- State generation and uniqueness
- Authorization input parsing (URL, code#state, query string formats)
- JWT decoding and payload extraction
- Authorization flow creation with PKCE
- URL parameter validation
### config.test.ts (13 tests)
Tests configuration parsing and merging:
- Global configuration application
- Per-model configuration overrides
- Mixed configuration (global + per-model)
- Default values and fallbacks
- Reasoning effort normalization (minimal → low for codex)
- Lightweight model detection (nano, mini)
### request-transformer.test.ts (30 tests)
Tests request body transformations:
- Model name normalization (all variants → gpt-5 or gpt-5-codex)
- Input filtering (removing stored conversation history)
- Tool remap message injection
- Reasoning configuration application
- Text verbosity settings
- Encrypted reasoning content inclusion
- Unsupported parameter removal
### response-handler.test.ts (10 tests)
Tests SSE to JSON conversion:
- Content-type header management
- SSE stream parsing (response.done, response.completed)
- Malformed JSON handling
- Empty stream handling
- Status preservation
### logger.test.ts (5 tests)
Tests logging functionality:
- LOGGING_ENABLED constant
- logRequest function parameter handling
- Complex data structure support
## Test Philosophy
1. **Comprehensive Coverage**: Each module has extensive tests covering normal cases, edge cases, and error conditions
2. **Fast Execution**: All tests run in < 250ms
3. **No External Dependencies**: Tests use mocked data and don't make real API calls
4. **Type Safety**: All tests are written in TypeScript with full type checking
## CI/CD Integration
Tests automatically run in GitHub Actions on:
- Every push to main
- Every pull request
The CI workflow tests against multiple Node.js versions (18.x, 20.x, 22.x) to ensure compatibility.
## Adding New Tests
When adding new functionality:
1. Create or update the relevant test file
2. Follow the existing pattern using vitest's `describe` and `it` blocks
3. Ensure tests are isolated and don't depend on external state
4. Run `npm test` to verify all tests pass
5. Run `npm run typecheck` to ensure TypeScript types are correct
## Example Configurations
See the `config/` directory for working configuration examples:
- `minimal-opencode.json`: Simplest setup with defaults
- `full-opencode.json`: Complete example with all model variants
+143
View File
@@ -0,0 +1,143 @@
import { describe, it, expect } from 'vitest';
import {
createState,
parseAuthorizationInput,
decodeJWT,
createAuthorizationFlow,
CLIENT_ID,
AUTHORIZE_URL,
REDIRECT_URI,
SCOPE,
} from '../lib/auth.js';
describe('Auth Module', () => {
describe('createState', () => {
it('should generate a random 32-character hex string', () => {
const state = createState();
expect(state).toMatch(/^[a-f0-9]{32}$/);
});
it('should generate unique states', () => {
const state1 = createState();
const state2 = createState();
expect(state1).not.toBe(state2);
});
});
describe('parseAuthorizationInput', () => {
it('should parse full OAuth callback URL', () => {
const input = 'http://localhost:1455/auth/callback?code=abc123&state=xyz789';
const result = parseAuthorizationInput(input);
expect(result).toEqual({ code: 'abc123', state: 'xyz789' });
});
it('should parse code#state format', () => {
const input = 'abc123#xyz789';
const result = parseAuthorizationInput(input);
expect(result).toEqual({ code: 'abc123', state: 'xyz789' });
});
it('should parse query string format', () => {
const input = 'code=abc123&state=xyz789';
const result = parseAuthorizationInput(input);
expect(result).toEqual({ code: 'abc123', state: 'xyz789' });
});
it('should parse code only', () => {
const input = 'abc123';
const result = parseAuthorizationInput(input);
expect(result).toEqual({ code: 'abc123' });
});
it('should return empty object for empty input', () => {
const result = parseAuthorizationInput('');
expect(result).toEqual({});
});
it('should handle whitespace', () => {
const result = parseAuthorizationInput(' ');
expect(result).toEqual({});
});
});
describe('decodeJWT', () => {
it('should decode valid JWT token', () => {
// Create a simple JWT token: header.payload.signature
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64');
const payload = Buffer.from(JSON.stringify({ sub: '1234567890', name: 'Test User' })).toString('base64');
const signature = 'fake-signature';
const token = `${header}.${payload}.${signature}`;
const decoded = decodeJWT(token);
expect(decoded).toEqual({ sub: '1234567890', name: 'Test User' });
});
it('should decode JWT with ChatGPT account info', () => {
const payload = Buffer.from(JSON.stringify({
'https://api.openai.com/auth': {
chatgpt_account_id: 'account-123',
},
})).toString('base64');
const token = `header.${payload}.signature`;
const decoded = decodeJWT(token);
expect(decoded?.['https://api.openai.com/auth']?.chatgpt_account_id).toBe('account-123');
});
it('should return null for invalid JWT', () => {
const result = decodeJWT('invalid-token');
expect(result).toBeNull();
});
it('should return null for malformed JWT', () => {
const result = decodeJWT('header.payload');
expect(result).toBeNull();
});
it('should return null for non-JSON payload', () => {
const token = 'header.not-json.signature';
const result = decodeJWT(token);
expect(result).toBeNull();
});
});
describe('createAuthorizationFlow', () => {
it('should create authorization flow with PKCE', async () => {
const flow = await createAuthorizationFlow();
expect(flow).toHaveProperty('pkce');
expect(flow).toHaveProperty('state');
expect(flow).toHaveProperty('url');
expect(flow.pkce).toHaveProperty('challenge');
expect(flow.pkce).toHaveProperty('verifier');
expect(flow.state).toMatch(/^[a-f0-9]{32}$/);
});
it('should generate URL with correct parameters', async () => {
const flow = await createAuthorizationFlow();
const url = new URL(flow.url);
expect(url.origin + url.pathname).toBe(AUTHORIZE_URL);
expect(url.searchParams.get('response_type')).toBe('code');
expect(url.searchParams.get('client_id')).toBe(CLIENT_ID);
expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI);
expect(url.searchParams.get('scope')).toBe(SCOPE);
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
expect(url.searchParams.get('code_challenge')).toBe(flow.pkce.challenge);
expect(url.searchParams.get('state')).toBe(flow.state);
expect(url.searchParams.get('id_token_add_organizations')).toBe('true');
expect(url.searchParams.get('codex_cli_simplified_flow')).toBe('true');
expect(url.searchParams.get('originator')).toBe('codex_cli_rs');
});
it('should generate unique flows', async () => {
const flow1 = await createAuthorizationFlow();
const flow2 = await createAuthorizationFlow();
expect(flow1.state).not.toBe(flow2.state);
expect(flow1.pkce.verifier).not.toBe(flow2.pkce.verifier);
expect(flow1.url).not.toBe(flow2.url);
});
});
});
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { getBrowserOpener } from '../lib/browser.js';
import { PLATFORM_OPENERS } from '../lib/constants.js';
describe('Browser Module', () => {
describe('getBrowserOpener', () => {
it('should return correct opener for darwin', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin' });
expect(getBrowserOpener()).toBe(PLATFORM_OPENERS.darwin);
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should return correct opener for win32', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });
expect(getBrowserOpener()).toBe(PLATFORM_OPENERS.win32);
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should return linux opener for other platforms', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'linux' });
expect(getBrowserOpener()).toBe(PLATFORM_OPENERS.linux);
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
it('should handle unknown platforms', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'freebsd' });
expect(getBrowserOpener()).toBe(PLATFORM_OPENERS.linux);
Object.defineProperty(process, 'platform', { value: originalPlatform });
});
});
});
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import { getModelConfig, getReasoningConfig } from '../lib/request-transformer.js';
import type { UserConfig } from '../lib/types.js';
describe('Configuration Parsing', () => {
const providerConfig = {
options: {
reasoningEffort: 'medium' as const,
reasoningSummary: 'auto' as const,
textVerbosity: 'medium' as const,
},
models: {
'gpt-5-codex': {
options: {
reasoningSummary: 'concise' as const,
},
},
'gpt-5': {
options: {
reasoningEffort: 'high' as const,
},
},
},
};
const userConfig: UserConfig = {
global: providerConfig.options || {},
models: providerConfig.models || {},
};
describe('getModelConfig', () => {
it('should merge global and model-specific config for gpt-5-codex', () => {
const codexConfig = getModelConfig('gpt-5-codex', userConfig);
expect(codexConfig.reasoningEffort).toBe('medium'); // from global
expect(codexConfig.reasoningSummary).toBe('concise'); // from model override
expect(codexConfig.textVerbosity).toBe('medium'); // from global
});
it('should merge global and model-specific config for gpt-5', () => {
const gpt5Config = getModelConfig('gpt-5', userConfig);
expect(gpt5Config.reasoningEffort).toBe('high'); // from model override
expect(gpt5Config.reasoningSummary).toBe('auto'); // from global
expect(gpt5Config.textVerbosity).toBe('medium'); // from global
});
it('should return empty config when no config provided', () => {
const emptyConfig = getModelConfig('gpt-5-codex', { global: {}, models: {} });
expect(emptyConfig).toEqual({});
});
});
describe('getReasoningConfig', () => {
it('should use user settings from merged config for gpt-5-codex', () => {
const codexConfig = getModelConfig('gpt-5-codex', userConfig);
const reasoningConfig = getReasoningConfig('gpt-5-codex', codexConfig);
expect(reasoningConfig.effort).toBe('medium');
expect(reasoningConfig.summary).toBe('concise');
});
it('should return defaults when no config provided', () => {
const emptyConfig = getModelConfig('gpt-5-codex', { global: {}, models: {} });
const defaultReasoning = getReasoningConfig('gpt-5-codex', emptyConfig);
expect(defaultReasoning.effort).toBe('medium');
expect(defaultReasoning.summary).toBe('auto');
});
it('should use minimal effort for lightweight models (nano/mini)', () => {
const nanoReasoning = getReasoningConfig('gpt-5-nano', {});
expect(nanoReasoning.effort).toBe('minimal');
expect(nanoReasoning.summary).toBe('auto');
});
it('should normalize "minimal" to "low" for gpt-5-codex', () => {
const codexMinimalConfig = { reasoningEffort: 'minimal' as const };
const codexMinimalReasoning = getReasoningConfig('gpt-5-codex', codexMinimalConfig);
expect(codexMinimalReasoning.effort).toBe('low');
expect(codexMinimalReasoning.summary).toBe('auto');
});
it('should preserve "minimal" effort for non-codex models', () => {
const gpt5MinimalConfig = { reasoningEffort: 'minimal' as const };
const gpt5MinimalReasoning = getReasoningConfig('gpt-5', gpt5MinimalConfig);
expect(gpt5MinimalReasoning.effort).toBe('minimal');
});
it('should handle high effort setting', () => {
const highConfig = { reasoningEffort: 'high' as const };
const highReasoning = getReasoningConfig('gpt-5', highConfig);
expect(highReasoning.effort).toBe('high');
expect(highReasoning.summary).toBe('auto');
});
it('should respect custom summary setting', () => {
const detailedConfig = { reasoningSummary: 'detailed' as const };
const detailedReasoning = getReasoningConfig('gpt-5-codex', detailedConfig);
expect(detailedReasoning.summary).toBe('detailed');
});
});
describe('Model-specific behavior', () => {
it('should detect lightweight models correctly', () => {
const miniReasoning = getReasoningConfig('gpt-5-mini', {});
expect(miniReasoning.effort).toBe('minimal');
});
it('should detect codex models correctly', () => {
const codexConfig = { reasoningEffort: 'minimal' as const };
const codexReasoning = getReasoningConfig('gpt-5-codex', codexConfig);
expect(codexReasoning.effort).toBe('low'); // normalized
});
it('should handle standard gpt-5 model', () => {
const gpt5Reasoning = getReasoningConfig('gpt-5', {});
expect(gpt5Reasoning.effort).toBe('medium');
});
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect, vi } from 'vitest';
import {
shouldRefreshToken,
extractRequestUrl,
rewriteUrlForCodex,
createCodexHeaders,
} from '../lib/fetch-helpers.js';
import type { Auth } from '../lib/types.js';
import { URL_PATHS, OPENAI_HEADERS, OPENAI_HEADER_VALUES } from '../lib/constants.js';
describe('Fetch Helpers Module', () => {
describe('shouldRefreshToken', () => {
it('should return true for non-oauth auth', () => {
const auth: Auth = { type: 'api', key: 'test-key' };
expect(shouldRefreshToken(auth)).toBe(true);
});
it('should return true when access token is missing', () => {
const auth: Auth = { type: 'oauth', access: '', refresh: 'refresh-token', expires: Date.now() + 1000 };
expect(shouldRefreshToken(auth)).toBe(true);
});
it('should return true when token is expired', () => {
const auth: Auth = {
type: 'oauth',
access: 'access-token',
refresh: 'refresh-token',
expires: Date.now() - 1000 // expired
};
expect(shouldRefreshToken(auth)).toBe(true);
});
it('should return false for valid oauth token', () => {
const auth: Auth = {
type: 'oauth',
access: 'access-token',
refresh: 'refresh-token',
expires: Date.now() + 10000 // valid for 10 seconds
};
expect(shouldRefreshToken(auth)).toBe(false);
});
});
describe('extractRequestUrl', () => {
it('should extract URL from string', () => {
const url = 'https://example.com/test';
expect(extractRequestUrl(url)).toBe(url);
});
it('should extract URL from URL object', () => {
const url = new URL('https://example.com/test');
expect(extractRequestUrl(url)).toBe('https://example.com/test');
});
it('should extract URL from Request object', () => {
const request = new Request('https://example.com/test');
expect(extractRequestUrl(request)).toBe('https://example.com/test');
});
});
describe('rewriteUrlForCodex', () => {
it('should rewrite /responses to /codex/responses', () => {
const url = 'https://chatgpt.com/backend-api/responses';
expect(rewriteUrlForCodex(url)).toBe('https://chatgpt.com/backend-api/codex/responses');
});
it('should not modify URL without /responses', () => {
const url = 'https://chatgpt.com/backend-api/other';
expect(rewriteUrlForCodex(url)).toBe(url);
});
it('should only replace first occurrence', () => {
const url = 'https://example.com/responses/responses';
const result = rewriteUrlForCodex(url);
expect(result).toBe('https://example.com/codex/responses/responses');
});
});
describe('createCodexHeaders', () => {
const accountId = 'test-account-123';
const accessToken = 'test-access-token';
it('should create headers with all required fields', () => {
const headers = createCodexHeaders(undefined, accountId, accessToken);
expect(headers.get('Authorization')).toBe(`Bearer ${accessToken}`);
expect(headers.get(OPENAI_HEADERS.ACCOUNT_ID)).toBe(accountId);
expect(headers.get(OPENAI_HEADERS.BETA)).toBe(OPENAI_HEADER_VALUES.BETA_RESPONSES);
expect(headers.get(OPENAI_HEADERS.ORIGINATOR)).toBe(OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
expect(headers.has(OPENAI_HEADERS.SESSION_ID)).toBe(true);
});
it('should remove x-api-key header', () => {
const init = { headers: { 'x-api-key': 'should-be-removed' } };
const headers = createCodexHeaders(init, accountId, accessToken);
expect(headers.has('x-api-key')).toBe(false);
});
it('should preserve other existing headers', () => {
const init = { headers: { 'Content-Type': 'application/json' } };
const headers = createCodexHeaders(init, accountId, accessToken);
expect(headers.get('Content-Type')).toBe('application/json');
});
it('should generate unique session IDs', () => {
const headers1 = createCodexHeaders(undefined, accountId, accessToken);
const headers2 = createCodexHeaders(undefined, accountId, accessToken);
expect(headers1.get(OPENAI_HEADERS.SESSION_ID)).not.toBe(
headers2.get(OPENAI_HEADERS.SESSION_ID)
);
});
it('should validate session ID format (UUID)', () => {
const headers = createCodexHeaders(undefined, accountId, accessToken);
const sessionId = headers.get(OPENAI_HEADERS.SESSION_ID);
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
expect(sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
});
});
+43
View File
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest';
import { LOGGING_ENABLED, logRequest } from '../lib/logger.js';
describe('Logger Module', () => {
describe('LOGGING_ENABLED constant', () => {
it('should be a boolean', () => {
expect(typeof LOGGING_ENABLED).toBe('boolean');
});
it('should default to false when env variable is not set', () => {
// This test verifies the default behavior
// In a real test environment, ENABLE_PLUGIN_REQUEST_LOGGING would not be set
const isEnabled = process.env.ENABLE_PLUGIN_REQUEST_LOGGING === '1';
expect(typeof isEnabled).toBe('boolean');
});
});
describe('logRequest function', () => {
it('should accept stage and data parameters', () => {
// This should not throw
expect(() => {
logRequest('test-stage', { data: 'test' });
}).not.toThrow();
});
it('should handle empty data object', () => {
expect(() => {
logRequest('test-stage', {});
}).not.toThrow();
});
it('should handle complex data structures', () => {
expect(() => {
logRequest('test-stage', {
nested: { data: 'value' },
array: [1, 2, 3],
number: 123,
boolean: true,
});
}).not.toThrow();
});
});
});
+289
View File
@@ -0,0 +1,289 @@
import { describe, it, expect } from 'vitest';
import {
normalizeModel,
filterInput,
addToolRemapMessage,
transformRequestBody,
} from '../lib/request-transformer.js';
import { TOOL_REMAP_MESSAGE } from '../lib/codex.js';
import type { RequestBody, UserConfig, InputItem } from '../lib/types.js';
describe('Request Transformer Module', () => {
describe('normalizeModel', () => {
it('should normalize gpt-5-codex', () => {
expect(normalizeModel('gpt-5-codex')).toBe('gpt-5-codex');
});
it('should normalize gpt-5', () => {
expect(normalizeModel('gpt-5')).toBe('gpt-5');
});
it('should normalize variants containing "codex"', () => {
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"', () => {
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', () => {
expect(normalizeModel('unknown-model')).toBe('gpt-5');
expect(normalizeModel('gpt-4')).toBe('gpt-5');
});
it('should return gpt-5 for undefined', () => {
expect(normalizeModel(undefined)).toBe('gpt-5');
});
});
describe('filterInput', () => {
it('should keep items without IDs', () => {
const input: InputItem[] = [
{ type: 'message', role: 'user', content: 'hello' },
];
const result = filterInput(input);
expect(result).toEqual(input);
});
it('should remove items with rs_ IDs', () => {
const input: InputItem[] = [
{ id: 'rs_123', type: 'message', role: 'user', content: 'hello' },
{ id: 'msg_456', type: 'message', role: 'user', content: 'world' },
];
const result = filterInput(input);
expect(result).toHaveLength(1);
expect(result![0].id).toBe('msg_456');
});
it('should handle mixed items', () => {
const input: InputItem[] = [
{ type: 'message', role: 'user', content: '1' },
{ id: 'rs_stored', type: 'message', role: 'assistant', content: '2' },
{ id: 'msg_123', type: 'message', role: 'user', content: '3' },
];
const result = filterInput(input);
expect(result).toHaveLength(2);
expect(result![0].content).toBe('1');
expect(result![1].content).toBe('3');
});
it('should return undefined for undefined input', () => {
expect(filterInput(undefined)).toBeUndefined();
});
it('should return non-array input as-is', () => {
const notArray = { notAnArray: true };
expect(filterInput(notArray as any)).toBe(notArray);
});
});
describe('addToolRemapMessage', () => {
it('should prepend tool remap message when tools present', () => {
const input: InputItem[] = [
{ type: 'message', role: 'user', content: 'hello' },
];
const result = addToolRemapMessage(input, true);
expect(result).toHaveLength(2);
expect(result![0].role).toBe('developer');
expect(result![0].type).toBe('message');
expect((result![0].content as any)[0].text).toContain('apply_patch');
});
it('should not modify input when tools not present', () => {
const input: InputItem[] = [
{ type: 'message', role: 'user', content: 'hello' },
];
const result = addToolRemapMessage(input, false);
expect(result).toEqual(input);
});
it('should return undefined for undefined input', () => {
expect(addToolRemapMessage(undefined, true)).toBeUndefined();
});
it('should handle non-array input', () => {
const notArray = { notAnArray: true };
expect(addToolRemapMessage(notArray as any, true)).toBe(notArray);
});
});
describe('transformRequestBody', () => {
const codexInstructions = 'Test Codex Instructions';
it('should set required Codex fields', () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
};
const result = transformRequestBody(body, codexInstructions);
expect(result.store).toBe(false);
expect(result.stream).toBe(true);
expect(result.instructions).toBe(codexInstructions);
});
it('should normalize model name', () => {
const body: RequestBody = {
model: 'gpt-5-mini',
input: [],
};
const result = transformRequestBody(body, codexInstructions);
expect(result.model).toBe('gpt-5');
});
it('should apply default reasoning config', () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
};
const result = transformRequestBody(body, codexInstructions);
expect(result.reasoning?.effort).toBe('medium');
expect(result.reasoning?.summary).toBe('auto');
});
it('should apply user reasoning config', () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
};
const userConfig: UserConfig = {
global: {
reasoningEffort: 'high',
reasoningSummary: 'detailed',
},
models: {},
};
const result = transformRequestBody(body, codexInstructions, userConfig);
expect(result.reasoning?.effort).toBe('high');
expect(result.reasoning?.summary).toBe('detailed');
});
it('should apply default text verbosity', () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
};
const result = transformRequestBody(body, codexInstructions);
expect(result.text?.verbosity).toBe('medium');
});
it('should apply user text verbosity', () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
};
const userConfig: UserConfig = {
global: { textVerbosity: 'low' },
models: {},
};
const result = transformRequestBody(body, codexInstructions, userConfig);
expect(result.text?.verbosity).toBe('low');
});
it('should set default include for encrypted reasoning', () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
};
const result = transformRequestBody(body, codexInstructions);
expect(result.include).toEqual(['reasoning.encrypted_content']);
});
it('should use user-configured include', () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
};
const userConfig: UserConfig = {
global: { include: ['custom_field', 'reasoning.encrypted_content'] },
models: {},
};
const result = transformRequestBody(body, codexInstructions, userConfig);
expect(result.include).toEqual(['custom_field', 'reasoning.encrypted_content']);
});
it('should filter input array', () => {
const body: RequestBody = {
model: 'gpt-5',
input: [
{ id: 'rs_123', type: 'message', role: 'assistant', content: 'old' },
{ type: 'message', role: 'user', content: 'new' },
],
};
const result = transformRequestBody(body, codexInstructions);
expect(result.input).toHaveLength(1);
expect(result.input![0].content).toBe('new');
});
it('should add tool remap message when tools present', () => {
const body: RequestBody = {
model: 'gpt-5',
input: [{ type: 'message', role: 'user', content: 'hello' }],
tools: [{ name: 'test_tool' }],
};
const result = transformRequestBody(body, codexInstructions);
expect(result.input![0].role).toBe('developer');
});
it('should not add tool remap message when tools absent', () => {
const body: RequestBody = {
model: 'gpt-5',
input: [{ type: 'message', role: 'user', content: 'hello' }],
};
const result = transformRequestBody(body, codexInstructions);
expect(result.input![0].role).toBe('user');
});
it('should remove unsupported parameters', () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
max_output_tokens: 1000,
max_completion_tokens: 2000,
};
const result = 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', () => {
const body: RequestBody = {
model: 'gpt-5-codex',
input: [],
};
const userConfig: UserConfig = {
global: { reasoningEffort: 'minimal' },
models: {},
};
const result = transformRequestBody(body, codexInstructions, userConfig);
expect(result.reasoning?.effort).toBe('low');
});
it('should preserve minimal for non-codex models', () => {
const body: RequestBody = {
model: 'gpt-5',
input: [],
};
const userConfig: UserConfig = {
global: { reasoningEffort: 'minimal' },
models: {},
};
const result = transformRequestBody(body, codexInstructions, userConfig);
expect(result.reasoning?.effort).toBe('minimal');
});
it('should use minimal effort for lightweight models', () => {
const body: RequestBody = {
model: 'gpt-5-nano',
input: [],
};
const result = transformRequestBody(body, codexInstructions);
expect(result.reasoning?.effort).toBe('minimal');
});
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, it, expect, vi } from 'vitest';
import { ensureContentType, convertSseToJson } from '../lib/response-handler.js';
describe('Response Handler Module', () => {
describe('ensureContentType', () => {
it('should preserve existing content-type', () => {
const headers = new Headers();
headers.set('content-type', 'application/json');
const result = ensureContentType(headers);
expect(result.get('content-type')).toBe('application/json');
});
it('should add default content-type if missing', () => {
const headers = new Headers();
const result = ensureContentType(headers);
expect(result.get('content-type')).toBe('text/event-stream; charset=utf-8');
});
it('should not modify original headers', () => {
const headers = new Headers();
const result = ensureContentType(headers);
expect(headers.has('content-type')).toBe(false);
expect(result.has('content-type')).toBe(true);
});
});
describe('convertSseToJson', () => {
it('should throw error if response has no body', async () => {
const response = new Response(null);
const headers = new Headers();
await expect(convertSseToJson(response, headers)).rejects.toThrow(
'Response has no body'
);
});
it('should parse SSE stream with response.done event', async () => {
const sseContent = `data: {"type":"response.started"}
data: {"type":"response.done","response":{"id":"resp_123","output":"test"}}
`;
const response = new Response(sseContent);
const headers = new Headers();
const result = await convertSseToJson(response, headers);
const body = await result.json();
expect(body).toEqual({ id: 'resp_123', output: 'test' });
expect(result.headers.get('content-type')).toBe('application/json; charset=utf-8');
});
it('should parse SSE stream with response.completed event', async () => {
const sseContent = `data: {"type":"response.started"}
data: {"type":"response.completed","response":{"id":"resp_456","output":"done"}}
`;
const response = new Response(sseContent);
const headers = new Headers();
const result = await convertSseToJson(response, headers);
const body = await result.json();
expect(body).toEqual({ id: 'resp_456', output: 'done' });
});
it('should return original text if no final response found', async () => {
const sseContent = `data: {"type":"response.started"}
data: {"type":"chunk","delta":"text"}
`;
const response = new Response(sseContent);
const headers = new Headers();
const result = await convertSseToJson(response, headers);
const text = await result.text();
expect(text).toBe(sseContent);
});
it('should skip malformed JSON in SSE stream', async () => {
const sseContent = `data: not-json
data: {"type":"response.done","response":{"id":"resp_789"}}
`;
const response = new Response(sseContent);
const headers = new Headers();
const result = await convertSseToJson(response, headers);
const body = await result.json();
expect(body).toEqual({ id: 'resp_789' });
});
it('should handle empty SSE stream', async () => {
const response = new Response('');
const headers = new Headers();
const result = await convertSseToJson(response, headers);
const text = await result.text();
expect(text).toBe('');
});
it('should preserve response status and statusText', async () => {
const sseContent = `data: {"type":"response.done","response":{"id":"x"}}`;
const response = new Response(sseContent, {
status: 200,
statusText: 'OK',
});
const headers = new Headers();
const result = await convertSseToJson(response, headers);
expect(result.status).toBe(200);
expect(result.statusText).toBe('OK');
});
});
});
+33
View File
@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": [
"ES2022"
],
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": [
"node"
]
},
"include": [
"index.ts",
"lib/**/*.ts"
],
"exclude": [
"node_modules",
"dist",
"test",
"**/*.mjs"
]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', 'test/'],
},
},
});