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
|
package-lock.json
|
||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.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
|
## 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
|
- ✅ **Zero external dependencies** - Lightweight with only @openauthjs/openauth
|
||||||
- ✅ **Auto-refreshing tokens** - Handles token expiration automatically
|
- ✅ **Auto-refreshing tokens** - Handles token expiration automatically
|
||||||
- ✅ **Smart auto-updating Codex instructions** - Tracks latest stable release with ETag caching
|
- ✅ **Smart auto-updating Codex instructions** - Tracks latest stable release with ETag caching
|
||||||
- ✅ Full tool support (write, edit, bash, grep, etc.)
|
- ✅ **Full tool support** - write, edit, bash, grep, glob, and more
|
||||||
- ✅ Automatic tool remapping (Codex tools → opencode tools)
|
- ✅ **Automatic tool remapping** - Codex tools → opencode tools
|
||||||
- ✅ Configurable reasoning effort and summaries (defaults: medium/auto)
|
- ✅ **Configurable reasoning** - Control effort, summary verbosity, and text output
|
||||||
- ✅ Modular architecture for easy maintenance
|
- ✅ **Type-safe & tested** - Strict TypeScript with 93 comprehensive tests
|
||||||
|
- ✅ **Modular architecture** - Easy to maintain and extend
|
||||||
|
|
||||||
## Installation
|
## 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.
|
**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`
|
**Global config**: `~/.config/opencode/opencode.json`
|
||||||
**Project config**: `<project>/.opencode.json`
|
**Project config**: `<project>/.opencode.json`
|
||||||
|
|
||||||
```json
|
This gives you 9 model variants with different reasoning levels:
|
||||||
{
|
- **GPT 5 Codex** (Low/Medium/High)
|
||||||
"$schema": "https://opencode.ai/config.json",
|
- **GPT 5** (Minimal/Low/Medium/High)
|
||||||
"plugin": [
|
- **gpt-5-mini** and **gpt-5-nano** (these are to support the default opencode model selector)
|
||||||
"opencode-openai-codex-auth"
|
|
||||||
],
|
All appear in the opencode model selector with optimal settings for each reasoning level.
|
||||||
"model": "openai/gpt-5-codex"
|
|
||||||
}
|
> **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.
|
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:
|
> **Note on Updates**: Sometimes 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
|
> - 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`
|
> - 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.
|
> 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
|
## Usage
|
||||||
|
|
||||||
|
If using the full configuration, select from the model picker in opencode, or specify via command line:
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
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
|
### Plugin Defaults
|
||||||
|
|
||||||
When no configuration is specified, the plugin uses these defaults for all GPT-5 models:
|
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
|
## 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 |
|
If you want to customize settings yourself, you can configure options at provider or model level.
|
||||||
|---------|-----------------|----------------|-------------|
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
#### GPT-5-Codex Model
|
#### Available Settings
|
||||||
|
|
||||||
| Setting | Supported Values | Plugin Default | Description |
|
⚠️ **Important**: The two base models have different supported values.
|
||||||
|---------|-----------------|----------------|-------------|
|
|
||||||
| `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) |
|
|
||||||
|
|
||||||
\* `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 |
|
#### Global Configuration Example
|
||||||
|---------|--------|----------------|-------------|
|
|
||||||
| `include` | Array of strings | `["reasoning.encrypted_content"]` | Additional response fields (for stateless reasoning) |
|
|
||||||
|
|
||||||
### Configuration Examples
|
Apply settings to all models:
|
||||||
|
|
||||||
#### Global Configuration
|
|
||||||
|
|
||||||
Apply the same settings to all GPT-5 models:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -157,17 +285,16 @@ Apply the same settings to all GPT-5 models:
|
|||||||
"openai": {
|
"openai": {
|
||||||
"options": {
|
"options": {
|
||||||
"reasoningEffort": "high",
|
"reasoningEffort": "high",
|
||||||
"reasoningSummary": "detailed",
|
"reasoningSummary": "detailed"
|
||||||
"textVerbosity": "medium"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Per-Model Configuration
|
#### Custom Model Variants Example
|
||||||
|
|
||||||
Different settings for different models:
|
Create your own named variants in the model selector:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -176,18 +303,17 @@ Different settings for different models:
|
|||||||
"provider": {
|
"provider": {
|
||||||
"openai": {
|
"openai": {
|
||||||
"models": {
|
"models": {
|
||||||
"gpt-5-codex": {
|
"My Fast Codex": {
|
||||||
|
"id": "gpt-5-codex",
|
||||||
"options": {
|
"options": {
|
||||||
"reasoningEffort": "high",
|
"reasoningEffort": "low"
|
||||||
"reasoningSummary": "detailed",
|
|
||||||
"textVerbosity": "medium"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gpt-5": {
|
"My Smart GPT-5": {
|
||||||
|
"id": "gpt-5",
|
||||||
"options": {
|
"options": {
|
||||||
"reasoningEffort": "high",
|
"reasoningEffort": "high",
|
||||||
"reasoningSummary": "detailed",
|
"textVerbosity": "high"
|
||||||
"textVerbosity": "low"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,58 +322,43 @@ Different settings for different models:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Mixed Configuration
|
The `id` field determines which base model is used (`gpt-5-codex` or `gpt-5`), and the name is what appears in the model selector.
|
||||||
|
|
||||||
Global defaults with per-model overrides:
|
### Configuration Files
|
||||||
|
|
||||||
```json
|
This repository includes ready-to-use configuration examples:
|
||||||
{
|
|
||||||
"$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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
In this example:
|
- **[`config/full-opencode.json`](./config/full-opencode.json)** - Complete configuration with 9 model variants (recommended)
|
||||||
- `gpt-5-codex` uses: `reasoningEffort: "medium"`, `reasoningSummary: "detailed"` (overridden), `textVerbosity: "medium"`
|
- **[`config/minimal-opencode.json`](./config/minimal-opencode.json)** - Minimal configuration for basic usage
|
||||||
- `gpt-5` uses all global defaults: `reasoningEffort: "medium"`, `reasoningSummary: "auto"`, `textVerbosity: "medium"`
|
- **[`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
|
## 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
|
1. **Token Management**: Checks and refreshes OAuth tokens if needed
|
||||||
2. **Token Management**: Native token refresh implementation (no external dependencies)
|
2. **URL Rewriting**: Transforms requests to `https://chatgpt.com/backend-api/codex/responses`
|
||||||
3. **Codex Instructions**: Automatically fetches from the latest stable release of [openai/codex](https://github.com/openai/codex)
|
3. **Request Transformation**:
|
||||||
- Tracks latest release tag (not main branch) for stability
|
- Model normalization (`gpt-5-codex` variants → `gpt-5-codex`, `gpt-5` variants → `gpt-5`)
|
||||||
- Uses ETag-based caching for efficient updates (only downloads when content changes)
|
- Injects Codex instructions from latest [openai/codex](https://github.com/openai/codex) release
|
||||||
- Cached locally in `~/.opencode/cache/`
|
- Applies reasoning configuration (effort, summary, verbosity)
|
||||||
- Auto-updates when OpenAI publishes new releases
|
- Adds tool remapping instructions (`apply_patch` → `edit`, `update_plan` → `todowrite`)
|
||||||
- Falls back to bundled version if GitHub is unavailable
|
- Filters conversation history (removes stored IDs for stateless operation)
|
||||||
4. **Request Transformation**: Routes requests to `https://chatgpt.com/backend-api/codex/responses`
|
4. **Headers**: Adds OAuth token and ChatGPT account ID headers
|
||||||
5. **Model Normalization**: Maps all model names to `gpt-5-codex` (the Codex backend model)
|
5. **Request Execution**: Sends to Codex backend API
|
||||||
6. **Tool Remapping**: Injects instructions to map Codex tools to opencode tools:
|
6. **Response Logging**: Optional debug logging (when `ENABLE_PLUGIN_REQUEST_LOGGING=1`)
|
||||||
- `apply_patch` → `edit`
|
7. **Response Handling**: Converts SSE to JSON or returns error details
|
||||||
- `update_plan` → `todowrite`
|
|
||||||
7. **Reasoning Configuration**: Defaults to medium effort and auto summaries (configurable per-model)
|
### Key Features
|
||||||
8. **Encrypted Reasoning**: Includes encrypted reasoning content for stateless multi-turn conversations
|
|
||||||
9. **History Filtering**: Removes stored conversation IDs since Codex uses `store: false`
|
- **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
|
## Limitations
|
||||||
|
|
||||||
@@ -315,14 +426,24 @@ Each request generates 3-4 JSON files:
|
|||||||
|
|
||||||
```
|
```
|
||||||
opencode-openai-codex-auth/
|
opencode-openai-codex-auth/
|
||||||
├── index.mjs # Main plugin entry point
|
├── index.ts # Main plugin entry point
|
||||||
├── lib/
|
├── lib/
|
||||||
│ ├── auth.mjs # OAuth authentication logic
|
│ ├── auth.ts # OAuth authentication logic
|
||||||
│ ├── codex.mjs # Codex instructions & tool remapping
|
│ ├── codex.ts # Codex instructions & tool remapping
|
||||||
│ ├── server.mjs # Local OAuth callback server
|
│ ├── server.ts # Local OAuth callback server
|
||||||
│ ├── logger.mjs # Request logging (debug mode)
|
│ ├── browser.ts # Platform-specific browser opening
|
||||||
│ ├── request-transformer.mjs # Request body transformations
|
│ ├── logger.ts # Request logging (debug mode)
|
||||||
│ └── response-handler.mjs # SSE to JSON conversion
|
│ ├── 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
|
├── package.json
|
||||||
├── README.md
|
├── README.md
|
||||||
└── LICENSE
|
└── LICENSE
|
||||||
@@ -330,13 +451,17 @@ opencode-openai-codex-auth/
|
|||||||
|
|
||||||
### Module Overview
|
### Module Overview
|
||||||
|
|
||||||
- **index.mjs**: Main plugin export and request orchestration
|
- **index.ts**: Main plugin export and fetch() orchestration (7-step flow)
|
||||||
- **lib/auth.mjs**: OAuth flow, PKCE, token exchange, JWT decoding
|
- **lib/auth.ts**: OAuth flow, PKCE, token exchange, JWT decoding
|
||||||
- **lib/codex.mjs**: Fetches/caches Codex instructions from GitHub, tool remapping
|
- **lib/codex.ts**: Fetches/caches Codex instructions from GitHub, tool remapping
|
||||||
- **lib/server.mjs**: Local HTTP server for OAuth callback handling
|
- **lib/server.ts**: Local HTTP server for OAuth callback handling
|
||||||
- **lib/logger.mjs**: Debug logging functionality (controlled by environment variable)
|
- **lib/browser.ts**: Platform detection and browser opening
|
||||||
- **lib/request-transformer.mjs**: Request body transformations (model normalization, tool remapping, reasoning config)
|
- **lib/logger.ts**: Debug logging (controlled by environment variable)
|
||||||
- **lib/response-handler.mjs**: Response handling (SSE to JSON conversion for generateText())
|
- **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
|
## 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 { generatePKCE } from "@openauthjs/openauth/pkce";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
import type { PKCEPair, AuthorizationFlow, TokenResult, ParsedAuthInput, JWTPayload } from "./types.js";
|
||||||
|
|
||||||
// OAuth constants (from openai/codex)
|
// OAuth constants (from openai/codex)
|
||||||
export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
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
|
* 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");
|
return randomBytes(16).toString("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse authorization code and state from user input
|
* Parse authorization code and state from user input
|
||||||
* @param {string} input - User input (URL, code#state, or just code)
|
* @param input - User input (URL, code#state, or just code)
|
||||||
* @returns {{code?: string, state?: string}} Parsed authorization data
|
* @returns Parsed authorization data
|
||||||
*/
|
*/
|
||||||
export function parseAuthorizationInput(input) {
|
export function parseAuthorizationInput(input: string): ParsedAuthInput {
|
||||||
const value = (input || "").trim();
|
const value = (input || "").trim();
|
||||||
if (!value) return {};
|
if (!value) return {};
|
||||||
|
|
||||||
@@ -49,16 +50,16 @@ export function parseAuthorizationInput(input) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Exchange authorization code for access and refresh tokens
|
* Exchange authorization code for access and refresh tokens
|
||||||
* @param {string} code - Authorization code from OAuth flow
|
* @param code - Authorization code from OAuth flow
|
||||||
* @param {string} verifier - PKCE verifier
|
* @param verifier - PKCE verifier
|
||||||
* @param {string} [redirectUri] - OAuth redirect URI
|
* @param redirectUri - OAuth redirect URI
|
||||||
* @returns {Promise<{type: "success", access: string, refresh: string, expires: number} | {type: "failed"}>}
|
* @returns Token result
|
||||||
*/
|
*/
|
||||||
export async function exchangeAuthorizationCode(
|
export async function exchangeAuthorizationCode(
|
||||||
code,
|
code: string,
|
||||||
verifier,
|
verifier: string,
|
||||||
redirectUri = REDIRECT_URI,
|
redirectUri: string = REDIRECT_URI,
|
||||||
) {
|
): Promise<TokenResult> {
|
||||||
const res = await fetch(TOKEN_URL, {
|
const res = await fetch(TOKEN_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
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);
|
console.error("[openai-codex-plugin] code->token failed:", res.status, text);
|
||||||
return { type: "failed" };
|
return { type: "failed" };
|
||||||
}
|
}
|
||||||
const json = await res.json();
|
const json = (await res.json()) as {
|
||||||
|
access_token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
};
|
||||||
if (
|
if (
|
||||||
!json?.access_token ||
|
!json?.access_token ||
|
||||||
!json?.refresh_token ||
|
!json?.refresh_token ||
|
||||||
@@ -94,16 +99,16 @@ export async function exchangeAuthorizationCode(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode a JWT token to extract payload
|
* Decode a JWT token to extract payload
|
||||||
* @param {string} token - JWT token to decode
|
* @param token - JWT token to decode
|
||||||
* @returns {any} Decoded payload or null if invalid
|
* @returns Decoded payload or null if invalid
|
||||||
*/
|
*/
|
||||||
export function decodeJWT(token) {
|
export function decodeJWT(token: string): JWTPayload | null {
|
||||||
try {
|
try {
|
||||||
const parts = token.split(".");
|
const parts = token.split(".");
|
||||||
if (parts.length !== 3) return null;
|
if (parts.length !== 3) return null;
|
||||||
const payload = parts[1];
|
const payload = parts[1];
|
||||||
const decoded = Buffer.from(payload, "base64").toString("utf-8");
|
const decoded = Buffer.from(payload, "base64").toString("utf-8");
|
||||||
return JSON.parse(decoded);
|
return JSON.parse(decoded) as JWTPayload;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -111,10 +116,10 @@ export function decodeJWT(token) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh access token using refresh token
|
* Refresh access token using refresh token
|
||||||
* @param {string} refreshToken - Refresh token
|
* @param refreshToken - Refresh token
|
||||||
* @returns {Promise<{type: "success", access: string, refresh: string, expires: number} | {type: "failed"}>}
|
* @returns Token result
|
||||||
*/
|
*/
|
||||||
export async function refreshAccessToken(refreshToken) {
|
export async function refreshAccessToken(refreshToken: string): Promise<TokenResult> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(TOKEN_URL, {
|
const response = await fetch(TOKEN_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -136,7 +141,11 @@ export async function refreshAccessToken(refreshToken) {
|
|||||||
return { type: "failed" };
|
return { type: "failed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = await response.json();
|
const json = (await response.json()) as {
|
||||||
|
access_token?: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
};
|
||||||
if (
|
if (
|
||||||
!json?.access_token ||
|
!json?.access_token ||
|
||||||
!json?.refresh_token ||
|
!json?.refresh_token ||
|
||||||
@@ -156,17 +165,18 @@ export async function refreshAccessToken(refreshToken) {
|
|||||||
expires: Date.now() + json.expires_in * 1000,
|
expires: Date.now() + json.expires_in * 1000,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} 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" };
|
return { type: "failed" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create OAuth authorization flow
|
* Create OAuth authorization flow
|
||||||
* @returns {Promise<{pkce: any, state: string, url: string}>}
|
* @returns Authorization flow details
|
||||||
*/
|
*/
|
||||||
export async function createAuthorizationFlow() {
|
export async function createAuthorizationFlow(): Promise<AuthorizationFlow> {
|
||||||
const pkce = await generatePKCE();
|
const pkce = (await generatePKCE()) as PKCEPair;
|
||||||
const state = createState();
|
const state = createState();
|
||||||
|
|
||||||
const url = new URL(AUTHORIZE_URL);
|
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 { join, dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
|
import type { GitHubRelease, CacheMetadata } from "./types.js";
|
||||||
|
|
||||||
// Codex instructions constants
|
// Codex instructions constants
|
||||||
const GITHUB_API_RELEASES = "https://api.github.com/repos/openai/codex/releases/latest";
|
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
|
* 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);
|
const response = await fetch(GITHUB_API_RELEASES);
|
||||||
if (!response.ok) throw new Error(`Failed to fetch latest release: ${response.status}`);
|
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;
|
return data.tag_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,19 +28,19 @@ async function getLatestReleaseTag() {
|
|||||||
* Fetch Codex instructions from GitHub with ETag-based caching
|
* Fetch Codex instructions from GitHub with ETag-based caching
|
||||||
* Uses HTTP conditional requests to efficiently check for updates
|
* Uses HTTP conditional requests to efficiently check for updates
|
||||||
* Always fetches from the latest release tag, not main branch
|
* 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 {
|
try {
|
||||||
// Get the latest release tag
|
// Get the latest release tag
|
||||||
const latestTag = await getLatestReleaseTag();
|
const latestTag = await getLatestReleaseTag();
|
||||||
const CODEX_INSTRUCTIONS_URL = `https://raw.githubusercontent.com/openai/codex/${latestTag}/codex-rs/core/gpt_5_codex_prompt.md`;
|
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)
|
// Load cached metadata (includes ETag and tag)
|
||||||
let cachedETag = null;
|
let cachedETag: string | null = null;
|
||||||
let cachedTag = null;
|
let cachedTag: string | null = null;
|
||||||
if (existsSync(CACHE_METADATA_FILE)) {
|
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;
|
cachedETag = metadata.etag;
|
||||||
cachedTag = metadata.tag;
|
cachedTag = metadata.tag;
|
||||||
}
|
}
|
||||||
@@ -50,7 +51,7 @@ export async function getCodexInstructions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Make conditional request with If-None-Match header
|
// Make conditional request with If-None-Match header
|
||||||
const headers = {};
|
const headers: Record<string, string> = {};
|
||||||
if (cachedETag) {
|
if (cachedETag) {
|
||||||
headers["If-None-Match"] = cachedETag;
|
headers["If-None-Match"] = cachedETag;
|
||||||
}
|
}
|
||||||
@@ -84,7 +85,7 @@ export async function getCodexInstructions() {
|
|||||||
tag: latestTag,
|
tag: latestTag,
|
||||||
lastChecked: Date.now(),
|
lastChecked: Date.now(),
|
||||||
url: CODEX_INSTRUCTIONS_URL,
|
url: CODEX_INSTRUCTIONS_URL,
|
||||||
}),
|
} satisfies CacheMetadata),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -93,9 +94,10 @@ export async function getCodexInstructions() {
|
|||||||
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
throw new Error(`HTTP ${response.status}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
console.error(
|
console.error(
|
||||||
"[openai-codex-plugin] Failed to fetch instructions from GitHub:",
|
"[openai-codex-plugin] Failed to fetch instructions from GitHub:",
|
||||||
error.message,
|
err.message,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Try to use cached version even if stale
|
// 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";
|
import { homedir } from "node:os";
|
||||||
|
|
||||||
// Logging configuration
|
// 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");
|
const LOG_DIR = join(homedir(), ".opencode", "logs", "codex-plugin");
|
||||||
|
|
||||||
// Log startup message about logging state
|
// Log startup message about logging state
|
||||||
@@ -15,10 +15,10 @@ let requestCounter = 0;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Log request data to file (only when LOGGING_ENABLED is true)
|
* 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 stage - The stage of the request (e.g., "before-transform", "after-transform")
|
||||||
* @param {object} data - The data to log
|
* @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
|
// Only log if explicitly enabled via environment variable
|
||||||
if (!LOGGING_ENABLED) return;
|
if (!LOGGING_ENABLED) return;
|
||||||
|
|
||||||
@@ -48,8 +48,7 @@ export function logRequest(stage, data) {
|
|||||||
);
|
);
|
||||||
console.log(`[openai-codex-plugin] Logged ${stage} to ${filename}`);
|
console.log(`[openai-codex-plugin] Logged ${stage} to ${filename}`);
|
||||||
} catch (e) {
|
} 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
|
* Normalize model name to Codex-supported variants
|
||||||
* @param {string} model - Original model name
|
* @param model - Original model name
|
||||||
* @returns {string} Normalized model name
|
* @returns Normalized model name
|
||||||
*/
|
*/
|
||||||
export function normalizeModel(model) {
|
export function normalizeModel(model: string | undefined): string {
|
||||||
if (!model) return "gpt-5";
|
if (!model) return "gpt-5";
|
||||||
|
|
||||||
if (model.includes("codex")) {
|
if (model.includes("codex")) {
|
||||||
@@ -21,11 +22,11 @@ export function normalizeModel(model) {
|
|||||||
/**
|
/**
|
||||||
* Extract configuration for a specific model
|
* Extract configuration for a specific model
|
||||||
* Merges global options with model-specific options (model-specific takes precedence)
|
* Merges global options with model-specific options (model-specific takes precedence)
|
||||||
* @param {string} modelName - Model name (e.g., "gpt-5-codex")
|
* @param modelName - Model name (e.g., "gpt-5-codex")
|
||||||
* @param {object} userConfig - Full user configuration object
|
* @param userConfig - Full user configuration object
|
||||||
* @returns {object} Merged configuration for this model
|
* @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 globalOptions = userConfig.global || {};
|
||||||
const modelOptions = userConfig.models?.[modelName]?.options || {};
|
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
|
* - opencode explicitly excludes gpt-5-codex from automatic reasoning configuration
|
||||||
* - Codex CLI has been thoroughly tested against this backend
|
* - Codex CLI has been thoroughly tested against this backend
|
||||||
*
|
*
|
||||||
* @param {string} originalModel - Original model name before normalization
|
* @param originalModel - Original model name before normalization
|
||||||
* @param {object} userConfig - User configuration object
|
* @param userConfig - User configuration object
|
||||||
* @returns {object} Reasoning configuration
|
* @returns Reasoning configuration
|
||||||
*/
|
*/
|
||||||
export function getReasoningConfig(originalModel, userConfig = {}) {
|
export function getReasoningConfig(originalModel: string | undefined, userConfig: ConfigOptions = {}): ReasoningConfig {
|
||||||
const isLightweight =
|
const isLightweight =
|
||||||
originalModel?.includes("nano") || originalModel?.includes("mini");
|
originalModel?.includes("nano") || originalModel?.includes("mini");
|
||||||
const isCodex = originalModel?.includes("codex");
|
const isCodex = originalModel?.includes("codex");
|
||||||
|
|
||||||
// Default based on model type (Codex CLI defaults)
|
// 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
|
// Get user-requested effort
|
||||||
let effort = userConfig.reasoningEffort || defaultEffort;
|
let effort = userConfig.reasoningEffort || defaultEffort;
|
||||||
@@ -71,10 +72,10 @@ export function getReasoningConfig(originalModel, userConfig = {}) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Filter input array to remove stored conversation history references
|
* Filter input array to remove stored conversation history references
|
||||||
* @param {Array} input - Original input array
|
* @param input - Original input array
|
||||||
* @returns {Array} Filtered input array
|
* @returns Filtered input array
|
||||||
*/
|
*/
|
||||||
export function filterInput(input) {
|
export function filterInput(input: InputItem[] | undefined): InputItem[] | undefined {
|
||||||
if (!Array.isArray(input)) return input;
|
if (!Array.isArray(input)) return input;
|
||||||
|
|
||||||
return input.filter((item) => {
|
return input.filter((item) => {
|
||||||
@@ -88,14 +89,14 @@ export function filterInput(input) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Add tool remapping message to input if tools are present
|
* Add tool remapping message to input if tools are present
|
||||||
* @param {Array} input - Input array
|
* @param input - Input array
|
||||||
* @param {boolean} hasTools - Whether tools are present in request
|
* @param hasTools - Whether tools are present in request
|
||||||
* @returns {Array} Input array with tool remap message prepended if needed
|
* @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;
|
if (!hasTools || !Array.isArray(input)) return input;
|
||||||
|
|
||||||
const toolRemapMessage = {
|
const toolRemapMessage: InputItem = {
|
||||||
type: "message",
|
type: "message",
|
||||||
role: "developer",
|
role: "developer",
|
||||||
content: [
|
content: [
|
||||||
@@ -117,12 +118,16 @@ export function addToolRemapMessage(input, hasTools) {
|
|||||||
* - opencode excludes gpt-5-codex from reasoning configuration
|
* - opencode excludes gpt-5-codex from reasoning configuration
|
||||||
* - This plugin uses store=false (stateless), requiring encrypted reasoning content
|
* - This plugin uses store=false (stateless), requiring encrypted reasoning content
|
||||||
*
|
*
|
||||||
* @param {object} body - Original request body
|
* @param body - Original request body
|
||||||
* @param {string} codexInstructions - Codex system instructions
|
* @param codexInstructions - Codex system instructions
|
||||||
* @param {object} userConfig - User configuration from loader
|
* @param userConfig - User configuration from loader
|
||||||
* @returns {object} Transformed request body
|
* @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 originalModel = body.model;
|
||||||
const normalizedModel = normalizeModel(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
|
* Parse SSE stream to extract final response
|
||||||
* @param {string} sseText - Complete SSE stream text
|
* @param sseText - Complete SSE stream text
|
||||||
* @returns {object|null} Final response object or null if not found
|
* @returns Final response object or null if not found
|
||||||
*/
|
*/
|
||||||
function parseSseStream(sseText) {
|
function parseSseStream(sseText: string): unknown | null {
|
||||||
const lines = sseText.split('\n');
|
const lines = sseText.split('\n');
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
try {
|
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
|
// Look for response.done event with final data
|
||||||
if (data.type === 'response.done' || data.type === 'response.completed') {
|
if (data.type === 'response.done' || data.type === 'response.completed') {
|
||||||
@@ -28,11 +29,14 @@ function parseSseStream(sseText) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert SSE stream response to JSON for generateText()
|
* Convert SSE stream response to JSON for generateText()
|
||||||
* @param {Response} response - Fetch response with SSE stream
|
* @param response - Fetch response with SSE stream
|
||||||
* @param {Headers} headers - Response headers
|
* @param headers - Response headers
|
||||||
* @returns {Promise<Response>} Response with JSON body
|
* @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 reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let fullText = '';
|
let fullText = '';
|
||||||
@@ -83,10 +87,10 @@ export async function convertSseToJson(response, headers) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure response has content-type header
|
* Ensure response has content-type header
|
||||||
* @param {Headers} headers - Response headers
|
* @param headers - Response headers
|
||||||
* @returns {Headers} Headers with content-type set
|
* @returns Headers with content-type set
|
||||||
*/
|
*/
|
||||||
export function ensureContentType(headers) {
|
export function ensureContentType(headers: Headers): Headers {
|
||||||
const responseHeaders = new Headers(headers);
|
const responseHeaders = new Headers(headers);
|
||||||
|
|
||||||
if (!responseHeaders.has('content-type')) {
|
if (!responseHeaders.has('content-type')) {
|
||||||
@@ -1,14 +1,22 @@
|
|||||||
import http from "node:http";
|
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
|
* Start a small local HTTP server that waits for /auth/callback and returns the code
|
||||||
* @param {{state: string}} options - OAuth state for validation
|
* @param options - OAuth state for validation
|
||||||
* @returns {Promise<{port: number, close: () => void, waitForCode: () => Promise<{code: string} | null>}>}
|
* @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) => {
|
const server = http.createServer((req, res) => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(req.url, "http://localhost");
|
const url = new URL(req.url || "", "http://localhost");
|
||||||
if (url.pathname !== "/auth/callback") {
|
if (url.pathname !== "/auth/callback") {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.end("Not found");
|
res.end("Not found");
|
||||||
@@ -27,15 +35,14 @@ export function startLocalOAuthServer({ state }) {
|
|||||||
}
|
}
|
||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||||
res.end(
|
res.end(successHtml);
|
||||||
"<html><body>OpenAI ChatGPT login complete. You can close this window.</body></html>",
|
(server as http.Server & { _lastCode?: string })._lastCode = code;
|
||||||
);
|
|
||||||
server._lastCode = code;
|
|
||||||
} catch {
|
} catch {
|
||||||
res.statusCode = 500;
|
res.statusCode = 500;
|
||||||
res.end("Internal error");
|
res.end("Internal error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
server
|
server
|
||||||
.listen(1455, "127.0.0.1", () => {
|
.listen(1455, "127.0.0.1", () => {
|
||||||
@@ -43,16 +50,17 @@ export function startLocalOAuthServer({ state }) {
|
|||||||
port: 1455,
|
port: 1455,
|
||||||
close: () => server.close(),
|
close: () => server.close(),
|
||||||
waitForCode: async () => {
|
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++) {
|
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();
|
await poll();
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.on("error", (err) => {
|
.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
console.error(
|
console.error(
|
||||||
"[openai-codex-plugin] Failed to bind http://127.0.0.1:1455 (",
|
"[openai-codex-plugin] Failed to bind http://127.0.0.1:1455 (",
|
||||||
err?.code,
|
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",
|
"name": "opencode-openai-codex-auth",
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"description": "OpenAI ChatGPT (Codex backend) OAuth auth plugin for opencode - use your ChatGPT Plus/Pro subscription instead of API credits",
|
"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",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "Numman Ali",
|
"author": "Numman Ali",
|
||||||
@@ -26,18 +27,33 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/numman-ali/opencode-openai-codex-auth/issues"
|
"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": [
|
"files": [
|
||||||
"index.mjs",
|
"dist/",
|
||||||
"lib/",
|
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"peerDependencies": {
|
||||||
"@opencode-ai/plugin": "^0.13.7"
|
"@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": {
|
"dependencies": {
|
||||||
"@openauthjs/openauth": "^0.4.3"
|
"@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