mirror of
https://github.com/hanzoai/opencode-openai-codex-auth.git
synced 2026-09-24 17:01:02 +00:00
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:
@@ -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
@@ -4,4 +4,4 @@ pnpm-lock.yaml
|
||||
package-lock.json
|
||||
dist/
|
||||
.DS_Store
|
||||
|
||||
.history/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
{
|
||||
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,97 +285,80 @@ Apply the same settings to all GPT-5 models:
|
||||
"openai": {
|
||||
"options": {
|
||||
"reasoningEffort": "high",
|
||||
"reasoningSummary": "detailed",
|
||||
"textVerbosity": "medium"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Per-Model Configuration
|
||||
|
||||
Different settings for different models:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": ["opencode-openai-codex-auth"],
|
||||
"provider": {
|
||||
"openai": {
|
||||
"models": {
|
||||
"gpt-5-codex": {
|
||||
"options": {
|
||||
"reasoningEffort": "high",
|
||||
"reasoningSummary": "detailed",
|
||||
"textVerbosity": "medium"
|
||||
}
|
||||
},
|
||||
"gpt-5": {
|
||||
"options": {
|
||||
"reasoningEffort": "high",
|
||||
"reasoningSummary": "detailed",
|
||||
"textVerbosity": "low"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Mixed Configuration
|
||||
|
||||
Global defaults with per-model overrides:
|
||||
|
||||
```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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Custom Model Variants Example
|
||||
|
||||
Create your own named variants in the model selector:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": ["opencode-openai-codex-auth"],
|
||||
"provider": {
|
||||
"openai": {
|
||||
"models": {
|
||||
"My Fast Codex": {
|
||||
"id": "gpt-5-codex",
|
||||
"options": {
|
||||
"reasoningEffort": "low"
|
||||
}
|
||||
},
|
||||
"My Smart GPT-5": {
|
||||
"id": "gpt-5",
|
||||
"options": {
|
||||
"reasoningEffort": "high",
|
||||
"textVerbosity": "high"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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"`
|
||||
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.
|
||||
|
||||
### Configuration Files
|
||||
|
||||
This repository includes ready-to-use configuration examples:
|
||||
|
||||
- **[`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
|
||||
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": ["opencode-openai-codex-auth"],
|
||||
"model": "openai/gpt-5-codex"
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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')) {
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user