From 76e37531301b6a15f016a3c0aa41d820ee089f2b Mon Sep 17 00:00:00 2001 From: Numman Ali Date: Sun, 4 Jan 2026 14:04:13 +0000 Subject: [PATCH 1/4] Fix prompt filtering, tool orphan handling, and auth fallback --- README.md | 3 +- docs/development/ARCHITECTURE.md | 15 ++- docs/getting-started.md | 2 + docs/index.md | 2 + docs/troubleshooting.md | 5 + index.ts | 57 ++++++-- lib/constants.ts | 3 + lib/logger.ts | 18 +-- lib/request/helpers/input-utils.ts | 210 +++++++++++++++++++++++++++++ lib/request/request-transformer.ts | 94 ++----------- test/request-transformer.test.ts | 130 +++++++++++++++--- 11 files changed, 411 insertions(+), 128 deletions(-) create mode 100644 lib/request/helpers/input-utils.ts diff --git a/README.md b/README.md index 6eef8ec..21b3a65 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,8 @@ Codex backend caching is enabled automatically. When OpenCode supplies a `prompt opencode auth login ``` -Select "OpenAI" → "ChatGPT Plus/Pro (Codex Subscription)" +Select "OpenAI" → "ChatGPT Plus/Pro (Codex Subscription)" +If you're on SSH/WSL/remote and the browser callback fails, choose **"ChatGPT Plus/Pro (Manual URL Paste)"** and paste the full redirect URL. > **⚠️ First-time setup**: Stop Codex CLI if running (both use port 1455) diff --git a/docs/development/ARCHITECTURE.md b/docs/development/ARCHITECTURE.md index 630def6..8cc13da 100644 --- a/docs/development/ARCHITECTURE.md +++ b/docs/development/ARCHITECTURE.md @@ -277,19 +277,25 @@ let include: Vec = if reasoning.is_some() { 5. System Prompt Handling (CODEX_MODE) ├─ Filter out OpenCode system prompts + ├─ Preserve OpenCode env + AGENTS instructions when concatenated └─ Add Codex-OpenCode bridge prompt -6. Reasoning Configuration +6. Orphan Tool Output Handling + ├─ Match function_call_output to function_call OR local_shell_call + ├─ Match custom_tool_call_output to custom_tool_call + └─ Convert unmatched outputs to assistant messages (preserve context) + +7. Reasoning Configuration ├─ Set reasoningEffort (minimal/low/medium/high) ├─ Set reasoningSummary (auto/detailed) └─ Based on model variant -7. Prompt Caching & Session Headers +8. Prompt Caching & Session Headers ├─ Preserve host-supplied prompt_cache_key (OpenCode session id) ├─ Add conversation + account headers for Codex debugging when cache key exists └─ Leave headers unset if host does not provide a cache key -8. Final Body +9. Final Body ├─ store: false ├─ stream: true ├─ instructions: Codex system prompt @@ -323,7 +329,8 @@ let include: Vec = if reasoning.is_some() { | Feature | Codex CLI | This Plugin | Why? | |---------|-----------|-------------|------| | **Codex-OpenCode Bridge** | N/A (native) | ✅ Custom prompt | OpenCode → Codex translation | -| **OpenCode Prompt Filtering** | N/A | ✅ Filter & replace | Remove OpenCode-specific prompts | +| **OpenCode Prompt Filtering** | N/A | ✅ Filter & replace | Remove OpenCode prompts, keep env/AGENTS | +| **Orphan Tool Output Handling** | ✅ Drop orphans | ✅ Convert to messages | Preserve context + avoid 400s | | **Usage-limit messaging** | CLI prints status | ✅ Friendly error summary | Surface 5h/weekly windows in OpenCode | | **Per-Model Options** | CLI flags | ✅ Config file | Better UX in OpenCode | | **Custom Model Names** | No | ✅ Display names | UI convenience | diff --git a/docs/getting-started.md b/docs/getting-started.md index 249b5ec..3a23d54 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -295,6 +295,8 @@ opencode auth login **⚠️ Important**: If you have the official Codex CLI running, stop it first (both use port 1455 for OAuth callback). +**Manual fallback**: On SSH/WSL/remote environments, pick **"ChatGPT Plus/Pro (Manual URL Paste)"** and paste the full redirect URL after login. + ### Step 3: Test It ```bash diff --git a/docs/index.md b/docs/index.md index 35e934c..8eaa20c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -51,6 +51,8 @@ opencode opencode auth login ``` +If the browser callback fails (SSH/WSL/remote), choose **"ChatGPT Plus/Pro (Manual URL Paste)"** and paste the full redirect URL. + ### Updating **⚠️ OpenCode does NOT auto-update plugins** diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1b72743..a926d2a 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -52,6 +52,11 @@ date +%s000 # Current timestamp in milliseconds # The auth URL is shown in console - copy and paste to browser manually ``` +**1a. Manual URL Paste login:** +- Re-run `opencode auth login` +- Select **"ChatGPT Plus/Pro (Manual URL Paste)"** +- Paste the full redirect URL after login + **2. Check port 1455 availability:** ```bash # See if something is using the OAuth callback port diff --git a/index.ts b/index.ts index 061ee3b..9b224bf 100644 --- a/index.ts +++ b/index.ts @@ -28,6 +28,7 @@ import { createAuthorizationFlow, decodeJWT, exchangeAuthorizationCode, + parseAuthorizationInput, REDIRECT_URI, } from "./lib/auth/auth.js"; import { openBrowserUrl } from "./lib/auth/browser.js"; @@ -45,7 +46,7 @@ import { PLUGIN_NAME, PROVIDER_ID, } from "./lib/constants.js"; -import { logRequest } from "./lib/logger.js"; +import { logRequest, logDebug } from "./lib/logger.js"; import { createCodexHeaders, extractRequestUrl, @@ -103,10 +104,12 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { 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 {}; - } + if (!accountId) { + logDebug( + `[${PLUGIN_NAME}] ${ERROR_MESSAGES.NO_ACCOUNT_ID} (skipping plugin)`, + ); + return {}; + } // Extract user configuration (global + per-model options) const providerConfig = provider as | { options?: Record; models?: UserConfig["models"] } @@ -211,10 +214,10 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { }, }; }, - methods: [ - { - label: AUTH_LABELS.OAUTH, - type: "oauth" as const, + methods: [ + { + label: AUTH_LABELS.OAUTH, + type: "oauth" as const, /** * OAuth authorization flow * @@ -258,11 +261,37 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { }, }; }, - }, - { - label: AUTH_LABELS.API_KEY, - type: "api" as const, - }, + }, + { + label: AUTH_LABELS.OAUTH_MANUAL, + type: "oauth" as const, + authorize: async () => { + const { pkce, state, url } = await createAuthorizationFlow(); + return { + url, + method: "code" as const, + instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL, + callback: async (input: string) => { + const parsed = parseAuthorizationInput(input); + if (!parsed.code) { + return { type: "failed" as const }; + } + const tokens = await exchangeAuthorizationCode( + parsed.code, + pkce.verifier, + REDIRECT_URI, + ); + return tokens?.type === "success" + ? tokens + : { type: "failed" as const }; + }, + }; + }, + }, + { + label: AUTH_LABELS.API_KEY, + type: "api" as const, + }, ], }, }; diff --git a/lib/constants.ts b/lib/constants.ts index f38b75d..471d874 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -70,6 +70,9 @@ export const PLATFORM_OPENERS = { /** OAuth authorization labels */ export const AUTH_LABELS = { OAUTH: "ChatGPT Plus/Pro (Codex Subscription)", + OAUTH_MANUAL: "ChatGPT Plus/Pro (Manual URL Paste)", API_KEY: "Manually enter API Key", INSTRUCTIONS: "A browser window should open. Complete login to finish.", + INSTRUCTIONS_MANUAL: + "After logging in, copy the full redirect URL and paste it here.", } as const; diff --git a/lib/logger.ts b/lib/logger.ts index 974ff1f..65b631c 100644 --- a/lib/logger.ts +++ b/lib/logger.ts @@ -1,6 +1,7 @@ import { writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; +import { PLUGIN_NAME } from "./constants.js"; // Logging configuration export const LOGGING_ENABLED = process.env.ENABLE_PLUGIN_REQUEST_LOGGING === "1"; @@ -9,10 +10,10 @@ const LOG_DIR = join(homedir(), ".opencode", "logs", "codex-plugin"); // Log startup message about logging state if (LOGGING_ENABLED) { - console.log("[openai-codex-plugin] Request logging ENABLED - logs will be saved to:", LOG_DIR); + console.log(`[${PLUGIN_NAME}] Request logging ENABLED - logs will be saved to:`, LOG_DIR); } if (DEBUG_ENABLED && !LOGGING_ENABLED) { - console.log("[openai-codex-plugin] Debug logging ENABLED"); + console.log(`[${PLUGIN_NAME}] Debug logging ENABLED`); } let requestCounter = 0; @@ -50,10 +51,10 @@ export function logRequest(stage: string, data: Record): void { ), "utf8", ); - console.log(`[openai-codex-plugin] Logged ${stage} to ${filename}`); + console.log(`[${PLUGIN_NAME}] Logged ${stage} to ${filename}`); } catch (e) { const error = e as Error; - console.error("[openai-codex-plugin] Failed to write log:", error.message); + console.error(`[${PLUGIN_NAME}] Failed to write log:`, error.message); } } @@ -66,9 +67,9 @@ export function logDebug(message: string, data?: unknown): void { if (!DEBUG_ENABLED) return; if (data !== undefined) { - console.log(`[openai-codex-plugin] ${message}`, data); + console.log(`[${PLUGIN_NAME}] ${message}`, data); } else { - console.log(`[openai-codex-plugin] ${message}`); + console.log(`[${PLUGIN_NAME}] ${message}`); } } @@ -78,9 +79,10 @@ export function logDebug(message: string, data?: unknown): void { * @param data - Optional data to log */ export function logWarn(message: string, data?: unknown): void { + if (!DEBUG_ENABLED && !LOGGING_ENABLED) return; if (data !== undefined) { - console.warn(`[openai-codex-plugin] ${message}`, data); + console.warn(`[${PLUGIN_NAME}] ${message}`, data); } else { - console.warn(`[openai-codex-plugin] ${message}`); + console.warn(`[${PLUGIN_NAME}] ${message}`); } } diff --git a/lib/request/helpers/input-utils.ts b/lib/request/helpers/input-utils.ts new file mode 100644 index 0000000..036f249 --- /dev/null +++ b/lib/request/helpers/input-utils.ts @@ -0,0 +1,210 @@ +import type { InputItem } from "../../types.js"; + +const OPENCODE_PROMPT_SIGNATURES = [ + "you are a coding agent running in the opencode", + "you are opencode, an agent", + "you are opencode, an interactive cli agent", + "you are opencode, an interactive cli tool", + "you are opencode, the best coding agent on the planet", +].map((signature) => signature.toLowerCase()); + +const OPENCODE_CONTEXT_MARKERS = [ + "here is some useful information about the environment you are running in:", + "", + "instructions from:", + "", +].map((marker) => marker.toLowerCase()); + +export const getContentText = (item: InputItem): string => { + if (typeof item.content === "string") { + return item.content; + } + if (Array.isArray(item.content)) { + return item.content + .filter((c) => c.type === "input_text" && c.text) + .map((c) => c.text) + .join("\n"); + } + return ""; +}; + +const replaceContentText = (item: InputItem, contentText: string): InputItem => { + if (typeof item.content === "string") { + return { ...item, content: contentText }; + } + if (Array.isArray(item.content)) { + return { + ...item, + content: [{ type: "input_text", text: contentText }], + }; + } + return { ...item, content: contentText }; +}; + +const extractOpenCodeContext = (contentText: string): string | null => { + const lower = contentText.toLowerCase(); + let earliestIndex = -1; + + for (const marker of OPENCODE_CONTEXT_MARKERS) { + const index = lower.indexOf(marker); + if (index >= 0 && (earliestIndex === -1 || index < earliestIndex)) { + earliestIndex = index; + } + } + + if (earliestIndex === -1) return null; + return contentText.slice(earliestIndex).trimStart(); +}; + +export function isOpenCodeSystemPrompt( + item: InputItem, + cachedPrompt: string | null, +): boolean { + const isSystemRole = item.role === "developer" || item.role === "system"; + if (!isSystemRole) return false; + + const contentText = getContentText(item); + if (!contentText) return false; + + if (cachedPrompt) { + const contentTrimmed = contentText.trim(); + const cachedTrimmed = cachedPrompt.trim(); + if (contentTrimmed === cachedTrimmed) { + return true; + } + + if (contentTrimmed.startsWith(cachedTrimmed)) { + return true; + } + + const contentPrefix = contentTrimmed.substring(0, 200); + const cachedPrefix = cachedTrimmed.substring(0, 200); + if (contentPrefix === cachedPrefix) { + return true; + } + } + + const normalized = contentText.trimStart().toLowerCase(); + return OPENCODE_PROMPT_SIGNATURES.some((signature) => + normalized.startsWith(signature), + ); +} + +export function filterOpenCodeSystemPromptsWithCachedPrompt( + input: InputItem[] | undefined, + cachedPrompt: string | null, +): InputItem[] | undefined { + if (!Array.isArray(input)) return input; + + return input.flatMap((item) => { + if (item.role === "user") return [item]; + + if (!isOpenCodeSystemPrompt(item, cachedPrompt)) { + return [item]; + } + + const contentText = getContentText(item); + const preservedContext = extractOpenCodeContext(contentText); + if (preservedContext) { + return [replaceContentText(item, preservedContext)]; + } + + return []; + }); +} + +const getCallId = (item: InputItem): string | null => { + const rawCallId = (item as { call_id?: unknown }).call_id; + if (typeof rawCallId !== "string") return null; + const trimmed = rawCallId.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const convertOrphanedOutputToMessage = ( + item: InputItem, + callId: string | null, +): InputItem => { + const toolName = + typeof (item as { name?: unknown }).name === "string" + ? ((item as { name?: string }).name as string) + : "tool"; + const labelCallId = callId ?? "unknown"; + let text: string; + try { + const out = (item as { output?: unknown }).output; + text = typeof out === "string" ? out : JSON.stringify(out); + } catch { + text = String((item as { output?: unknown }).output ?? ""); + } + if (text.length > 16000) { + text = text.slice(0, 16000) + "\n...[truncated]"; + } + return { + type: "message", + role: "assistant", + content: `[Previous ${toolName} result; call_id=${labelCallId}]: ${text}`, + } as InputItem; +}; + +const collectCallIds = (input: InputItem[]) => { + const functionCallIds = new Set(); + const localShellCallIds = new Set(); + const customToolCallIds = new Set(); + + for (const item of input) { + const callId = getCallId(item); + if (!callId) continue; + switch (item.type) { + case "function_call": + functionCallIds.add(callId); + break; + case "local_shell_call": + localShellCallIds.add(callId); + break; + case "custom_tool_call": + customToolCallIds.add(callId); + break; + default: + break; + } + } + + return { functionCallIds, localShellCallIds, customToolCallIds }; +}; + +export const normalizeOrphanedToolOutputs = ( + input: InputItem[], +): InputItem[] => { + const { functionCallIds, localShellCallIds, customToolCallIds } = + collectCallIds(input); + + return input.map((item) => { + if (item.type === "function_call_output") { + const callId = getCallId(item); + const hasMatch = + !!callId && + (functionCallIds.has(callId) || localShellCallIds.has(callId)); + if (!hasMatch) { + return convertOrphanedOutputToMessage(item, callId); + } + } + + if (item.type === "custom_tool_call_output") { + const callId = getCallId(item); + const hasMatch = !!callId && customToolCallIds.has(callId); + if (!hasMatch) { + return convertOrphanedOutputToMessage(item, callId); + } + } + + if (item.type === "local_shell_call_output") { + const callId = getCallId(item); + const hasMatch = !!callId && localShellCallIds.has(callId); + if (!hasMatch) { + return convertOrphanedOutputToMessage(item, callId); + } + } + + return item; + }); +}; diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 05705ed..46fcf35 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -3,6 +3,10 @@ import { TOOL_REMAP_MESSAGE } from "../prompts/codex.js"; import { CODEX_OPENCODE_BRIDGE } from "../prompts/codex-opencode-bridge.js"; import { getOpenCodeCodexPrompt } from "../prompts/opencode-codex.js"; import { getNormalizedModel } from "./helpers/model-map.js"; +import { + filterOpenCodeSystemPromptsWithCachedPrompt, + normalizeOrphanedToolOutputs, +} from "./helpers/input-utils.js"; import type { ConfigOptions, InputItem, @@ -11,6 +15,11 @@ import type { UserConfig, } from "../types.js"; +export { + isOpenCodeSystemPrompt, + filterOpenCodeSystemPromptsWithCachedPrompt, +} from "./helpers/input-utils.js"; + /** * Normalize model name to Codex-supported variants * @@ -274,56 +283,6 @@ export function filterInput( }); } -/** - * Check if an input item is the OpenCode system prompt - * Uses cached OpenCode codex.txt for verification with fallback to text matching - * @param item - Input item to check - * @param cachedPrompt - Cached OpenCode codex.txt content - * @returns True if this is the OpenCode system prompt - */ -export function isOpenCodeSystemPrompt( - item: InputItem, - cachedPrompt: string | null, -): boolean { - const isSystemRole = item.role === "developer" || item.role === "system"; - if (!isSystemRole) return false; - - const getContentText = (item: InputItem): string => { - if (typeof item.content === "string") { - return item.content; - } - if (Array.isArray(item.content)) { - return item.content - .filter((c) => c.type === "input_text" && c.text) - .map((c) => c.text) - .join("\n"); - } - return ""; - }; - - const contentText = getContentText(item); - if (!contentText) return false; - - // Primary check: Compare against cached OpenCode prompt - if (cachedPrompt) { - // Exact match (trim whitespace for comparison) - if (contentText.trim() === cachedPrompt.trim()) { - return true; - } - - // Partial match: Check if first 200 chars match (handles minor variations) - const contentPrefix = contentText.trim().substring(0, 200); - const cachedPrefix = cachedPrompt.trim().substring(0, 200); - if (contentPrefix === cachedPrefix) { - return true; - } - } - - // Fallback check: Known OpenCode prompt signature (for safety) - // This catches the prompt even if cache fails - return contentText.startsWith("You are a coding agent running in"); -} - /** * Filter out OpenCode system prompts from input * Used in CODEX_MODE to replace OpenCode prompts with Codex-OpenCode bridge @@ -344,12 +303,7 @@ export async function filterOpenCodeSystemPrompts( // This is safe because we still have the "starts with" check } - return input.filter((item) => { - // Keep user messages - if (item.role === "user") return true; - // Filter out OpenCode system prompts - return !isOpenCodeSystemPrompt(item, cachedPrompt); - }); + return filterOpenCodeSystemPromptsWithCachedPrompt(input, cachedPrompt); } /** @@ -495,33 +449,7 @@ export async function transformRequestBody( // Instead of removing orphans (which causes infinite loops as LLM loses tool results), // convert them to messages to preserve context while avoiding API errors if (body.input) { - const functionCallIds = new Set( - body.input - .filter((item) => item.type === "function_call" && item.call_id) - .map((item) => item.call_id), - ); - body.input = body.input.map((item) => { - if (item.type === "function_call_output" && !functionCallIds.has(item.call_id)) { - const toolName = typeof (item as any).name === "string" ? (item as any).name : "tool"; - const callId = (item as any).call_id ?? ""; - let text: string; - try { - const out = (item as any).output; - text = typeof out === "string" ? out : JSON.stringify(out); - } catch { - text = String((item as any).output ?? ""); - } - if (text.length > 16000) { - text = text.slice(0, 16000) + "\n...[truncated]"; - } - return { - type: "message", - role: "assistant", - content: `[Previous ${toolName} result; call_id=${callId}]: ${text}`, - } as InputItem; - } - return item; - }); + body.input = normalizeOrphanedToolOutputs(body.input); } } diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index e0fced8..84cb22d 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -6,6 +6,7 @@ import { addToolRemapMessage, isOpenCodeSystemPrompt, filterOpenCodeSystemPrompts, + filterOpenCodeSystemPromptsWithCachedPrompt, addCodexBridgeMessage, transformRequestBody, } from '../lib/request/request-transformer.js'; @@ -378,7 +379,7 @@ describe('Request Transformer Module', () => { const item: InputItem = { type: 'message', role: 'developer', - content: 'You are a coding agent running in OpenCode', + content: 'You are a coding agent running in the opencode, a terminal-based coding assistant.', }; expect(isOpenCodeSystemPrompt(item, null)).toBe(true); }); @@ -390,7 +391,7 @@ describe('Request Transformer Module', () => { content: [ { type: 'input_text', - text: 'You are a coding agent running in OpenCode', + text: 'You are a coding agent running in the opencode, a terminal-based coding assistant.', }, ], }; @@ -401,7 +402,7 @@ describe('Request Transformer Module', () => { const item: InputItem = { type: 'message', role: 'system', - content: 'You are a coding agent running in OpenCode', + content: 'You are a coding agent running in the opencode, a terminal-based coding assistant.', }; expect(isOpenCodeSystemPrompt(item, null)).toBe(true); }); @@ -410,7 +411,7 @@ describe('Request Transformer Module', () => { const item: InputItem = { type: 'message', role: 'user', - content: 'You are a coding agent running in OpenCode', + content: 'You are a coding agent running in the opencode, a terminal-based coding assistant.', }; expect(isOpenCodeSystemPrompt(item, null)).toBe(false); }); @@ -443,26 +444,35 @@ describe('Request Transformer Module', () => { }); it('should NOT detect content with codex signature in the middle', async () => { - const cachedPrompt = 'You are a coding agent running in OpenCode.'; + const cachedPrompt = 'You are a coding agent running in the opencode.'; const item: InputItem = { type: 'message', role: 'developer', // Has codex.txt content but with environment prepended (like OpenCode does) - content: 'Environment info here\n\nYou are a coding agent running in OpenCode.', + content: 'Environment info here\n\nYou are a coding agent running in the opencode.', }; // First 200 chars won't match because of prepended content expect(isOpenCodeSystemPrompt(item, cachedPrompt)).toBe(false); }); it('should detect with cached prompt exact match', async () => { - const cachedPrompt = 'You are a coding agent running in OpenCode'; + const cachedPrompt = 'You are a coding agent running in the opencode'; const item: InputItem = { type: 'message', role: 'developer', - content: 'You are a coding agent running in OpenCode', + content: 'You are a coding agent running in the opencode', }; expect(isOpenCodeSystemPrompt(item, cachedPrompt)).toBe(true); }); + + it('should detect alternative OpenCode prompt signatures', async () => { + const item: InputItem = { + type: 'message', + role: 'developer', + content: "You are opencode, an agent - please keep going until the user's query is completely resolved.", + }; + expect(isOpenCodeSystemPrompt(item, null)).toBe(true); + }); }); describe('filterOpenCodeSystemPrompts', () => { @@ -471,11 +481,11 @@ describe('Request Transformer Module', () => { { type: 'message', role: 'developer', - content: 'You are a coding agent running in OpenCode', + content: 'You are a coding agent running in the opencode', }, { type: 'message', role: 'user', content: 'hello' }, ]; - const result = await filterOpenCodeSystemPrompts(input); + const result = filterOpenCodeSystemPromptsWithCachedPrompt(input, null); expect(result).toHaveLength(1); expect(result![0].role).toBe('user'); }); @@ -485,7 +495,7 @@ describe('Request Transformer Module', () => { { type: 'message', role: 'user', content: 'message 1' }, { type: 'message', role: 'user', content: 'message 2' }, ]; - const result = await filterOpenCodeSystemPrompts(input); + const result = filterOpenCodeSystemPromptsWithCachedPrompt(input, null); expect(result).toHaveLength(2); }); @@ -494,7 +504,7 @@ describe('Request Transformer Module', () => { { type: 'message', role: 'developer', content: 'Custom instruction' }, { type: 'message', role: 'user', content: 'hello' }, ]; - const result = await filterOpenCodeSystemPrompts(input); + const result = filterOpenCodeSystemPromptsWithCachedPrompt(input, null); expect(result).toHaveLength(2); }); @@ -503,7 +513,7 @@ describe('Request Transformer Module', () => { { type: 'message', role: 'developer', - content: 'You are a coding agent running in OpenCode', // This is codex.txt + content: 'You are a coding agent running in the opencode', // This is codex.txt }, { type: 'message', @@ -512,19 +522,44 @@ describe('Request Transformer Module', () => { }, { type: 'message', role: 'user', content: 'hello' }, ]; - const result = await filterOpenCodeSystemPrompts(input); + const result = filterOpenCodeSystemPromptsWithCachedPrompt(input, null); // Should filter codex.txt but keep AGENTS.md expect(result).toHaveLength(2); expect(result![0].content).toContain('AGENTS.md'); expect(result![1].role).toBe('user'); }); + it('should strip OpenCode prompt but keep concatenated env/instructions', async () => { + const input: InputItem[] = [ + { + type: 'message', + role: 'developer', + content: [ + 'You are a coding agent running in the opencode, a terminal-based coding assistant.', + 'Here is some useful information about the environment you are running in:', + '', + ' Working directory: /path/to/project', + '', + 'Instructions from: /path/to/AGENTS.md', + '# Project Guidelines', + ].join('\n'), + }, + { type: 'message', role: 'user', content: 'hello' }, + ]; + const result = filterOpenCodeSystemPromptsWithCachedPrompt(input, null); + expect(result).toHaveLength(2); + const preserved = String(result![0].content); + expect(preserved).toContain('Here is some useful information about the environment'); + expect(preserved).toContain('Instructions from: /path/to/AGENTS.md'); + expect(preserved).not.toContain('You are a coding agent running in the opencode'); + }); + it('should keep environment+AGENTS.md concatenated message', async () => { const input: InputItem[] = [ { type: 'message', role: 'developer', - content: 'You are a coding agent running in OpenCode', // codex.txt alone + content: 'You are a coding agent running in the opencode', // codex.txt alone }, { type: 'message', @@ -534,7 +569,7 @@ describe('Request Transformer Module', () => { }, { type: 'message', role: 'user', content: 'hello' }, ]; - const result = await filterOpenCodeSystemPrompts(input); + const result = filterOpenCodeSystemPromptsWithCachedPrompt(input, null); // Should filter first message (codex.txt) but keep second (env+AGENTS.md) expect(result).toHaveLength(2); expect(result![0].content).toContain('AGENTS.md'); @@ -1006,6 +1041,65 @@ describe('Request Transformer Module', () => { expect(result.input![2].type).toBe('function_call_output'); }); + it('should treat local_shell_call as a match for function_call_output', async () => { + const body: RequestBody = { + model: 'gpt-5-codex', + input: [ + { type: 'message', role: 'user', content: 'hello' }, + { + type: 'local_shell_call', + call_id: 'shell_call', + action: { type: 'exec', command: ['ls'] }, + } as any, + { type: 'function_call_output', call_id: 'shell_call', output: 'ok' } as any, + ], + }; + + const result = await transformRequestBody(body, codexInstructions); + + expect(result.input).toHaveLength(3); + expect(result.input![1].type).toBe('local_shell_call'); + expect(result.input![2].type).toBe('function_call_output'); + }); + + it('should keep matching custom_tool_call_output items', async () => { + const body: RequestBody = { + model: 'gpt-5-codex', + input: [ + { type: 'message', role: 'user', content: 'hello' }, + { + type: 'custom_tool_call', + call_id: 'custom_call', + name: 'mcp_tool', + input: '{}', + } as any, + { type: 'custom_tool_call_output', call_id: 'custom_call', output: 'done' } as any, + ], + }; + + const result = await transformRequestBody(body, codexInstructions); + + expect(result.input).toHaveLength(3); + expect(result.input![1].type).toBe('custom_tool_call'); + expect(result.input![2].type).toBe('custom_tool_call_output'); + }); + + it('should convert orphaned custom_tool_call_output to message', async () => { + const body: RequestBody = { + model: 'gpt-5-codex', + input: [ + { type: 'message', role: 'user', content: 'hello' }, + { type: 'custom_tool_call_output', call_id: 'orphan_custom', output: 'oops' } as any, + ], + }; + + const result = await transformRequestBody(body, codexInstructions); + + expect(result.input).toHaveLength(2); + expect(result.input![1].type).toBe('message'); + expect(result.input![1].content).toContain('[Previous tool result; call_id=orphan_custom]'); + }); + describe('CODEX_MODE parameter', () => { it('should use bridge message when codexMode=true and tools present (default)', async () => { const body: RequestBody = { @@ -1027,7 +1121,7 @@ describe('Request Transformer Module', () => { { type: 'message', role: 'developer', - content: 'You are a coding agent running in OpenCode', + content: 'You are a coding agent running in the opencode', }, { type: 'message', role: 'user', content: 'hello' }, ], @@ -1073,7 +1167,7 @@ describe('Request Transformer Module', () => { { type: 'message', role: 'developer', - content: 'You are a coding agent running in OpenCode', + content: 'You are a coding agent running in the opencode', }, { type: 'message', role: 'user', content: 'hello' }, ], From a8c13950f03d86aa2ecab92fc31ae2f929830ac0 Mon Sep 17 00:00:00 2001 From: Numman Ali Date: Sun, 4 Jan 2026 19:40:10 +0000 Subject: [PATCH 2/4] Handle headless OAuth, retryable 404s, and error handling --- docs/troubleshooting.md | 10 +++ index.ts | 54 ++++++++------- lib/auth/browser.ts | 45 ++++++++++++- lib/auth/server.ts | 2 + lib/constants.ts | 5 +- lib/request/fetch-helpers.ts | 123 ++++++++++++----------------------- lib/types.ts | 1 + test/browser.test.ts | 22 ++++++- test/fetch-helpers.test.ts | 89 ++++++++++++++++++------- 9 files changed, 213 insertions(+), 138 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a926d2a..3d0d7d4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -67,6 +67,16 @@ lsof -i :1455 - Stop Codex CLI if running - Both use port 1455 for OAuth +### "Invalid Session" or "Authorization session expired" + +**Symptoms:** +- Browser shows: `Your authorization session was not initialized or has expired` + +**Solutions:** +- Re-run `opencode auth login` to generate a fresh URL +- Open the **"Go to"** URL directly in your browser (don’t use a stale link) +- If you’re on SSH/WSL/remote, choose **"ChatGPT Plus/Pro (Manual URL Paste)"** + ### "403 Forbidden" Error **Cause**: ChatGPT subscription issue diff --git a/index.ts b/index.ts index 9b224bf..20ce21b 100644 --- a/index.ts +++ b/index.ts @@ -75,6 +75,23 @@ import type { UserConfig } from "./lib/types.js"; * ``` */ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { + const buildManualOAuthFlow = (pkce: { verifier: string }, url: string) => ({ + url, + method: "code" as const, + instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL, + callback: async (input: string) => { + const parsed = parseAuthorizationInput(input); + if (!parsed.code) { + return { type: "failed" as const }; + } + const tokens = await exchangeAuthorizationCode( + parsed.code, + pkce.verifier, + REDIRECT_URI, + ); + return tokens?.type === "success" ? tokens : { type: "failed" as const }; + }, + }); return { auth: { provider: PROVIDER_ID, @@ -148,15 +165,9 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { init?: RequestInit, ): Promise { // Step 1: Check and refresh token if needed - const currentAuth = await getAuth(); + let currentAuth = await getAuth(); if (shouldRefreshToken(currentAuth)) { - const refreshResult = await refreshAndUpdateToken( - currentAuth, - client, - ); - if (!refreshResult.success) { - return refreshResult.response; - } + currentAuth = await refreshAndUpdateToken(currentAuth, client); } // Step 2: Extract and rewrite URL for Codex backend @@ -237,6 +248,11 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { // Attempt to open browser automatically openBrowserUrl(url); + if (!serverInfo.ready) { + serverInfo.close(); + return buildManualOAuthFlow(pkce, url); + } + return { url, method: "auto" as const, @@ -266,26 +282,8 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => { label: AUTH_LABELS.OAUTH_MANUAL, type: "oauth" as const, authorize: async () => { - const { pkce, state, url } = await createAuthorizationFlow(); - return { - url, - method: "code" as const, - instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL, - callback: async (input: string) => { - const parsed = parseAuthorizationInput(input); - if (!parsed.code) { - return { type: "failed" as const }; - } - const tokens = await exchangeAuthorizationCode( - parsed.code, - pkce.verifier, - REDIRECT_URI, - ); - return tokens?.type === "success" - ? tokens - : { type: "failed" as const }; - }, - }; + const { pkce, url } = await createAuthorizationFlow(); + return buildManualOAuthFlow(pkce, url); }, }, { diff --git a/lib/auth/browser.ts b/lib/auth/browser.ts index 1024c28..aa4f355 100644 --- a/lib/auth/browser.ts +++ b/lib/auth/browser.ts @@ -4,6 +4,8 @@ */ import { spawn } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; import { PLATFORM_OPENERS } from "../constants.js"; /** @@ -17,19 +19,58 @@ export function getBrowserOpener(): string { return PLATFORM_OPENERS.linux; } +function commandExists(command: string): boolean { + if (!command) return false; + + // "start" is a shell builtin on Windows; rely on shell execution + if (process.platform === "win32" && command.toLowerCase() === "start") { + return true; + } + + const pathValue = process.env.PATH || ""; + const entries = pathValue.split(path.delimiter).filter(Boolean); + if (entries.length === 0) return false; + + if (process.platform === "win32") { + const pathext = (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM") + .split(";") + .filter(Boolean); + for (const entry of entries) { + for (const ext of pathext) { + const candidate = path.join(entry, `${command}${ext}`); + if (fs.existsSync(candidate)) return true; + } + } + return false; + } + + for (const entry of entries) { + const candidate = path.join(entry, command); + if (fs.existsSync(candidate)) return true; + } + return false; +} + /** * Opens a URL in the default browser * Silently fails if browser cannot be opened (user can copy URL manually) * @param url - URL to open + * @returns True if a browser launch was attempted */ -export function openBrowserUrl(url: string): void { +export function openBrowserUrl(url: string): boolean { try { const opener = getBrowserOpener(); - spawn(opener, [url], { + if (!commandExists(opener)) { + return false; + } + const child = spawn(opener, [url], { stdio: "ignore", shell: process.platform === "win32", }); + child.on("error", () => {}); + return true; } catch (error) { // Silently fail - user can manually open the URL from instructions + return false; } } diff --git a/lib/auth/server.ts b/lib/auth/server.ts index 03b7d37..1a3dcb7 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -48,6 +48,7 @@ export function startLocalOAuthServer({ state }: { state: string }): Promise { resolve({ port: 1455, + ready: true, close: () => server.close(), waitForCode: async () => { const poll = () => new Promise((r) => setTimeout(r, 100)); @@ -68,6 +69,7 @@ export function startLocalOAuthServer({ state }: { state: string }): Promise { try { server.close(); diff --git a/lib/constants.ts b/lib/constants.ts index 471d874..0df6dfc 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -19,6 +19,8 @@ export const PROVIDER_ID = "openai"; export const HTTP_STATUS = { OK: 200, UNAUTHORIZED: 401, + NOT_FOUND: 404, + TOO_MANY_REQUESTS: 429, } as const; /** OpenAI-specific headers */ @@ -72,7 +74,8 @@ export const AUTH_LABELS = { OAUTH: "ChatGPT Plus/Pro (Codex Subscription)", OAUTH_MANUAL: "ChatGPT Plus/Pro (Manual URL Paste)", API_KEY: "Manually enter API Key", - INSTRUCTIONS: "A browser window should open. Complete login to finish.", + INSTRUCTIONS: + "A browser window should open. If it doesn't, copy the URL and open it manually.", INSTRUCTIONS_MANUAL: "After logging in, copy the full redirect URL and paste it here.", } as const; diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index 6690c4b..4389d4c 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -34,26 +34,17 @@ export function shouldRefreshToken(auth: Auth): boolean { * 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 + * @returns Updated auth (throws on failure) */ export async function refreshAndUpdateToken( currentAuth: Auth, client: OpencodeClient, -): Promise< - { success: true; auth: Auth } | { success: false; response: Response } -> { +): Promise { 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 }, - ), - }; + throw new Error(ERROR_MESSAGES.TOKEN_REFRESH_FAILED); } // Update stored credentials @@ -74,7 +65,7 @@ export async function refreshAndUpdateToken( currentAuth.expires = refreshResult.expires; } - return { success: true, auth: currentAuth }; + return currentAuth; } /** @@ -207,74 +198,20 @@ export function createCodexHeaders( /** * Handles error responses from the Codex API * @param response - Error response from API - * @returns Response with error details + * @returns Original response or mapped retryable response */ export async function handleErrorResponse( response: Response, ): Promise { - const raw = await response.text(); + const mapped = await mapUsageLimit404(response); + const finalResponse = mapped ?? response; - let enriched = raw; - try { - const parsed = JSON.parse(raw) as any; - const err = parsed?.error ?? {}; - - // Parse Codex rate-limit headers if present - const h = response.headers; - const primary = { - used_percent: toNumber(h.get("x-codex-primary-used-percent")), - window_minutes: toInt(h.get("x-codex-primary-window-minutes")), - resets_at: toInt(h.get("x-codex-primary-reset-at")), - }; - const secondary = { - used_percent: toNumber(h.get("x-codex-secondary-used-percent")), - window_minutes: toInt(h.get("x-codex-secondary-window-minutes")), - resets_at: toInt(h.get("x-codex-secondary-reset-at")), - }; - const rate_limits = - primary.used_percent !== undefined || secondary.used_percent !== undefined - ? { primary, secondary } - : undefined; - - // Friendly message for subscription/rate usage limits - const code = (err.code ?? err.type ?? "").toString(); - const resetsAt = err.resets_at ?? primary.resets_at ?? secondary.resets_at; - const mins = resetsAt ? Math.max(0, Math.round((resetsAt * 1000 - Date.now()) / 60000)) : undefined; - let friendly_message: string | undefined; - if (/usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code) || response.status === 429) { - const plan = err.plan_type ? ` (${String(err.plan_type).toLowerCase()} plan)` : ""; - const when = mins !== undefined ? ` Try again in ~${mins} min.` : ""; - friendly_message = `You have hit your ChatGPT usage limit${plan}.${when}`.trim(); - } - - const enhanced = { - error: { - ...err, - message: err.message ?? friendly_message ?? "Usage limit reached.", - friendly_message, - rate_limits, - status: response.status, - }, - }; - enriched = JSON.stringify(enhanced); - } catch { - // Raw body not JSON; leave unchanged - enriched = raw; - } - - console.error(`[${PLUGIN_NAME}] ${response.status} error:`, enriched); logRequest(LOG_STAGES.ERROR_RESPONSE, { - status: response.status, - error: enriched, + status: finalResponse.status, + statusText: finalResponse.statusText, }); - const headers = new Headers(response.headers); - headers.set("content-type", "application/json; charset=utf-8"); - return new Response(enriched, { - status: response.status, - statusText: response.statusText, - headers, - }); + return finalResponse; } /** @@ -304,13 +241,35 @@ export async function handleSuccessResponse( }); } -function toNumber(v: string | null): number | undefined { - if (v == null) return undefined; - const n = Number(v); - return Number.isFinite(n) ? n : undefined; -} -function toInt(v: string | null): number | undefined { - if (v == null) return undefined; - const n = parseInt(v, 10); - return Number.isFinite(n) ? n : undefined; +async function mapUsageLimit404(response: Response): Promise { + if (response.status !== HTTP_STATUS.NOT_FOUND) return null; + + const clone = response.clone(); + let text = ""; + try { + text = await clone.text(); + } catch { + text = ""; + } + if (!text) return null; + + let code = ""; + try { + const parsed = JSON.parse(text) as any; + code = (parsed?.error?.code ?? parsed?.error?.type ?? "").toString(); + } catch { + code = ""; + } + + const haystack = `${code} ${text}`.toLowerCase(); + if (!/usage_limit_reached|usage_not_included|rate_limit_exceeded|usage limit/i.test(haystack)) { + return null; + } + + const headers = new Headers(response.headers); + return new Response(response.body, { + status: HTTP_STATUS.TOO_MANY_REQUESTS, + statusText: "Too Many Requests", + headers, + }); } diff --git a/lib/types.ts b/lib/types.ts index 80c8b02..fdb8723 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -46,6 +46,7 @@ export interface ReasoningConfig { */ export interface OAuthServerInfo { port: number; + ready: boolean; close: () => void; waitForCode: (state: string) => Promise<{ code: string } | null>; } diff --git a/test/browser.test.ts b/test/browser.test.ts index 6fceab3..c094d5c 100644 --- a/test/browser.test.ts +++ b/test/browser.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getBrowserOpener } from '../lib/auth/browser.js'; +import { getBrowserOpener, openBrowserUrl } from '../lib/auth/browser.js'; import { PLATFORM_OPENERS } from '../lib/constants.js'; describe('Browser Module', () => { @@ -32,4 +32,24 @@ describe('Browser Module', () => { Object.defineProperty(process, 'platform', { value: originalPlatform }); }); }); + + describe('openBrowserUrl', () => { + it('should return false when opener command is missing', () => { + const originalPlatform = process.platform; + const originalPath = process.env.PATH; + + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env.PATH = ''; + + const result = openBrowserUrl('https://example.com'); + expect(result).toBe(false); + + Object.defineProperty(process, 'platform', { value: originalPlatform }); + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + }); + }); }); diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index d6ff687..54a21e0 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as authModule from '../lib/auth/auth.js'; import { shouldRefreshToken, + refreshAndUpdateToken, extractRequestUrl, rewriteUrlForCodex, createCodexHeaders, @@ -10,6 +12,10 @@ import type { Auth } from '../lib/types.js'; import { URL_PATHS, OPENAI_HEADERS, OPENAI_HEADER_VALUES } from '../lib/constants.js'; describe('Fetch Helpers Module', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + describe('shouldRefreshToken', () => { it('should return true for non-oauth auth', () => { const auth: Auth = { type: 'api', key: 'test-key' }; @@ -42,6 +48,42 @@ describe('Fetch Helpers Module', () => { }); }); + describe('refreshAndUpdateToken', () => { + it('throws when refresh fails', async () => { + const auth: Auth = { type: 'oauth', access: 'old', refresh: 'bad', expires: 0 }; + const client = { auth: { set: vi.fn() } } as any; + vi.spyOn(authModule, 'refreshAccessToken').mockResolvedValue({ type: 'failed' } as any); + + await expect(refreshAndUpdateToken(auth, client)).rejects.toThrow(); + }); + + it('updates stored auth on success', async () => { + const auth: Auth = { type: 'oauth', access: 'old', refresh: 'oldr', expires: 0 }; + const client = { auth: { set: vi.fn() } } as any; + vi.spyOn(authModule, 'refreshAccessToken').mockResolvedValue({ + type: 'success', + access: 'new', + refresh: 'newr', + expires: 123, + } as any); + + const updated = await refreshAndUpdateToken(auth, client); + + expect(client.auth.set).toHaveBeenCalledWith({ + path: { id: 'openai' }, + body: { + type: 'oauth', + access: 'new', + refresh: 'newr', + expires: 123, + }, + }); + expect(updated.access).toBe('new'); + expect(updated.refresh).toBe('newr'); + expect(updated.expires).toBe(123); + }); + }); + describe('extractRequestUrl', () => { it('should extract URL from string', () => { const url = 'https://example.com/test'; @@ -93,29 +135,28 @@ describe('Fetch Helpers Module', () => { expect(headers.get('accept')).toBe('text/event-stream'); }); - it('enriches usage limit errors with friendly message and rate limits', async () => { - const body = { - error: { - code: 'usage_limit_reached', - message: 'limit reached', - plan_type: 'pro', - }, - }; - const headers = new Headers({ - 'x-codex-primary-used-percent': '75', - 'x-codex-primary-window-minutes': '300', - 'x-codex-primary-reset-at': String(Math.floor(Date.now() / 1000) + 1800), - }); - const resp = new Response(JSON.stringify(body), { status: 429, headers }); - const enriched = await handleErrorResponse(resp); - expect(enriched.status).toBe(429); - const json = await enriched.json() as any; - expect(json.error).toBeTruthy(); - expect(json.error.friendly_message).toMatch(/usage limit/i); - expect(json.error.rate_limits.primary.used_percent).toBe(75); - expect(json.error.rate_limits.primary.window_minutes).toBe(300); - expect(typeof json.error.rate_limits.primary.resets_at).toBe('number'); - }); + it('maps usage-limit 404 errors to 429', async () => { + const body = { + error: { + code: 'usage_limit_reached', + message: 'limit reached', + }, + }; + const resp = new Response(JSON.stringify(body), { status: 404 }); + const mapped = await handleErrorResponse(resp); + expect(mapped.status).toBe(429); + const json = await mapped.json() as any; + expect(json.error.code).toBe('usage_limit_reached'); + }); + + it('leaves non-usage 404 errors unchanged', async () => { + const body = { error: { code: 'not_found', message: 'nope' } }; + const resp = new Response(JSON.stringify(body), { status: 404 }); + const result = await handleErrorResponse(resp); + expect(result.status).toBe(404); + const json = await result.json() as any; + expect(json.error.code).toBe('not_found'); + }); it('should remove x-api-key header', () => { const init = { headers: { 'x-api-key': 'should-be-removed' } } as any; From dccdd6e3f7ad9e56c099020f5b0bf40b80c4a2d1 Mon Sep 17 00:00:00 2001 From: Numman Ali Date: Sun, 4 Jan 2026 22:20:44 +0000 Subject: [PATCH 3/4] Add variant config support and modern presets --- CHANGELOG.md | 8 +- README.md | 134 +++++----- config/README.md | 110 +++++--- ...ull-opencode.json => opencode-legacy.json} | 0 config/opencode-modern.json | 239 ++++++++++++++++++ docs/DOCUMENTATION.md | 3 +- docs/configuration.md | 13 +- docs/development/ARCHITECTURE.md | 2 +- docs/development/CONFIG_FLOW.md | 2 +- docs/getting-started.md | 2 +- docs/index.md | 5 +- lib/prompts/opencode-codex.ts | 2 +- lib/request/request-transformer.ts | 59 ++++- lib/types.ts | 6 + scripts/test-all-models.sh | 20 +- test/README.md | 3 +- test/request-transformer.test.ts | 75 ++++++ 17 files changed, 545 insertions(+), 138 deletions(-) rename config/{full-opencode.json => opencode-legacy.json} (100%) create mode 100644 config/opencode-modern.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ce9697f..75776ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ All notable changes to this project are documented here. Dates use the ISO forma ### Changed - **Prompt selection alignment**: GPT 5.2 general now uses `gpt_5_2_prompt.md` (Codex CLI parity). - **Reasoning configuration**: GPT 5.2 Codex supports `xhigh` but does **not** support `"none"`; `"none"` auto-upgrades to `"low"` and `"minimal"` normalizes to `"low"`. -- **Config presets**: `config/full-opencode.json` now includes 22 pre-configured variants (adds GPT 5.2 Codex). +- **Config presets**: `config/opencode-legacy.json` includes the 22 pre-configured presets (adds GPT 5.2 Codex); `config/opencode-modern.json` provides the variant-based setup. - **Docs**: Updated README/AGENTS/config docs to include GPT 5.2 Codex and new model family behavior. ## [4.1.1] - 2025-12-17 @@ -161,12 +161,12 @@ This release brings full parity with Codex CLI's prompt engineering: ## [3.2.0] - 2025-11-14 ### Added -- GPT 5.1 model family support: normalization for `gpt-5.1`, `gpt-5.1-codex`, and `gpt-5.1-codex-mini` plus new GPT 5.1-only presets in the canonical `config/full-opencode.json`. +- GPT 5.1 model family support: normalization for `gpt-5.1`, `gpt-5.1-codex`, and `gpt-5.1-codex-mini` plus new GPT 5.1-only presets in the canonical `config/opencode-legacy.json`. - Documentation updates (README, docs, AGENTS) describing the 5.1 families, their reasoning defaults, and how they map to ChatGPT slugs and token limits. ### Changed - Model normalization docs and tests now explicitly cover both 5.0 and 5.1 Codex/general families and the two Codex Mini tiers. -- The legacy GPT 5.0 full configuration is now published as `config/full-opencode-gpt5.json`; new installs should prefer the 5.1 presets. +- The legacy GPT 5.0 full configuration is now published separately; new installs should prefer the 5.1 presets in `config/opencode-legacy.json`. ## [3.1.0] - 2025-11-11 ### Added @@ -179,7 +179,7 @@ This release brings full parity with Codex CLI's prompt engineering: ## [3.0.0] - 2025-11-04 ### Added - Codex-style usage-limit messaging that mirrors the 5-hour and weekly windows reported by the Codex CLI. -- Documentation guidance noting that OpenCode's context auto-compaction and usage sidebar require the canonical `config/full-opencode.json`. +- Documentation guidance noting that OpenCode's context auto-compaction and usage sidebar require the canonical `config/opencode-legacy.json`. ### Changed - Prompt caching now relies solely on the host-supplied `prompt_cache_key`; conversation/session headers are forwarded only when OpenCode provides one. diff --git a/README.md b/README.md index 21b3a65..d882fee 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Follow me on [X @nummanthinks](https://x.com/nummanthinks) for future updates an - ✅ **ChatGPT Plus/Pro OAuth authentication** - Use your existing subscription - ✅ **22 pre-configured model variants** - GPT 5.2, GPT 5.2 Codex, GPT 5.1, GPT 5.1 Codex, GPT 5.1 Codex Max, and GPT 5.1 Codex Mini presets for all reasoning levels +- ✅ **OpenCode variants system support** - Works with v1.0.210+ variant cycling (Ctrl+T) and legacy per-model presets - ✅ **GPT 5.2 + GPT 5.2 Codex support** - Latest models with `low/medium/high/xhigh` reasoning levels (Codex excludes `none`) - ✅ **Full image input support** - All models configured with multimodal capabilities for reading screenshots, diagrams, and images - ⚠️ **GPT 5.1+ only** - Older GPT 5.0 models are deprecated and may not work reliably @@ -101,7 +102,7 @@ If you’re using an AI agent (Codex/Claude/etc.) to install or update this plug 1. **Use the official repo**: https://github.com/numman-ali/opencode-openai-codex-auth 2. **Pin the plugin version** in the user’s OpenCode config (`~/.config/opencode/opencode.json` or project `.opencode.json`). -3. **Replace the config** with the latest `config/full-opencode.json` from the repo. +3. **Replace the config** with the latest `config/opencode-modern.json` (OpenCode v1.0.210+) or `config/opencode-legacy.json` (v1.0.209 and below). 4. **Refresh the plugin cache** so OpenCode reinstalls the updated version. 5. **Restart OpenCode**. @@ -109,35 +110,36 @@ If you’re using an AI agent (Codex/Claude/etc.) to install or update this plug # 1) Update plugin version (replace with newest release tag) # Example: opencode-openai-codex-auth@4.2.0 -# 2) Copy full config -cp /config/full-opencode.json ~/.config/opencode/opencode.json +# 2) Copy config (choose based on your OpenCode version) +# opencode --version +# Modern (v1.0.210+): +cp /config/opencode-modern.json ~/.config/opencode/opencode.json +# Legacy (v1.0.209 and below): +cp /config/opencode-legacy.json ~/.config/opencode/opencode.json # 3) Refresh OpenCode plugin cache rm -rf ~/.cache/opencode/node_modules ~/.cache/opencode/bun.lock -# 4) Optional sanity check for GPT-5.2-Codex presets -jq '.provider.openai.models | keys | map(select(startswith("gpt-5.2-codex")))' \ - ~/.config/opencode/opencode.json +# 4) Optional sanity check for GPT-5.2 models +jq '.provider.openai.models | keys' ~/.config/opencode/opencode.json ``` > **Note**: If using a project-local config, replace the target path with `/.opencode.json`. --- -#### ⚠️ REQUIRED: Full Configuration (Only Supported Setup) +#### ⚠️ REQUIRED: Use the Supported Configuration -**IMPORTANT**: You MUST use the full configuration from [`config/full-opencode.json`](./config/full-opencode.json). Other configurations are not officially supported and may not work reliably. +**Pick the config file that matches your OpenCode version:** +- **OpenCode v1.0.210+** → `config/opencode-modern.json` (variants system) +- **OpenCode v1.0.209 and below** → `config/opencode-legacy.json` (legacy per-variant model list) -**Why the full config is required:** -- GPT 5 models can be temperamental - some work, some don't, some may error -- The full config has been tested and verified to work -- Minimal configs lack proper model metadata for OpenCode features +**Why this is required:** +- GPT 5 models can be temperamental and need proper configuration +- Full model metadata is required for OpenCode features (limits, usage widgets, compaction) - Older GPT 5.0 models are deprecated and being phased out by OpenAI -1. **Copy the full configuration** from [`config/full-opencode.json`](./config/full-opencode.json) to your opencode config file. - - The config includes 22 models with image input support. Here's a condensed example showing the structure: - +**Modern config (variants) example:** ```json { "$schema": "https://opencode.ai/config.json", @@ -152,52 +154,44 @@ jq '.provider.openai.models | keys | map(select(startswith("gpt-5.2-codex")))' \ "store": false }, "models": { - "gpt-5.2-high": { - "name": "GPT 5.2 High (OAuth)", - "limit": { "context": 272000, "output": 128000 }, - "modalities": { "input": ["text", "image"], "output": ["text"] }, - "options": { - "reasoningEffort": "high", - "reasoningSummary": "detailed", - "textVerbosity": "medium", - "include": ["reasoning.encrypted_content"], - "store": false - } - }, - "gpt-5.1-codex-max-high": { - "name": "GPT 5.1 Codex Max High (OAuth)", + "gpt-5.2": { + "name": "GPT 5.2 (OAuth)", "limit": { "context": 272000, "output": 128000 }, "modalities": { "input": ["text", "image"], "output": ["text"] }, - "options": { - "reasoningEffort": "high", - "reasoningSummary": "detailed", - "textVerbosity": "medium", - "include": ["reasoning.encrypted_content"], - "store": false + "variants": { + "low": { "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "medium" }, + "high": { "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "medium" } } } - // ... 20 more models - see config/full-opencode.json for complete list } } } } ``` - **⚠️ Copy the complete file** from [`config/full-opencode.json`](./config/full-opencode.json) - don't use this truncated example. +**Usage (modern config):** +```bash +opencode run "task" --model=openai/gpt-5.2 --variant=medium +opencode run "task" --model=openai/gpt-5.2 --variant=high +``` - **Global config**: `~/.config/opencode/opencode.json` - **Project config**: `/.opencode.json` +**Usage (legacy config):** +```bash +opencode run "task" --model=openai/gpt-5.2-medium +opencode run "task" --model=openai/gpt-5.2-high +``` - This gives you 22 model variants with different reasoning levels: - - **gpt-5.2** (none/low/medium/high/xhigh) - Latest GPT 5.2 model with full reasoning support - - **gpt-5.2-codex** (low/medium/high/xhigh) - GPT 5.2 Codex presets - - **gpt-5.1-codex-max** (low/medium/high/xhigh) - Codex Max presets - - **gpt-5.1-codex** (low/medium/high) - Codex model presets - - **gpt-5.1-codex-mini** (medium/high) - Codex mini tier presets - - **gpt-5.1** (none/low/medium/high) - General-purpose reasoning presets +This gives you 22 model variants with different reasoning levels: +- **gpt-5.2** (none/low/medium/high/xhigh) - Latest GPT 5.2 model with full reasoning support +- **gpt-5.2-codex** (low/medium/high/xhigh) - GPT 5.2 Codex presets +- **gpt-5.1-codex-max** (low/medium/high/xhigh) - Codex Max presets +- **gpt-5.1-codex** (low/medium/high) - Codex model presets +- **gpt-5.1-codex-mini** (medium/high) - Codex mini tier presets +- **gpt-5.1** (none/low/medium/high) - General-purpose reasoning presets - All appear in the opencode model selector as "GPT 5.1 Codex Low (OAuth)", "GPT 5.1 High (OAuth)", etc. +All appear in the opencode model selector as "GPT 5.1 Codex Low (OAuth)", "GPT 5.1 High (OAuth)", etc. +> **⚠️ IMPORTANT:** Use the config file above. Minimal configs are NOT supported and may fail unpredictably. ### Prompt caching & usage limits Codex backend caching is enabled automatically. When OpenCode supplies a `prompt_cache_key` (its session identifier), the plugin forwards it unchanged so Codex can reuse work between turns. The plugin no longer synthesizes its own cache IDs—if the host omits `prompt_cache_key`, Codex will treat the turn as uncached. The bundled CODEX_MODE bridge prompt is synchronized with the latest Codex CLI release, so opencode and Codex stay in lock-step on tool availability. When your ChatGPT subscription nears a limit, opencode surfaces the plugin's friendly error message with the 5-hour and weekly windows, mirroring the Codex CLI summary. @@ -245,27 +239,25 @@ If you're on SSH/WSL/remote and the browser callback fails, choose **"ChatGPT Pl ## Usage -If using the full configuration, select from the model picker in opencode, or specify via command line: +If using the supported configuration, select from the model picker in opencode, or specify via command line. ```bash -# Use different reasoning levels for gpt-5.1-codex -opencode run "simple task" --model=openai/gpt-5.1-codex-low -opencode run "complex task" --model=openai/gpt-5.1-codex-high -opencode run "large refactor" --model=openai/gpt-5.1-codex-max-high -opencode run "research-grade analysis" --model=openai/gpt-5.1-codex-max-xhigh +# Modern config (v1.0.210+): use --variant +opencode run "simple task" --model=openai/gpt-5.1-codex --variant=low +opencode run "complex task" --model=openai/gpt-5.1-codex --variant=high +opencode run "large refactor" --model=openai/gpt-5.1-codex-max --variant=high +opencode run "research-grade analysis" --model=openai/gpt-5.1-codex-max --variant=xhigh -# Use different reasoning levels for gpt-5.1 +# Legacy config: use model names opencode run "quick question" --model=openai/gpt-5.1-low opencode run "deep analysis" --model=openai/gpt-5.1-high - -# Use Codex Mini variants -opencode run "balanced task" --model=openai/gpt-5.1-codex-mini-medium -opencode run "complex code" --model=openai/gpt-5.1-codex-mini-high ``` -### Available Model Variants (Full Config) +### Available Model Variants (Legacy Config) -When using [`config/full-opencode.json`](./config/full-opencode.json), you get these pre-configured variants: +When using [`config/opencode-legacy.json`](./config/opencode-legacy.json), you get these pre-configured variants: + +For the modern config (`opencode-modern.json`), use the same variant names via `--variant` or `Ctrl+T` in the TUI (e.g., `--model=openai/gpt-5.2 --variant=high`). | CLI Model ID | TUI Display Name | Reasoning Effort | Best For | |--------------|------------------|-----------------|----------| @@ -299,7 +291,7 @@ When using [`config/full-opencode.json`](./config/full-opencode.json), you get t > > **Note**: GPT 5.2, GPT 5.2 Codex, and Codex Max all support `xhigh` reasoning. Use explicit reasoning levels (e.g., `gpt-5.2-high`, `gpt-5.2-codex-xhigh`, `gpt-5.1-codex-max-xhigh`) for precise control. -> **⚠️ Important**: GPT 5 models can be temperamental - some variants may work better than others, some may give errors, and behavior may vary. Stick to the presets above configured in `full-opencode.json` for best results. +> **⚠️ Important**: GPT 5 models can be temperamental - some variants may work better than others, some may give errors, and behavior may vary. Stick to the presets above configured in `opencode-legacy.json` or the variants in `opencode-modern.json` for best results. All accessed via your ChatGPT Plus/Pro subscription. @@ -339,19 +331,21 @@ These defaults are tuned for Codex CLI-style usage and can be customized (see Co ## Configuration -### ⚠️ REQUIRED: Use Pre-Configured File +### ⚠️ REQUIRED: Use a Supported Config File + +Choose the config file that matches your OpenCode version: -**YOU MUST use [`config/full-opencode.json`](./config/full-opencode.json)** - this is the only officially supported configuration: -- 22 pre-configured model variants (GPT 5.2, GPT 5.2 Codex, GPT 5.1, Codex, Codex Max, Codex Mini) +- **OpenCode v1.0.210+** → [`config/opencode-modern.json`](./config/opencode-modern.json) +- **OpenCode v1.0.209 and below** → [`config/opencode-legacy.json`](./config/opencode-legacy.json) + +Both provide: +- 22 reasoning variants across GPT 5.2, GPT 5.2 Codex, GPT 5.1, Codex, Codex Max, Codex Mini - Image input support enabled for all models -- Optimal configuration for each reasoning level -- All variants visible in the opencode model selector -- Required metadata for OpenCode features to work properly +- Required metadata for OpenCode features (limits, usage widgets, compaction) -**Do NOT use other configurations** - they are not supported and may fail unpredictably with GPT 5 models. +**Do NOT use other configurations** — minimal configs are not supported and may fail unpredictably with GPT‑5 models. See [Installation](#installation) for setup instructions. - ### Custom Configuration If you want to customize settings yourself, you can configure options at provider or model level. diff --git a/config/README.md b/config/README.md index 2722711..c065450 100644 --- a/config/README.md +++ b/config/README.md @@ -1,59 +1,101 @@ # Configuration -This directory contains the official opencode configuration for the OpenAI Codex OAuth plugin. +This directory contains the official opencode configuration files for the OpenAI Codex OAuth plugin. -## ⚠️ REQUIRED Configuration File +## ⚠️ REQUIRED: Choose the Right Configuration -### full-opencode.json (REQUIRED - USE THIS ONLY) +**Two configuration files are available based on your OpenCode version:** -**YOU MUST use this configuration file** - it is the ONLY officially supported setup: +| File | OpenCode Version | Description | +|------|------------------|-------------| +| [`opencode-modern.json`](./opencode-modern.json) | **v1.0.210+ (Jan 2026+)** | Compact config using variants system - 6 models with built-in reasoning level variants | +| [`opencode-legacy.json`](./opencode-legacy.json) | **v1.0.209 and below** | Extended config with separate model entries for each reasoning level - 20+ individual model definitions | +### Which one should I use? + +**If you have OpenCode v1.0.210 or newer** (check with `opencode --version`): +```bash +cp config/opencode-modern.json ~/.config/opencode/opencode.json +``` + +**If you have OpenCode v1.0.209 or older**: ```bash -cp config/full-opencode.json ~/.config/opencode/opencode.json +cp config/opencode-legacy.json ~/.config/opencode/opencode.json ``` -**Why this is required:** -- GPT 5 models can be temperamental and need proper configuration -- Contains 22 verified GPT 5.2/5.1 model variants (GPT 5.2, GPT 5.2 Codex, Codex, Codex Max, Codex Mini, and general GPT 5.1 including `gpt-5.1-codex-max-low/medium/high/xhigh`) -- Includes all required metadata for OpenCode features -- Guaranteed to work reliably -- Global options for all models + per-model configuration overrides +### Why two configs? + +OpenCode v1.0.210+ introduced a **variants system** that allows defining reasoning effort levels as variants under a single model. This reduces config size from 572 lines to ~150 lines while maintaining the same functionality. + +**What you get:** -**What's included:** -- All supported GPT 5.2/5.1 variants: gpt-5.2, gpt-5.2-codex, gpt-5.1, gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini -- Proper reasoning effort settings for each variant (including new `xhigh` for Codex Max) -- Context limits (272k context / 128k output for all Codex families, including Codex Max) -- Required options: `store: false`, `include: ["reasoning.encrypted_content"]` +| Config File | Model Families | Reasoning Variants | Total Models | +|------------|----------------|-------------------|--------------| +| `opencode-modern.json` | 6 | Built-in variants (low/medium/high/xhigh) | 6 base models with 19 total variants | +| `opencode-legacy.json` | 6 | Separate model entries | 20 individual model definitions | -### ❌ Other Configurations (NOT SUPPORTED) +Both configs provide: +- ✅ All supported GPT 5.2/5.1 variants: gpt-5.2, gpt-5.2-codex, gpt-5.1, gpt-5.1-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini +- ✅ Proper reasoning effort settings for each variant (including `xhigh` for Codex Max/5.2) +- ✅ Context limits (272k context / 128k output for all Codex families) +- ✅ Required options: `store: false`, `include: ["reasoning.encrypted_content"]` +- ✅ Image input support for all models +- ✅ All required metadata for OpenCode features -**DO NOT use:** -- `minimal-opencode.json` - NOT supported, will fail unpredictably -- `full-opencode-gpt5.json` - DEPRECATED, GPT 5.0 models are being phased out by OpenAI -- Custom configurations - NOT recommended, may not work reliably +### Modern Config Benefits (v1.0.210+) -**Why other configs don't work:** -- GPT 5 models need specific configurations -- Missing metadata breaks OpenCode features -- No support for usage limits or context compaction -- Cannot guarantee stable operation +- **74% smaller**: 150 lines vs 572 lines +- **DRY**: Common options defined once at provider level +- **Variant cycling**: Built-in support for `Ctrl+T` to switch reasoning levels +- **Easier maintenance**: Add new variants without copying model definitions ## Usage -**ONLY ONE OPTION** - use the full configuration: +1. **Check your OpenCode version**: + ```bash + opencode --version + ``` -1. Copy `full-opencode.json` to your opencode config directory: - - Global: `~/.config/opencode/opencode.json` - - Project: `/.opencode.json` +2. **Copy the appropriate config** based on your version: + ```bash + # For v1.0.210+ (recommended): + cp config/opencode-modern.json ~/.config/opencode/opencode.json -2. **DO NOT modify** the configuration unless you know exactly what you're doing. The provided settings have been tested and verified to work. + # For older versions: + cp config/opencode-legacy.json ~/.config/opencode/opencode.json + ``` -3. Run opencode: `opencode run "your prompt" --model=openai/gpt-5.1-codex-medium` +3. **Run opencode**: + ```bash + # Modern config (v1.0.210+): + opencode run "task" --model=openai/gpt-5.2 --variant=medium + opencode run "task" --model=openai/gpt-5.2 --variant=high -> **⚠️ Critical**: GPT 5 models require this exact configuration. Do not use minimal configs or create custom variants - they are not supported and will fail unpredictably. + # Legacy config: + opencode run "task" --model=openai/gpt-5.2-medium + opencode run "task" --model=openai/gpt-5.2-high + ``` + +> **⚠️ Important**: Use the config file appropriate for your OpenCode version. Using the modern config with an older OpenCode version (v1.0.209 or below) will not work correctly. + +## Available Models + +Both configs provide access to the same model families: + +- **gpt-5.2** (none/low/medium/high/xhigh) - Latest GPT 5.2 model with full reasoning support +- **gpt-5.2-codex** (low/medium/high/xhigh) - GPT 5.2 Codex presets +- **gpt-5.1-codex-max** (low/medium/high/xhigh) - Codex Max presets +- **gpt-5.1-codex** (low/medium/high) - Codex model presets +- **gpt-5.1-codex-mini** (medium/high) - Codex mini tier presets +- **gpt-5.1** (none/low/medium/high) - General-purpose reasoning presets + +All appear in the opencode model selector as "GPT 5.1 Codex Low (OAuth)", "GPT 5.1 High (OAuth)", etc. ## Configuration Options See the main [README.md](../README.md#configuration) for detailed documentation of all configuration options. -**Remember**: Use `full-opencode.json` as-is for guaranteed compatibility. Custom configurations are not officially supported. +## Version History + +- **January 2026 (v1.0.210+)**: Introduced variant system support. Use `opencode-modern.json` +- **December 2025 and earlier**: Use `opencode-legacy.json` diff --git a/config/full-opencode.json b/config/opencode-legacy.json similarity index 100% rename from config/full-opencode.json rename to config/opencode-legacy.json diff --git a/config/opencode-modern.json b/config/opencode-modern.json new file mode 100644 index 0000000..c274f47 --- /dev/null +++ b/config/opencode-modern.json @@ -0,0 +1,239 @@ +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + "opencode-openai-codex-auth@4.2.0" + ], + "provider": { + "openai": { + "options": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "textVerbosity": "medium", + "include": [ + "reasoning.encrypted_content" + ], + "store": false + }, + "models": { + "gpt-5.2": { + "name": "GPT 5.2 (OAuth)", + "limit": { + "context": 272000, + "output": 128000 + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "variants": { + "none": { + "reasoningEffort": "none", + "reasoningSummary": "auto", + "textVerbosity": "medium" + }, + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "textVerbosity": "medium" + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "textVerbosity": "medium" + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "detailed", + "textVerbosity": "medium" + }, + "xhigh": { + "reasoningEffort": "xhigh", + "reasoningSummary": "detailed", + "textVerbosity": "medium" + } + } + }, + "gpt-5.2-codex": { + "name": "GPT 5.2 Codex (OAuth)", + "limit": { + "context": 272000, + "output": 128000 + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "variants": { + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "textVerbosity": "medium" + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "textVerbosity": "medium" + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "detailed", + "textVerbosity": "medium" + }, + "xhigh": { + "reasoningEffort": "xhigh", + "reasoningSummary": "detailed", + "textVerbosity": "medium" + } + } + }, + "gpt-5.1-codex-max": { + "name": "GPT 5.1 Codex Max (OAuth)", + "limit": { + "context": 272000, + "output": 128000 + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "variants": { + "low": { + "reasoningEffort": "low", + "reasoningSummary": "detailed", + "textVerbosity": "medium" + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "detailed", + "textVerbosity": "medium" + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "detailed", + "textVerbosity": "medium" + }, + "xhigh": { + "reasoningEffort": "xhigh", + "reasoningSummary": "detailed", + "textVerbosity": "medium" + } + } + }, + "gpt-5.1-codex": { + "name": "GPT 5.1 Codex (OAuth)", + "limit": { + "context": 272000, + "output": 128000 + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "variants": { + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "textVerbosity": "medium" + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "textVerbosity": "medium" + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "detailed", + "textVerbosity": "medium" + } + } + }, + "gpt-5.1-codex-mini": { + "name": "GPT 5.1 Codex Mini (OAuth)", + "limit": { + "context": 272000, + "output": 128000 + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "variants": { + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "textVerbosity": "medium" + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "detailed", + "textVerbosity": "medium" + } + } + }, + "gpt-5.1": { + "name": "GPT 5.1 (OAuth)", + "limit": { + "context": 272000, + "output": 128000 + }, + "modalities": { + "input": [ + "text", + "image" + ], + "output": [ + "text" + ] + }, + "variants": { + "none": { + "reasoningEffort": "none", + "reasoningSummary": "auto", + "textVerbosity": "medium" + }, + "low": { + "reasoningEffort": "low", + "reasoningSummary": "auto", + "textVerbosity": "low" + }, + "medium": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "textVerbosity": "medium" + }, + "high": { + "reasoningEffort": "high", + "reasoningSummary": "detailed", + "textVerbosity": "high" + } + } + } + } + } + } +} diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md index e6a0a67..733d0b8 100644 --- a/docs/DOCUMENTATION.md +++ b/docs/DOCUMENTATION.md @@ -19,7 +19,8 @@ This document explains the organization of documentation in this repository. │ └── TESTING.md # Test procedures ├── config/ │ ├── README.md # Example configs guide -│ ├── full-opencode.json # Full config example +│ ├── opencode-legacy.json # Legacy full config example (v1.0.209 and below) +│ ├── opencode-modern.json # Variant config example (v1.0.210+) │ └── minimal-opencode.json # Minimal config example └── tmp/release-notes/ # Detailed release artifacts ├── CHANGES.md # Detailed v2.1.2 changes diff --git a/docs/configuration.md b/docs/configuration.md index ac66a4b..f338df8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -264,7 +264,7 @@ opencode run "task" --model=openai/my-custom-id # TUI shows: "My Display Name" ``` -> **⚠️ Recommendation:** Stick to the official presets in `full-opencode.json` rather than creating custom model variants. GPT 5 models need specific configurations to work reliably. +> **⚠️ Recommendation:** Stick to the official presets in `opencode-modern.json` (v1.0.210+) or `opencode-legacy.json` rather than creating custom model variants. GPT 5 models need specific configurations to work reliably. See [development/CONFIG_FIELDS.md](development/CONFIG_FIELDS.md) for complete explanation. @@ -407,9 +407,10 @@ CODEX_MODE=1 opencode run "task" # Temporarily enable ## Configuration Files **Provided Examples:** -- [config/full-opencode.json](../config/full-opencode.json) - Complete with 22 GPT 5.x variants (GPT 5.2, GPT 5.2 Codex, GPT 5.1) +- [config/opencode-modern.json](../config/opencode-modern.json) - Variants-based config for OpenCode v1.0.210+ +- [config/opencode-legacy.json](../config/opencode-legacy.json) - Legacy full list for v1.0.209 and below -> **⚠️ REQUIRED:** You MUST use `full-opencode.json` - this is the ONLY officially supported configuration. Minimal configs are NOT supported for GPT 5 models and will fail unpredictably. OpenCode's auto-compaction and usage widgets also require the full config's per-model `limit` metadata. +> **⚠️ REQUIRED:** You MUST use the config that matches your OpenCode version (`opencode-modern.json` or `opencode-legacy.json`). Minimal configs are NOT supported for GPT 5 models and will fail unpredictably. OpenCode's auto-compaction and usage widgets also require the full config's per-model `limit` metadata. **Your Configs:** - `~/.config/opencode/opencode.json` - Global config @@ -461,7 +462,7 @@ cat ~/.opencode/logs/codex-plugin/request-*-after-transform.json | jq '.reasonin Old verbose names still work: -**⚠️ IMPORTANT:** Old configs with GPT 5.0 models are deprecated. You MUST migrate to the new `full-opencode.json` with GPT 5.x models. +**⚠️ IMPORTANT:** Old configs with GPT 5.0 models are deprecated. You MUST migrate to the new GPT 5.x configs (`opencode-modern.json` or `opencode-legacy.json`). **Old config (deprecated):** ```json @@ -477,7 +478,7 @@ Old verbose names still work: **New config (required):** -Use the official [`config/full-opencode.json`](../config/full-opencode.json) file which includes: +Use the official config file (`opencode-modern.json` for v1.0.210+, `opencode-legacy.json` for older) which includes: ```json { @@ -608,7 +609,7 @@ Look for `hasModelSpecificConfig: true` in debug output. **Fix**: Use exact name you specify in CLI as config key. -> **⚠️ Best Practice:** Use the official `full-opencode.json` configuration instead of creating custom configs. This ensures proper model normalization and compatibility with GPT 5 models. +> **⚠️ Best Practice:** Use the official `opencode-modern.json` or `opencode-legacy.json` configuration instead of creating custom configs. This ensures proper model normalization and compatibility with GPT 5 models. --- diff --git a/docs/development/ARCHITECTURE.md b/docs/development/ARCHITECTURE.md index 8cc13da..b66bb7c 100644 --- a/docs/development/ARCHITECTURE.md +++ b/docs/development/ARCHITECTURE.md @@ -403,7 +403,7 @@ let include: Vec = if reasoning.is_some() { - ✅ Descriptive names ("Fast", "Balanced", "Max Quality") - ✅ Persistent across sessions -**Source**: `config/full-opencode.json` +**Source**: `config/opencode-legacy.json` (legacy) or `config/opencode-modern.json` (variants) --- diff --git a/docs/development/CONFIG_FLOW.md b/docs/development/CONFIG_FLOW.md index 742ca96..7fb8b31 100644 --- a/docs/development/CONFIG_FLOW.md +++ b/docs/development/CONFIG_FLOW.md @@ -86,7 +86,7 @@ Plugins can inject options via the `loader()` function. ### Display Names vs Internal IDs -**Your Config** (`config/full-opencode.json`): +**Your Config** (`config/opencode-legacy.json`): ```json { "provider": { diff --git a/docs/getting-started.md b/docs/getting-started.md index 3a23d54..2ae37ef 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -38,7 +38,7 @@ OpenCode automatically installs plugins - no `npm install` needed! Add this to `~/.config/opencode/opencode.json`: -**Tip**: The snippet below is a truncated excerpt. For the complete list (including GPT 5.2 and GPT 5.2 Codex presets), copy `config/full-opencode.json` directly. +**Tip**: The snippet below is a truncated excerpt. For the complete legacy list, copy `config/opencode-legacy.json`. For the modern variants config (OpenCode v1.0.210+), use `config/opencode-modern.json`. ```json { diff --git a/docs/index.md b/docs/index.md index 8eaa20c..628e378 100644 --- a/docs/index.md +++ b/docs/index.md @@ -85,12 +85,13 @@ opencode run "write hello world to test.txt" --model=openai/gpt-5-codex ✅ **OAuth Authentication** - Secure ChatGPT Plus/Pro login ✅ **GPT 5.2 + GPT 5.2 Codex + GPT 5.1 Models** - 22 pre-configured variants across GPT 5.2, GPT 5.2 Codex, GPT 5.1, Codex, Codex Max, Codex Mini +✅ **Variant system support** - Works with OpenCode v1.0.210+ model variants and legacy presets ✅ **Per-Model Configuration** - Different reasoning effort, including `xhigh` for GPT 5.2, GPT 5.2 Codex, and Codex Max ✅ **Multi-Turn Conversations** - Full conversation history with stateless backend -✅ **Verified Configuration** - Use `config/full-opencode.json` for guaranteed compatibility +✅ **Verified Configuration** - Use `config/opencode-modern.json` (v1.0.210+) or `config/opencode-legacy.json` (older) ✅ **Comprehensive Testing** - 200+ unit tests + integration tests -> **⚠️ Important**: GPT 5 models can be temperamental. Use the official `config/full-opencode.json` configuration - it's the ONLY supported setup. Older GPT 5.0 models are deprecated. +> **⚠️ Important**: GPT 5 models can be temperamental. Use the official config for your OpenCode version (`opencode-modern.json` or `opencode-legacy.json`). Older GPT 5.0 models are deprecated. --- diff --git a/lib/prompts/opencode-codex.ts b/lib/prompts/opencode-codex.ts index 036b14b..d93a276 100644 --- a/lib/prompts/opencode-codex.ts +++ b/lib/prompts/opencode-codex.ts @@ -10,7 +10,7 @@ import { homedir } from "node:os"; import { mkdir, readFile, writeFile } from "node:fs/promises"; const OPENCODE_CODEX_URL = - "https://raw.githubusercontent.com/sst/opencode/dev/packages/opencode/src/session/prompt/codex.txt"; + "https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/opencode/src/session/prompt/codex.txt"; const CACHE_DIR = join(homedir(), ".opencode", "cache"); const CACHE_FILE = join(CACHE_DIR, "opencode-codex.txt"); const CACHE_META_FILE = join(CACHE_DIR, "opencode-codex-meta.json"); diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 46fcf35..a80b003 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -130,6 +130,53 @@ export function getModelConfig( return { ...globalOptions, ...modelOptions }; } +function resolveReasoningConfig( + modelName: string, + modelConfig: ConfigOptions, + body: RequestBody, +): ReasoningConfig { + const providerOpenAI = body.providerOptions?.openai; + const existingEffort = + body.reasoning?.effort ?? providerOpenAI?.reasoningEffort; + const existingSummary = + body.reasoning?.summary ?? providerOpenAI?.reasoningSummary; + + const mergedConfig: ConfigOptions = { + ...modelConfig, + ...(existingEffort ? { reasoningEffort: existingEffort } : {}), + ...(existingSummary ? { reasoningSummary: existingSummary } : {}), + }; + + return getReasoningConfig(modelName, mergedConfig); +} + +function resolveTextVerbosity( + modelConfig: ConfigOptions, + body: RequestBody, +): "low" | "medium" | "high" { + const providerOpenAI = body.providerOptions?.openai; + return ( + body.text?.verbosity ?? + providerOpenAI?.textVerbosity ?? + modelConfig.textVerbosity ?? + "medium" + ); +} + +function resolveInclude(modelConfig: ConfigOptions, body: RequestBody): string[] { + const providerOpenAI = body.providerOptions?.openai; + const base = + body.include ?? + providerOpenAI?.include ?? + modelConfig.include ?? + ["reasoning.encrypted_content"]; + const include = Array.from(new Set(base.filter(Boolean))); + if (!include.includes("reasoning.encrypted_content")) { + include.push("reasoning.encrypted_content"); + } + return include; +} + /** * Configure reasoning parameters based on model variant and user config * @@ -453,8 +500,12 @@ export async function transformRequestBody( } } - // Configure reasoning (use normalized model family + model-specific config) - const reasoningConfig = getReasoningConfig(normalizedModel, modelConfig); + // Configure reasoning (prefer existing body/provider options, then config defaults) + const reasoningConfig = resolveReasoningConfig( + normalizedModel, + modelConfig, + body, + ); body.reasoning = { ...body.reasoning, ...reasoningConfig, @@ -464,13 +515,13 @@ export async function transformRequestBody( // Default: "medium" (matches Codex CLI default for all GPT-5 models) body.text = { ...body.text, - verbosity: modelConfig.textVerbosity || "medium", + verbosity: resolveTextVerbosity(modelConfig, body), }; // Add include for encrypted reasoning content // Default: ["reasoning.encrypted_content"] (required for stateless operation with store=false) // This allows reasoning context to persist across turns without server-side storage - body.include = modelConfig.include || ["reasoning.encrypted_content"]; + body.include = resolveInclude(modelConfig, body); // Remove unsupported parameters body.max_output_tokens = undefined; diff --git a/lib/types.ts b/lib/types.ts index fdb8723..40e7bc4 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -19,6 +19,8 @@ export interface UserConfig { models: { [modelName: string]: { options?: ConfigOptions; + variants?: Record; + [key: string]: unknown; }; }; } @@ -134,6 +136,10 @@ export interface RequestBody { verbosity?: "low" | "medium" | "high"; }; include?: string[]; + providerOptions?: { + openai?: Partial & { store?: boolean; include?: string[] }; + [key: string]: unknown; + }; /** Stable key to enable prompt-token caching on Codex backend */ prompt_cache_key?: string; max_output_tokens?: number; diff --git a/scripts/test-all-models.sh b/scripts/test-all-models.sh index 57892ef..6e3767b 100755 --- a/scripts/test-all-models.sh +++ b/scripts/test-all-models.sh @@ -138,18 +138,14 @@ update_config() { echo "" case "${config_type}" in - "full") - cat "${REPO_DIR}/config/full-opencode.json" > "${OPENCODE_JSON}" - echo "✓ Updated opencode.json with full config (GPT 5.x)" + "legacy") + cat "${REPO_DIR}/config/opencode-legacy.json" > "${OPENCODE_JSON}" + echo "✓ Updated opencode.json with legacy config (GPT 5.x)" ;; "minimal") cat "${REPO_DIR}/config/minimal-opencode.json" > "${OPENCODE_JSON}" echo "✓ Updated opencode.json with minimal config" ;; - "backwards-compat") - cat "${REPO_DIR}/config/full-opencode-gpt5.json" > "${OPENCODE_JSON}" - echo "✓ Updated opencode.json with backwards compatibility config" - ;; esac # Replace npm package with local dist for testing @@ -161,9 +157,9 @@ update_config() { } # ============================================================================ -# Scenario 1: Full Config - GPT 5.x Model Family +# Scenario 1: Legacy Config - GPT 5.x Model Family # ============================================================================ -update_config "full" +update_config "legacy" # GPT 5.1 Codex presets test_model "gpt-5.1-codex-low" "gpt-5.1-codex" "codex" "low" "auto" "medium" @@ -247,9 +243,9 @@ echo -e "Results saved to: ${RESULTS_FILE} (will be removed)" echo "" # Restore original config -if [ -f "${REPO_DIR}/config/full-opencode.json" ]; then - cat "${REPO_DIR}/config/full-opencode.json" > "${OPENCODE_JSON}" - echo "✓ Restored original full config to opencode.json" +if [ -f "${REPO_DIR}/config/opencode-legacy.json" ]; then + cat "${REPO_DIR}/config/opencode-legacy.json" > "${OPENCODE_JSON}" + echo "✓ Restored original legacy config to opencode.json" fi # Cleanup results file to avoid polluting the repo diff --git a/test/README.md b/test/README.md index caa87c5..93c3ecc 100644 --- a/test/README.md +++ b/test/README.md @@ -102,4 +102,5 @@ When adding new functionality: See the `config/` directory for working configuration examples: - `minimal-opencode.json`: Simplest setup with defaults -- `full-opencode.json`: Complete example with all model variants +- `opencode-legacy.json`: Legacy complete example with all model variants +- `opencode-modern.json`: Variant-based example for OpenCode v1.0.210+ diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 84cb22d..91b727f 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -681,6 +681,42 @@ describe('Request Transformer Module', () => { expect(result.reasoning?.summary).toBe('detailed'); }); + it('should respect reasoning config already set in body', async () => { + const body: RequestBody = { + model: 'gpt-5', + input: [], + reasoning: { + effort: 'low', + summary: 'auto', + }, + }; + const userConfig: UserConfig = { + global: { reasoningEffort: 'high', reasoningSummary: 'detailed' }, + models: {}, + }; + const result = await transformRequestBody(body, codexInstructions, userConfig); + + expect(result.reasoning?.effort).toBe('low'); + expect(result.reasoning?.summary).toBe('auto'); + }); + + it('should use reasoning config from providerOptions when present', async () => { + const body: RequestBody = { + model: 'gpt-5', + input: [], + providerOptions: { + openai: { + reasoningEffort: 'high', + reasoningSummary: 'detailed', + }, + }, + }; + const result = await transformRequestBody(body, codexInstructions); + + expect(result.reasoning?.effort).toBe('high'); + expect(result.reasoning?.summary).toBe('detailed'); + }); + it('should apply default text verbosity', async () => { const body: RequestBody = { model: 'gpt-5', @@ -703,6 +739,35 @@ describe('Request Transformer Module', () => { expect(result.text?.verbosity).toBe('low'); }); + it('should use text verbosity from providerOptions when present', async () => { + const body: RequestBody = { + model: 'gpt-5', + input: [], + providerOptions: { + openai: { + textVerbosity: 'low', + }, + }, + }; + const result = await transformRequestBody(body, codexInstructions); + expect(result.text?.verbosity).toBe('low'); + }); + + it('should prefer body text verbosity over providerOptions', async () => { + const body: RequestBody = { + model: 'gpt-5', + input: [], + text: { verbosity: 'high' }, + providerOptions: { + openai: { + textVerbosity: 'low', + }, + }, + }; + const result = await transformRequestBody(body, codexInstructions); + expect(result.text?.verbosity).toBe('high'); + }); + it('should set default include for encrypted reasoning', async () => { const body: RequestBody = { model: 'gpt-5', @@ -725,6 +790,16 @@ describe('Request Transformer Module', () => { expect(result.include).toEqual(['custom_field', 'reasoning.encrypted_content']); }); + it('should always include reasoning.encrypted_content when include provided', async () => { + const body: RequestBody = { + model: 'gpt-5', + input: [], + include: ['custom_field'], + }; + const result = await transformRequestBody(body, codexInstructions); + expect(result.include).toEqual(['custom_field', 'reasoning.encrypted_content']); + }); + it('should remove IDs from input array (keep all items, strip IDs)', async () => { const body: RequestBody = { model: 'gpt-5', From 11fbe18ab4844dd3066c04f7603d3421470cd15f Mon Sep 17 00:00:00 2001 From: Numman Ali Date: Sun, 4 Jan 2026 23:35:55 +0000 Subject: [PATCH 4/4] Add cross-platform installer for global config --- README.md | 86 ++++++----- config/README.md | 2 + config/opencode-legacy.json | 2 +- config/opencode-modern.json | 2 +- docs/getting-started.md | 32 ++++- docs/index.md | 20 ++- docs/troubleshooting.md | 2 +- package.json | 5 + scripts/install-opencode-codex-auth.js | 192 +++++++++++++++++++++++++ scripts/test-all-models.sh | 2 +- 10 files changed, 285 insertions(+), 60 deletions(-) create mode 100755 scripts/install-opencode-codex-auth.js diff --git a/README.md b/README.md index d882fee..87fd60b 100644 --- a/README.md +++ b/README.md @@ -52,45 +52,57 @@ Follow me on [X @nummanthinks](https://x.com/nummanthinks) for future updates an ## Installation -### Quick Start +### One-Command Install/Update (Recommended) -**No npm install needed!** opencode automatically installs plugins when you add them to your config. - -### Plugin Versioning & Updates +Works on **Windows, macOS, and Linux** with a single command: -**⚠️ Important**: OpenCode does NOT auto-update plugins. You must pin versions for reliable updates. +```bash +npx -y opencode-openai-codex-auth@latest +``` -#### Recommended: Pin the Version +What it does: +- Writes the **global** config at `~/.config/opencode/opencode.json` +- Uses the **modern** variants config by default +- Ensures the plugin is **unversioned** (uses `latest`) +- **Backs up** your existing config before changes +- Clears OpenCode’s plugin cache so the latest version is installed -```json -"plugin": ["opencode-openai-codex-auth@4.2.0"] +Need legacy config (OpenCode v1.0.209 and below)? +```bash +npx -y opencode-openai-codex-auth@latest --legacy ``` -**Why pin versions?** OpenCode uses Bun's lockfile which pins resolved versions. If you use `"opencode-openai-codex-auth"` without a version, it resolves to "latest" once and **never updates** even when new versions are published. +--- -#### Upgrading to a New Version +### Manual Install (Advanced) -Simply change the version in your config and restart OpenCode: +**No npm install needed!** opencode automatically installs plugins when you add them to your config. ```json -// Change from: -"plugin": ["opencode-openai-codex-auth@3.3.0"] - -// To: -"plugin": ["opencode-openai-codex-auth@4.2.0"] +"plugin": ["opencode-openai-codex-auth"] ``` -OpenCode will detect the version mismatch and install the new version automatically. +Copy the config that matches your OpenCode version: -#### If You're Stuck on an Old Version +```bash +# Modern (v1.0.210+): +cp /config/opencode-modern.json ~/.config/opencode/opencode.json -If you previously used an unpinned version, clear the cache: +# Legacy (v1.0.209 and below): +cp /config/opencode-legacy.json ~/.config/opencode/opencode.json +``` + +Refresh the plugin cache to force a reinstall: ```bash rm -rf ~/.cache/opencode/node_modules ~/.cache/opencode/bun.lock ``` -Then restart OpenCode with a pinned version in your config. +**Optional: pin a version** for reproducibility: + +```json +"plugin": ["opencode-openai-codex-auth@4.2.0"] +``` Check [releases](https://github.com/numman-ali/opencode-openai-codex-auth/releases) for version history. @@ -98,33 +110,19 @@ Check [releases](https://github.com/numman-ali/opencode-openai-codex-auth/releas ### Agent Instructions (Install / Update) -If you’re using an AI agent (Codex/Claude/etc.) to install or update this plugin on a machine, use these steps: - -1. **Use the official repo**: https://github.com/numman-ali/opencode-openai-codex-auth -2. **Pin the plugin version** in the user’s OpenCode config (`~/.config/opencode/opencode.json` or project `.opencode.json`). -3. **Replace the config** with the latest `config/opencode-modern.json` (OpenCode v1.0.210+) or `config/opencode-legacy.json` (v1.0.209 and below). -4. **Refresh the plugin cache** so OpenCode reinstalls the updated version. -5. **Restart OpenCode**. +If you’re using an AI agent (Codex/Claude/etc.), **run the one-command installer**: ```bash -# 1) Update plugin version (replace with newest release tag) -# Example: opencode-openai-codex-auth@4.2.0 - -# 2) Copy config (choose based on your OpenCode version) -# opencode --version -# Modern (v1.0.210+): -cp /config/opencode-modern.json ~/.config/opencode/opencode.json -# Legacy (v1.0.209 and below): -cp /config/opencode-legacy.json ~/.config/opencode/opencode.json +npx -y opencode-openai-codex-auth@latest +``` -# 3) Refresh OpenCode plugin cache -rm -rf ~/.cache/opencode/node_modules ~/.cache/opencode/bun.lock +Legacy OpenCode (v1.0.209 and below): -# 4) Optional sanity check for GPT-5.2 models -jq '.provider.openai.models | keys' ~/.config/opencode/opencode.json +```bash +npx -y opencode-openai-codex-auth@latest --legacy ``` -> **Note**: If using a project-local config, replace the target path with `/.opencode.json`. +This updates the **global** config only and clears the plugin cache so OpenCode reinstalls the latest version. --- @@ -143,7 +141,7 @@ jq '.provider.openai.models | keys' ~/.config/opencode/opencode.json ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-openai-codex-auth@4.2.0"], + "plugin": ["opencode-openai-codex-auth"], "provider": { "openai": { "options": { @@ -376,7 +374,7 @@ Apply settings to all models: ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-openai-codex-auth@4.2.0"], + "plugin": ["opencode-openai-codex-auth"], "model": "openai/gpt-5-codex", "provider": { "openai": { @@ -396,7 +394,7 @@ Create your own named variants in the model selector: ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-openai-codex-auth@4.2.0"], + "plugin": ["opencode-openai-codex-auth"], "provider": { "openai": { "models": { diff --git a/config/README.md b/config/README.md index c065450..b015a05 100644 --- a/config/README.md +++ b/config/README.md @@ -78,6 +78,8 @@ Both configs provide: > **⚠️ Important**: Use the config file appropriate for your OpenCode version. Using the modern config with an older OpenCode version (v1.0.209 or below) will not work correctly. +> **Note**: The config templates use an **unversioned** plugin entry (`opencode-openai-codex-auth`) so the installer can always pull the latest release. If you need reproducibility, pin a specific version manually. + ## Available Models Both configs provide access to the same model families: diff --git a/config/opencode-legacy.json b/config/opencode-legacy.json index 2fed205..5381226 100644 --- a/config/opencode-legacy.json +++ b/config/opencode-legacy.json @@ -1,7 +1,7 @@ { "$schema": "https://opencode.ai/config.json", "plugin": [ - "opencode-openai-codex-auth@4.2.0" + "opencode-openai-codex-auth" ], "provider": { "openai": { diff --git a/config/opencode-modern.json b/config/opencode-modern.json index c274f47..9161024 100644 --- a/config/opencode-modern.json +++ b/config/opencode-modern.json @@ -1,7 +1,7 @@ { "$schema": "https://opencode.ai/config.json", "plugin": [ - "opencode-openai-codex-auth@4.2.0" + "opencode-openai-codex-auth" ], "provider": { "openai": { diff --git a/docs/getting-started.md b/docs/getting-started.md index 2ae37ef..2c87492 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -20,6 +20,24 @@ For production applications, use the [OpenAI Platform API](https://platform.open ## Installation +### One-Command Install/Update (Recommended) + +Works on **Windows, macOS, and Linux**: + +```bash +npx -y opencode-openai-codex-auth@latest +``` + +This writes the **global** config at `~/.config/opencode/opencode.json`, backs it up, and clears the OpenCode plugin cache so the latest version installs. + +Need legacy config (OpenCode v1.0.209 and below)? + +```bash +npx -y opencode-openai-codex-auth@latest --legacy +``` + +--- + ### Step 1: Add Plugin to Config OpenCode automatically installs plugins - no `npm install` needed! @@ -325,16 +343,16 @@ OpenCode checks multiple config files in order: ## ⚠️ Updating the Plugin (Important!) -**OpenCode does NOT automatically update plugins.** - -When a new version is released, you must manually update: +OpenCode caches plugins. To install the latest version, just re-run the installer: ```bash -# Step 1: Clear plugin cache -(cd ~ && sed -i.bak '/"opencode-openai-codex-auth"/d' .cache/opencode/package.json && rm -rf .cache/opencode/node_modules/opencode-openai-codex-auth) +npx -y opencode-openai-codex-auth@latest +``` -# Step 2: Restart OpenCode - it will reinstall the latest version -opencode +Legacy OpenCode (v1.0.209 and below): + +```bash +npx -y opencode-openai-codex-auth@latest --legacy ``` **When to update:** diff --git a/docs/index.md b/docs/index.md index 628e378..17a7fdb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,7 +40,19 @@ Explore the engineering depth behind this plugin: ### Installation -Add to your config, run OpenCode, authenticate: +One-command install/update (global config): + +```bash +npx -y opencode-openai-codex-auth@latest +``` + +Legacy OpenCode (v1.0.209 and below): + +```bash +npx -y opencode-openai-codex-auth@latest --legacy +``` + +Then run OpenCode and authenticate: ```bash # 1. Add plugin to ~/.config/opencode/opencode.json @@ -55,12 +67,10 @@ If the browser callback fails (SSH/WSL/remote), choose **"ChatGPT Plus/Pro (Manu ### Updating -**⚠️ OpenCode does NOT auto-update plugins** +Re-run the installer to update: -To get the latest version: ```bash -(cd ~ && sed -i.bak '/"opencode-openai-codex-auth"/d' .cache/opencode/package.json && rm -rf .cache/opencode/node_modules/opencode-openai-codex-auth) -opencode # Reinstalls latest +npx -y opencode-openai-codex-auth@latest ``` ### Minimal Configuration diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3d0d7d4..0de785f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -163,7 +163,7 @@ Items are not persisted when `store` is set to false. **Solution:** ```bash # Update plugin -(cd ~ && sed -i.bak '/"opencode-openai-codex-auth"/d' .cache/opencode/package.json && rm -rf .cache/opencode/node_modules/opencode-openai-codex-auth) +npx -y opencode-openai-codex-auth@latest # Restart OpenCode opencode diff --git a/package.json b/package.json index 39a9c9a..1061a95 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,13 @@ "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage" }, + "bin": { + "opencode-openai-codex-auth": "./scripts/install-opencode-codex-auth.js" + }, "files": [ "dist/", + "config/", + "scripts/", "README.md", "LICENSE" ], diff --git a/scripts/install-opencode-codex-auth.js b/scripts/install-opencode-codex-auth.js new file mode 100755 index 0000000..c1d0733 --- /dev/null +++ b/scripts/install-opencode-codex-auth.js @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +import { existsSync } from "node:fs"; +import { readFile, writeFile, mkdir, copyFile, rm } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { homedir } from "node:os"; + +const PLUGIN_NAME = "opencode-openai-codex-auth"; +const args = new Set(process.argv.slice(2)); + +if (args.has("--help") || args.has("-h")) { + console.log(`Usage: ${PLUGIN_NAME} [--modern|--legacy] [--dry-run] [--no-cache-clear]\n\n` + + "Default behavior:\n" + + " - Installs/updates global config at ~/.config/opencode/opencode.json\n" + + " - Uses modern config (variants) by default\n" + + " - Ensures plugin is unpinned (latest)\n" + + " - Clears OpenCode plugin cache\n\n" + + "Options:\n" + + " --modern Force modern config (default)\n" + + " --legacy Use legacy config (older OpenCode versions)\n" + + " --dry-run Show actions without writing\n" + + " --no-cache-clear Skip clearing OpenCode cache\n" + ); + process.exit(0); +} + +const useLegacy = args.has("--legacy"); +const useModern = args.has("--modern") || !useLegacy; +const dryRun = args.has("--dry-run"); +const skipCacheClear = args.has("--no-cache-clear"); + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(scriptDir, ".."); +const templatePath = join( + repoRoot, + "config", + useLegacy ? "opencode-legacy.json" : "opencode-modern.json" +); + +const configDir = join(homedir(), ".config", "opencode"); +const configPath = join(configDir, "opencode.json"); +const cacheDir = join(homedir(), ".cache", "opencode"); +const cacheNodeModules = join(cacheDir, "node_modules", PLUGIN_NAME); +const cacheBunLock = join(cacheDir, "bun.lock"); +const cachePackageJson = join(cacheDir, "package.json"); + +function log(message) { + console.log(message); +} + +function normalizePluginList(list) { + const entries = Array.isArray(list) ? list.filter(Boolean) : []; + const filtered = entries.filter((entry) => { + if (typeof entry !== "string") return true; + return entry !== PLUGIN_NAME && !entry.startsWith(`${PLUGIN_NAME}@`); + }); + return [...filtered, PLUGIN_NAME]; +} + +function formatJson(obj) { + return `${JSON.stringify(obj, null, 2)}\n`; +} + +async function readJson(filePath) { + const content = await readFile(filePath, "utf-8"); + return JSON.parse(content); +} + +async function backupConfig(sourcePath) { + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, "-") + .replace("T", "_") + .replace("Z", ""); + const backupPath = `${sourcePath}.bak-${timestamp}`; + if (!dryRun) { + await copyFile(sourcePath, backupPath); + } + return backupPath; +} + +async function removePluginFromCachePackage() { + if (!existsSync(cachePackageJson)) { + return; + } + + let cacheData; + try { + cacheData = await readJson(cachePackageJson); + } catch (error) { + log(`Warning: Could not parse ${cachePackageJson} (${error}). Skipping.`); + return; + } + + const sections = [ + "dependencies", + "devDependencies", + "peerDependencies", + "optionalDependencies", + ]; + + let changed = false; + for (const section of sections) { + const deps = cacheData?.[section]; + if (deps && typeof deps === "object" && PLUGIN_NAME in deps) { + delete deps[PLUGIN_NAME]; + changed = true; + } + } + + if (!changed) { + return; + } + + if (dryRun) { + log(`[dry-run] Would update ${cachePackageJson} to remove ${PLUGIN_NAME}`); + return; + } + + await writeFile(cachePackageJson, formatJson(cacheData), "utf-8"); +} + +async function clearCache() { + if (skipCacheClear) { + log("Skipping cache clear (--no-cache-clear)."); + return; + } + + if (dryRun) { + log(`[dry-run] Would remove ${cacheNodeModules}`); + log(`[dry-run] Would remove ${cacheBunLock}`); + } else { + await rm(cacheNodeModules, { recursive: true, force: true }); + await rm(cacheBunLock, { force: true }); + } + + await removePluginFromCachePackage(); +} + +async function main() { + if (!existsSync(templatePath)) { + throw new Error(`Config template not found at ${templatePath}`); + } + + const template = await readJson(templatePath); + template.plugin = [PLUGIN_NAME]; + + let nextConfig = template; + if (existsSync(configPath)) { + const backupPath = await backupConfig(configPath); + log(`${dryRun ? "[dry-run] Would create backup" : "Backup created"}: ${backupPath}`); + + try { + const existing = await readJson(configPath); + const merged = { ...existing }; + merged.plugin = normalizePluginList(existing.plugin); + const provider = (existing.provider && typeof existing.provider === "object") + ? { ...existing.provider } + : {}; + provider.openai = template.provider.openai; + merged.provider = provider; + nextConfig = merged; + } catch (error) { + log(`Warning: Could not parse existing config (${error}). Replacing with template.`); + nextConfig = template; + } + } else { + log("No existing config found. Creating new global config."); + } + + if (dryRun) { + log(`[dry-run] Would write ${configPath} using ${useLegacy ? "legacy" : "modern"} config`); + } else { + await mkdir(configDir, { recursive: true }); + await writeFile(configPath, formatJson(nextConfig), "utf-8"); + log(`Wrote ${configPath} (${useLegacy ? "legacy" : "modern"} config)`); + } + + await clearCache(); + + log("\nDone. Restart OpenCode to (re)install the plugin."); + log("Example: opencode"); + if (useLegacy) { + log("Note: Legacy config requires OpenCode v1.0.209 or older."); + } +} + +main().catch((error) => { + console.error(`Installer failed: ${error instanceof Error ? error.message : error}`); + process.exit(1); +}); diff --git a/scripts/test-all-models.sh b/scripts/test-all-models.sh index 6e3767b..21770a0 100755 --- a/scripts/test-all-models.sh +++ b/scripts/test-all-models.sh @@ -149,7 +149,7 @@ update_config() { esac # Replace npm package with local dist for testing - sed -i.bak 's|"opencode-openai-codex-auth@[^"]*"|"file://'"${REPO_DIR}"'/dist"|' "${OPENCODE_JSON}" + sed -i.bak -E 's|"opencode-openai-codex-auth(@[^"]*)?"|"file://'"${REPO_DIR}"'/dist"|' "${OPENCODE_JSON}" rm -f "${OPENCODE_JSON}.bak" echo "✓ Using local dist for plugin"