diff --git a/AGENTS.md b/AGENTS.md index 61082d3..9341455 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ This file provides coding guidance for AI agents (including Claude Code, Codex, ## Overview -This is an **opencode plugin** that enables OAuth authentication with OpenAI's ChatGPT Plus/Pro Codex backend. It now mirrors the Codex CLI lineup, making `gpt-5.1-codex-max` (with optional `xhigh` reasoning) the default alongside the existing `gpt-5.1-codex`, `gpt-5.1-codex-mini`, and legacy `gpt-5` models—all available through a ChatGPT subscription instead of OpenAI Platform API credits. +This is an **opencode plugin** that enables OAuth authentication with OpenAI's ChatGPT Plus/Pro Codex backend. It now mirrors the Codex CLI lineup, making `gpt-5.1-codex-max` (with optional `xhigh` reasoning) the default while also exposing the new `gpt-5.2` frontier preset, the existing `gpt-5.1-codex` / `gpt-5.1-codex-mini`, and legacy `gpt-5` models—all available through a ChatGPT subscription instead of OpenAI Platform API credits. **Key architecture principle**: 7-step fetch flow that intercepts opencode's OpenAI SDK requests, transforms them for the ChatGPT backend API, and handles OAuth token management. @@ -58,25 +58,30 @@ The main entry point orchestrates a **7-step fetch flow**: ### Module Organization **Core Plugin** (`index.ts`) + - Plugin definition and main fetch orchestration - OAuth loader (extracts ChatGPT account ID from JWT) - Configuration loading and CODEX_MODE determination **Authentication** (`lib/auth/`) + - `auth.ts`: OAuth flow (PKCE, token exchange, JWT decoding, refresh) - `server.ts`: Local HTTP server for OAuth callback (port 1455) - `browser.ts`: Platform-specific browser opening **Request Handling** (`lib/request/`) + - `fetch-helpers.ts`: 10 focused helper functions for main fetch flow - `request-transformer.ts`: Body transformations (model normalization, reasoning config, input filtering) - `response-handler.ts`: SSE to JSON conversion **Prompts** (`lib/prompts/`) + - `codex.ts`: Fetches Codex instructions from GitHub (ETag-cached), tool remap message - `codex-opencode-bridge.ts`: CODEX_MODE bridge prompt for CLI parity **Configuration** (`lib/`) + - `config.ts`: Plugin config loading, CODEX_MODE determination - `constants.ts`: All magic values, URLs, error messages - `types.ts`: TypeScript type definitions @@ -85,10 +90,12 @@ The main entry point orchestrates a **7-step fetch flow**: ### Key Design Patterns **1. Stateless Operation**: Uses `store: false` + `include: ["reasoning.encrypted_content"]` + - Allows multi-turn conversations without server-side storage - Encrypted reasoning content persists context across turns **2. CODEX_MODE** (enabled by default): + - **Priority**: `CODEX_MODE` env var > `~/.opencode/openhax-codex-config.json` > default (true) - When enabled: Filters out OpenCode system prompts, adds Codex-OpenCode bridge prompt with Task tool & MCP awareness - When disabled: Uses legacy tool remap message @@ -96,17 +103,20 @@ The main entry point orchestrates a **7-step fetch flow**: - **Prompt verification**: Caches OpenCode's codex.txt from GitHub (ETag-based) to verify exact prompt removal, with fallback to text signature matching **3. Configuration Merging**: + - Global options (`provider.openai.options`) + per-model options (`provider.openai.models[name].options`) - Model-specific options override global - Plugin defaults: `reasoningEffort: "medium"`, `reasoningSummary: "auto"`, `textVerbosity: "medium"` **4. Model Normalization**: + - All `gpt-5-codex` variants → `gpt-5-codex` - All `gpt-5-codex-mini*` or `codex-mini-latest` variants → `codex-mini-latest` - All `gpt-5` variants → `gpt-5` - `minimal` effort auto-normalized to `low` for gpt-5-codex (API limitation) and clamped to `medium` (or `high` when requested) for Codex Mini **5. Codex Instructions Caching**: + - Fetches from latest release tag (not main branch) - ETag-based HTTP conditional requests - Cache invalidation when release tag changes @@ -124,6 +134,7 @@ The main entry point orchestrates a **7-step fetch flow**: ### Modifying Request Transformation All request transformations go through `transformRequestBody()`: + - Input filtering: `filterInput()`, `filterOpenCodeSystemPrompts()` - Message injection: `addCodexBridgeMessage()` or `addToolRemapMessage()` - Reasoning config: `getReasoningConfig()` (follows Codex CLI defaults, not opencode defaults) @@ -132,6 +143,7 @@ All request transformations go through `transformRequestBody()`: ### OAuth Flow Modifications OAuth implementation follows OpenAI Codex CLI patterns: + - Client ID: `app_EMoamEEZ73f0CkXaXp7hrann` - PKCE with S256 challenge - Special params: `codex_cli_simplified_flow=true`, `originator=codex_cli_rs` @@ -148,16 +160,16 @@ OAuth implementation follows OpenAI Codex CLI patterns: This plugin **intentionally differs from opencode defaults** because it accesses ChatGPT backend API (not OpenAI Platform API): -| Setting | opencode Default | This Plugin Default | Reason | -|---------|-----------------|---------------------|--------| -| `reasoningEffort` | "high" (gpt-5) | "medium" | Matches Codex CLI default | -| `textVerbosity` | "low" (gpt-5) | "medium" | Matches Codex CLI default | -| `reasoningSummary` | "detailed" | "auto" | Matches Codex CLI default | -| gpt-5-codex config | (excluded) | Full support | opencode excludes gpt-5-codex from auto-config | -| `store` | true | false | Required for ChatGPT backend | -| `include` | (not set) | `["reasoning.encrypted_content"]` | Required for stateless operation | +| Setting | opencode Default | This Plugin Default | Reason | +| ------------------ | ---------------- | --------------------------------- | ---------------------------------------------- | +| `reasoningEffort` | "high" (gpt-5) | "medium" | Matches Codex CLI default | +| `textVerbosity` | "low" (gpt-5) | "medium" | Matches Codex CLI default | +| `reasoningSummary` | "detailed" | "auto" | Matches Codex CLI default | +| gpt-5-codex config | (excluded) | Full support | opencode excludes gpt-5-codex from auto-config | +| `store` | true | false | Required for ChatGPT backend | +| `include` | (not set) | `["reasoning.encrypted_content"]` | Required for stateless operation | -> **Extra High reasoning**: `reasoningEffort: "xhigh"` is only honored for `gpt-5.1-codex-max`. Other models automatically downgrade it to `high` so their API calls remain valid. +> **Extra High reasoning**: `reasoningEffort: "xhigh"` is honored for `gpt-5.1-codex-max` and `gpt-5.2`. Other models automatically downgrade it to `high` so their API calls remain valid. ## File Paths & Locations @@ -188,9 +200,11 @@ This plugin **intentionally differs from opencode defaults** because it accesses ## Dependencies **Production**: + - `@openauthjs/openauth` (OAuth PKCE implementation) **Development**: + - `@opencode-ai/plugin` (peer dependency) - `vitest` (testing framework) - TypeScript @@ -200,11 +214,13 @@ This plugin **intentionally differs from opencode defaults** because it accesses ## 🔗 Cross-Repository Integration ### Comprehensive Cross-References + - **[CROSS_REFERENCES.md](./CROSS_REFERENCES.md)** - Complete cross-references to all related repositories - **[Workspace AGENTS.md](../AGENTS.md)** - Main workspace documentation - **[Repository Index](../REPOSITORY_INDEX.md)** - Complete repository overview ### Related Repositories + - **[promethean](../promethean/)**: Agent orchestration and automated testing - **[agent-shell](../agent-shell/)**: Authentication patterns for Agent Shell - **[moofone/codex-ts-sdk](../moofone/codex-ts-sdk/)**: TypeScript SDK compatibility diff --git a/CHANGELOG.md b/CHANGELOG.md index fcb39ec..e3f8be5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ All notable changes to this project are documented here. Dates use the ISO format (YYYY-MM-DD). +## [3.4.0] - 2025-12-12 + +### Added + +- GPT-5.2 support mirroring the latest Codex CLI release: model normalization, reasoning heuristics (including native `xhigh`), text-verbosity defaults, sample config/test coverage, and docs describing the new frontier preset. + +### Changed + +- README, AGENTS.md, configuration docs, and the diagnostic script now call out GPT-5.2 alongside Codex Max wherever reasoning tiers or available presets are listed. + +### Fixed + +- Requests targeting `gpt-5.2` now clamp unsupported `none`/`minimal` reasoning values to `low`, preventing invalid API calls while keeping `xhigh` available without Codex Max. + ## [3.3.0] - 2025-11-19 ### Added diff --git a/README.md b/README.md index 7490035..dc50ce8 100644 --- a/README.md +++ b/README.md @@ -42,8 +42,12 @@ This plugin enables opencode to use OpenAI's Codex backend via ChatGPT Plus/Pro 2. Restart OpenCode (it installs plugins automatically). If prompted, run `opencode auth login` and finish the OAuth flow with your ChatGPT account. 3. In the TUI, choose `GPT 5.1 Codex Max (OAuth)` and start chatting. +Need a full walkthrough or update/cleanup steps? See [docs/getting-started.md](./docs/getting-started.md) and [docs/index.md](./docs/index.md#installation). + Prefer every preset? Copy [`config/full-opencode.json`](./config/full-opencode.json) instead; it registers all GPT-5.1/GPT-5 Codex variants with recommended settings. +Need live stats? A local dashboard now starts automatically (binds to 127.0.0.1 on a random port) and shows cache/request metrics plus the last few transformed requests; check logs for the URL. + Want to customize? Jump to [Configuration reference](#configuration-reference). ## Plugin-Level Settings @@ -108,17 +112,7 @@ Example: - **Reduces token consumption** by reusing cached prompts - **Lowers costs** significantly for multi-turn conversations -### Reducing Cache Churn (keep `prompt_cache_key` stable) - -- Why caches reset: OpenCode rebuilds the system/developer prompt every turn; the env block includes today’s date and a ripgrep tree of your workspace, so daily rollovers or file tree changes alter the prefix and trigger a new cache key. -- Keep the tree stable: ensure noisy/ephemeral dirs are ignored (e.g. `dist/`, `build/`, `.next/`, `coverage/`, `.cache/`, `logs/`, `tmp/`, `.turbo/`, `.vite/`, `.stryker-tmp/`, `artifacts/`, and similar). Put transient outputs under an ignored directory or `/tmp`. -- Don’t thrash the workspace mid-session: large checkouts, mass file generation, or moving directories will change the ripgrep listing and force a cache miss. -- Model/provider switches also change the system prompt (different base prompt), so avoid swapping models in the middle of a session if you want to reuse cache. -- Optional: set `CODEX_APPEND_ENV_CONTEXT=1` to reattach env/files at the end of the prompt instead of stripping them. This keeps the shared prefix stable (better cache reuse) while still sending env/files as a trailing developer message. Default is off (env/files stripped to maximize stability). - -### Managing Caching - -#### Recommended: Full Configuration (Codex CLI Experience) +## Recommended: Full Configuration (Codex CLI Experience) For the complete experience with all reasoning variants matching the official Codex CLI: @@ -441,7 +435,7 @@ For the complete experience with all reasoning variants matching the official Co **Global config**: `~/.config/opencode/opencode.json` **Project config**: `/.opencode.json` -This now gives you 21 model variants: the refreshed GPT-5.1 lineup (with Codex Max as the default) plus every legacy gpt-5 preset for backwards compatibility. +This now gives you 22 model variants: the refreshed GPT-5.2 frontier preset, the GPT-5.1 lineup (with Codex Max as the default), plus every legacy gpt-5 preset for backwards compatibility. All appear in the opencode model selector as "GPT 5.1 Codex Low (OAuth)", "GPT 5 High (OAuth)", etc. @@ -449,6 +443,12 @@ All appear in the opencode model selector as "GPT 5.1 Codex Low (OAuth)", "GPT 5 When using [`config/full-opencode.json`](./config/full-opencode.json), you get these GPT-5.1 presets plus the original gpt-5 variants: +#### GPT-5.2 frontier preset + +| CLI Model ID | TUI Display Name | Reasoning Effort | Best For | +| ------------ | ---------------- | ------------------------------ | ---------------------------------------------------------------------- | +| `gpt-5.2` | GPT 5.2 (OAuth) | Low/Medium/High/**Extra High** | Latest frontier model with improved reasoning + general-purpose coding | + #### GPT-5.1 lineup (recommended) | CLI Model ID | TUI Display Name | Reasoning Effort | Best For | @@ -464,7 +464,7 @@ When using [`config/full-opencode.json`](./config/full-opencode.json), you get t | `gpt-5.1-medium` | GPT 5.1 Medium (OAuth) | Medium | Default adaptive reasoning for everyday work | | `gpt-5.1-high` | GPT 5.1 High (OAuth) | High | Deep analysis when reliability matters most | -> **Extra High reasoning:** `reasoningEffort: "xhigh"` provides maximum computational effort for complex, multi-step problems and is exclusive to `gpt-5.1-codex-max`. Other models automatically map that option to `high` so their API calls remain valid. +> **Extra High reasoning:** `reasoningEffort: "xhigh"` provides maximum computational effort for complex, multi-step problems and is honored on `gpt-5.1-codex-max` and `gpt-5.2`. Other models automatically map that option to `high` so their API calls remain valid. #### Legacy GPT-5 lineup (still supported) @@ -520,7 +520,7 @@ When no configuration is specified, the plugin uses these defaults for all GPT-5 - **`reasoningSummary: "auto"`** - Automatically adapts summary verbosity - **`textVerbosity: "medium"`** - Balanced output length -These defaults match the official Codex CLI behavior and can be customized (see Configuration below). GPT-5.1 requests automatically start at `reasoningEffort: "none"`, while Codex/Codex Mini presets continue to clamp to their supported levels. +These defaults match the official Codex CLI behavior and can be customized (see Configuration below). GPT-5.1 requests automatically start at `reasoningEffort: "none"`, while Codex/Codex Mini presets continue to clamp to their supported levels, and GPT-5.2 keeps `reasoningEffort: "medium"` but accepts `xhigh` while mapping `none`/`minimal` to `low`. ## Configuration Reference @@ -560,7 +560,7 @@ Use the smallest working provider config if you only need one flagship model: The easiest way to get all presets is to use [`config/full-opencode.json`](./config/full-opencode.json), which provides: -- 21 pre-configured model variants matching the latest Codex CLI presets (GPT-5.1 Codex Max + GPT-5.1 + GPT-5) +- 22 pre-configured model variants matching the latest Codex CLI presets (GPT-5.2 + GPT-5.1 Codex lineup + GPT-5) - Optimal settings for each reasoning level - All variants visible in the opencode model selector @@ -581,9 +581,9 @@ If you want to customize settings yourself, you can configure options at provide | `textVerbosity` | `low`, `medium`, `high` | `medium` only | `medium` | | `include` | Array of strings | Array of strings | `["reasoning.encrypted_content"]` | -> **Note**: `minimal` effort is auto-normalized to `low` for gpt-5-codex (not supported by the API). `none` is only supported on GPT-5.1 general models; when used with legacy gpt-5 it is normalized to `minimal`. `xhigh` is exclusive to `gpt-5.1-codex-max`—other Codex presets automatically map it to `high`. +> **Note**: `minimal` effort is auto-normalized to `low` for gpt-5-codex (not supported by the API). `none` is only supported on GPT-5.1 general models; when used with legacy gpt-5 it is normalized to `minimal`, and `gpt-5.2` automatically bumps `none`/`minimal` to `low`. `xhigh` is honored on `gpt-5.1-codex-max` and `gpt-5.2`—other presets automatically map it to `high`. > -> † **Extra High reasoning**: `reasoningEffort: "xhigh"` provides maximum computational effort for complex, multi-step problems and is only available on `gpt-5.1-codex-max`. +> † **Extra High reasoning**: `reasoningEffort: "xhigh"` provides maximum computational effort for complex, multi-step problems and is only available on `gpt-5.1-codex-max` and `gpt-5.2`. #### Global Configuration Example diff --git a/config/full-opencode.json b/config/full-opencode.json index 64022e7..3239f02 100644 --- a/config/full-opencode.json +++ b/config/full-opencode.json @@ -1,17 +1,13 @@ { "$schema": "https://opencode.ai/config.json", - "plugin": [ - "@openhax/codex" - ], + "plugin": ["@openhax/codex"], "provider": { "openai": { "options": { "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false }, "models": { @@ -25,9 +21,7 @@ "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -41,9 +35,7 @@ "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -57,9 +49,7 @@ "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -73,9 +63,7 @@ "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -89,9 +77,7 @@ "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -105,9 +91,7 @@ "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -121,9 +105,7 @@ "reasoningEffort": "none", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -137,9 +119,7 @@ "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -153,9 +133,7 @@ "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -169,9 +147,21 @@ "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "high", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], + "store": false + } + }, + "gpt-5.2": { + "name": "GPT 5.2 (OAuth)", + "limit": { + "context": 400000, + "output": 128000 + }, + "options": { + "reasoningEffort": "medium", + "reasoningSummary": "auto", + "textVerbosity": "low", + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -185,9 +175,7 @@ "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -201,9 +189,7 @@ "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -217,9 +203,7 @@ "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -233,9 +217,7 @@ "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -249,9 +231,7 @@ "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -265,9 +245,7 @@ "reasoningEffort": "minimal", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -281,9 +259,7 @@ "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -297,9 +273,7 @@ "reasoningEffort": "medium", "reasoningSummary": "auto", "textVerbosity": "medium", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -313,9 +287,7 @@ "reasoningEffort": "high", "reasoningSummary": "detailed", "textVerbosity": "high", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -329,9 +301,7 @@ "reasoningEffort": "low", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } }, @@ -345,9 +315,7 @@ "reasoningEffort": "minimal", "reasoningSummary": "auto", "textVerbosity": "low", - "include": [ - "reasoning.encrypted_content" - ], + "include": ["reasoning.encrypted_content"], "store": false } } diff --git a/docs/development/CONFIG_FIELDS.md b/docs/development/CONFIG_FIELDS.md index b8ff402..0446e47 100644 --- a/docs/development/CONFIG_FIELDS.md +++ b/docs/development/CONFIG_FIELDS.md @@ -28,6 +28,7 @@ Understanding the difference between config key, `id`, and `name` fields in Open **Example:** `"gpt-5-codex-low"` **Used For:** + - ✅ CLI `--model` flag: `--model=openai/gpt-5-codex-low` - ✅ OpenCode internal lookups: `provider.info.models["gpt-5-codex-low"]` - ✅ TUI persistence: Saved to `~/.opencode/tui` as `model_id = "gpt-5-codex-low"` @@ -45,16 +46,19 @@ Understanding the difference between config key, `id`, and `name` fields in Open **Example:** `"gpt-5-codex"` **What it's used for:** + - ⚠️ **Other providers**: Some providers use this for `sdk.languageModel(id)` - ⚠️ **Sorting**: Used for model priority sorting in OpenCode - ⚠️ **Documentation**: Indicates the "canonical" model ID **What it's NOT used for with OpenAI:** + - ❌ **NOT sent to AI SDK** (config key is sent instead) - ❌ **NOT used by plugin** (plugin receives config key) - ❌ **NOT required** (OpenCode defaults it to config key) **Code Reference:** (`tmp/opencode/packages/opencode/src/provider/provider.ts:252`) + ```typescript const parsedModel: ModelsDev.Model = { id: model.id ?? modelID, // ← Defaults to config key if omitted @@ -63,14 +67,15 @@ const parsedModel: ModelsDev.Model = { ``` **OpenAI Custom Loader:** (`tmp/opencode/packages/opencode/src/provider/provider.ts:58-65`) + ```typescript openai: async () => { return { async getModel(sdk: any, modelID: string) { - return sdk.responses(modelID) // ← Receives CONFIG KEY, not id field! - } - } -} + return sdk.responses(modelID); // ← Receives CONFIG KEY, not id field! + }, + }; +}; ``` **Our plugin receives:** `body.model = "gpt-5-codex-low"` (config key, NOT id field) @@ -84,10 +89,12 @@ openai: async () => { **Example:** `"GPT 5 Codex Low (OAuth)"` **Used For:** + - ✅ **TUI Model Picker**: Display name shown in the model selection UI - ℹ️ **Documentation**: Human-friendly description **Code Reference:** (`tmp/opencode/packages/opencode/src/provider/provider.ts:253`) + ```typescript const parsedModel: ModelsDev.Model = { name: model.name ?? existing?.name ?? modelID, // Defaults to config key @@ -187,6 +194,7 @@ const parsedModel: ModelsDev.Model = { ``` **Purpose:** + - 🎯 **PRIMARY identifier** - used everywhere in OpenCode - 🎯 **Plugin receives this** - what our plugin sees in `body.model` - 🎯 **Config lookup key** - how plugin finds per-model options @@ -205,6 +213,7 @@ const parsedModel: ModelsDev.Model = { ``` **Purpose:** + - 📝 **Documents** what base model this variant uses - 📝 **Helps sorting** in model lists - 📝 **Clarity** - shows relationship between variants @@ -224,6 +233,7 @@ const parsedModel: ModelsDev.Model = { ``` **Purpose:** + - 🎨 **TUI display** - what users see in model picker - 🎨 **User-friendly** - can be descriptive - 🎨 **Differentiation** - helps distinguish from API key models @@ -247,6 +257,7 @@ const parsedModel: ModelsDev.Model = { ``` **When user selects `openai/gpt-5-codex-low`:** + - CLI: Uses `"gpt-5-codex-low"` (config key) - TUI: Shows `"GPT 5 Codex Low (OAuth)"` (name field) - Plugin receives: `body.model = "gpt-5-codex-low"` (config key) @@ -273,6 +284,7 @@ const parsedModel: ModelsDev.Model = { ``` **Why this works:** + - Config keys are different: `"gpt-5-codex-low"` vs `"gpt-5-codex-high"` ✅ - Same `id` is fine - it's just metadata - Different `name` values help distinguish in TUI @@ -304,13 +316,13 @@ const parsedModel: ModelsDev.Model = { ``` **Why this matters:** + - Config keys mirror the Codex CLI's 5.1 presets, making it obvious which tier you're targeting. - `reasoningEffort: "none"` (No Reasoning) disables reasoning entirely for latency-sensitive tasks and is only valid for GPT-5.1 general models—the plugin automatically downgrades unsupported values for Codex/Codex Mini. -- `reasoningEffort: "xhigh"` (Extra High) provides maximum computational effort for complex, multi-step problems and is exclusive to `gpt-5.1-codex-max`; other models automatically clamp it to `high`. +- `reasoningEffort: "xhigh"` (Extra High) provides maximum computational effort for complex, multi-step problems and is honored on `gpt-5.1-codex-max` and `gpt-5.2`; other models automatically clamp it to `high`. - Legacy GPT-5, GPT-5-Codex, and Codex Mini presets automatically clamp unsupported values (`none` → `minimal`/`low`, `minimal` → `low` for Codex). - Mixing GPT-5.1 and GPT-5 presets inside the same config is fine—just keep config keys unique and let the plugin normalize them. - --- ## Why We Need Different Config Keys @@ -321,22 +333,26 @@ const parsedModel: ModelsDev.Model = { ```json { - "gpt-5-codex-low": { // ← Unique config key #1 - "id": "gpt-5-codex", // ← Same base model + "gpt-5-codex-low": { + // ← Unique config key #1 + "id": "gpt-5-codex", // ← Same base model "options": { "reasoningEffort": "low" } }, - "gpt-5-codex-medium": { // ← Unique config key #2 - "id": "gpt-5-codex", // ← Same base model + "gpt-5-codex-medium": { + // ← Unique config key #2 + "id": "gpt-5-codex", // ← Same base model "options": { "reasoningEffort": "medium" } }, - "gpt-5-codex-high": { // ← Unique config key #3 - "id": "gpt-5-codex", // ← Same base model + "gpt-5-codex-high": { + // ← Unique config key #3 + "id": "gpt-5-codex", // ← Same base model "options": { "reasoningEffort": "high" } } } ``` **Result:** + - 3 selectable variants in TUI ✅ - Same API model (`gpt-5-codex`) ✅ - Different reasoning settings ✅ @@ -349,24 +365,29 @@ const parsedModel: ModelsDev.Model = { ### Config Changes are Safe ✅ **Old Plugin + Old Config:** + ```json "GPT 5 Codex Low (ChatGPT Subscription)": { "id": "gpt-5-codex", "options": { "reasoningEffort": "low" } } ``` + **Result:** ❌ Per-model options broken (existing bug in old plugin) **New Plugin + Old Config:** + ```json "GPT 5 Codex Low (ChatGPT Subscription)": { "id": "gpt-5-codex", "options": { "reasoningEffort": "low" } } ``` + **Result:** ✅ Per-model options work! (bug fixed) **New Plugin + New Config:** + ```json "gpt-5-codex-low": { "id": "gpt-5-codex", @@ -374,9 +395,11 @@ const parsedModel: ModelsDev.Model = { "options": { "reasoningEffort": "low" } } ``` + **Result:** ✅ Per-model options work! (bug fixed + cleaner naming) **Conclusion:** + - ✅ Existing configs continue to work - ✅ New configs work better - ✅ Users can migrate at their own pace @@ -402,27 +425,30 @@ const parsedModel: ModelsDev.Model = { ``` **What it does:** + - `false` (required): Prevents AI SDK from using `item_reference` for conversation history - `true` (default): Uses server-side storage with references (incompatible with Codex API) **Why required:** AI SDK 2.0.50 introduced automatic use of `item_reference` items to reduce payload size when `store: true`. However: + - Codex API requires `store: false` (stateless mode) - `item_reference` items cannot be resolved without server-side storage - Without this setting, multi-turn conversations fail with: `"Item with id 'fc_xxx' not found"` **Where to set:** + ```json { "provider": { "openai": { "options": { - "store": false // ← Global: applies to all models + "store": false // ← Global: applies to all models }, "models": { "gpt-5-codex-low": { "options": { - "store": false // ← Per-model: redundant but explicit + "store": false // ← Per-model: redundant but explicit } } } @@ -451,12 +477,14 @@ AI SDK 2.0.50 introduced automatic use of `item_reference` items to reduce paylo ``` **Benefits:** + - ✅ Clean config key: `gpt-5-codex-low` (matches Codex CLI presets) - ✅ Friendly display: `"GPT 5 Codex Low (OAuth)"` (UX) - ✅ No redundant fields - ✅ OpenCode auto-sets `id` to config key **Why no `id` field?** + - For OpenAI provider, the `id` field is NOT used (custom loader receives config key) - OpenCode defaults `id` to config key if omitted - Including it is redundant and creates confusion @@ -474,6 +502,7 @@ AI SDK 2.0.50 introduced automatic use of `item_reference` items to reduce paylo ``` **What happens:** + - `id` defaults to: `"gpt-5-codex-low"` (config key) - `name` defaults to: `"gpt-5-codex-low"` (config key) - TUI shows: `"gpt-5-codex-low"` (less friendly) @@ -495,6 +524,7 @@ AI SDK 2.0.50 introduced automatic use of `item_reference` items to reduce paylo ``` **What happens:** + - `id` field is stored but NOT used by OpenAI custom loader - Adds documentation value but is technically redundant - Works fine, just verbose @@ -503,18 +533,18 @@ AI SDK 2.0.50 introduced automatic use of `item_reference` items to reduce paylo ## Summary Table -| Use Case | Which Field? | Example Value | -|----------|-------------|---------------| -| **CLI `--model` flag** | Config Key | `openai/gpt-5-codex-low` | -| **Custom commands** | Config Key | `model: openai/gpt-5-codex-low` | -| **Agent config** | Config Key | `"model": "openai/gpt-5-codex-low"` | -| **TUI display** | `name` field | `"GPT 5 Codex Low (OAuth)"` | -| **Plugin config lookup** | Config Key | `models["gpt-5-codex-low"]` | -| **AI SDK receives** | Config Key | `body.model = "gpt-5-codex-low"` | -| **Plugin normalizes** | Transformed | `"gpt-5-codex"` (sent to API) | -| **TUI persistence** | Config Key | `model_id = "gpt-5-codex-low"` | -| **Documentation** | `id` field | `"gpt-5-codex"` (base model) | -| **Model sorting** | `id` field | Used for priority ranking | +| Use Case | Which Field? | Example Value | +| ------------------------ | ------------ | ----------------------------------- | +| **CLI `--model` flag** | Config Key | `openai/gpt-5-codex-low` | +| **Custom commands** | Config Key | `model: openai/gpt-5-codex-low` | +| **Agent config** | Config Key | `"model": "openai/gpt-5-codex-low"` | +| **TUI display** | `name` field | `"GPT 5 Codex Low (OAuth)"` | +| **Plugin config lookup** | Config Key | `models["gpt-5-codex-low"]` | +| **AI SDK receives** | Config Key | `body.model = "gpt-5-codex-low"` | +| **Plugin normalizes** | Transformed | `"gpt-5-codex"` (sent to API) | +| **TUI persistence** | Config Key | `model_id = "gpt-5-codex-low"` | +| **Documentation** | `id` field | `"gpt-5-codex"` (base model) | +| **Model sorting** | `id` field | Used for priority ranking | --- @@ -542,12 +572,14 @@ name field is UI sugar 🎨 ## Why The Bug Happened **Old Plugin Logic (Broken):** + ```typescript -const normalizedModel = normalizeModel(body.model); // "gpt-5-codex-low" → "gpt-5-codex" -const modelConfig = getModelConfig(normalizedModel, userConfig); // Lookup "gpt-5-codex" +const normalizedModel = normalizeModel(body.model); // "gpt-5-codex-low" → "gpt-5-codex" +const modelConfig = getModelConfig(normalizedModel, userConfig); // Lookup "gpt-5-codex" ``` **Problem:** + - Plugin received: `"gpt-5-codex-low"` (config key) - Plugin normalized first: `"gpt-5-codex"` - Plugin looked up config: `models["gpt-5-codex"]` ❌ NOT FOUND @@ -555,13 +587,15 @@ const modelConfig = getModelConfig(normalizedModel, userConfig); // Lookup "gpt - **Result:** Per-model options ignored! **New Plugin Logic (Fixed):** + ```typescript -const originalModel = body.model; // "gpt-5-codex-low" (config key) -const normalizedModel = normalizeModel(body.model); // "gpt-5-codex" (for API) -const modelConfig = getModelConfig(originalModel, userConfig); // Lookup "gpt-5-codex-low" ✅ +const originalModel = body.model; // "gpt-5-codex-low" (config key) +const normalizedModel = normalizeModel(body.model); // "gpt-5-codex" (for API) +const modelConfig = getModelConfig(originalModel, userConfig); // Lookup "gpt-5-codex-low" ✅ ``` **Fix:** + - Use original value (config key) for config lookup ✅ - Normalize separately for API call ✅ - **Result:** Per-model options applied correctly! @@ -573,6 +607,7 @@ const modelConfig = getModelConfig(originalModel, userConfig); // Lookup "gpt-5 ### Test Case 1: Which model does plugin send to API? **Config:** + ```json { "my-custom-name": { @@ -588,6 +623,7 @@ const modelConfig = getModelConfig(originalModel, userConfig); // Lookup "gpt-5 **Question:** What model does plugin send to Codex API? **Answer:** + 1. Plugin receives: `body.model = "my-custom-name"` 2. Plugin normalizes: `"my-custom-name"` → `"gpt-5-codex"` (contains "codex") 3. Plugin sends to API: `"gpt-5-codex"` ✅ @@ -599,6 +635,7 @@ const modelConfig = getModelConfig(originalModel, userConfig); // Lookup "gpt-5 ### Test Case 2: How does TUI know what to display? **Config:** + ```json { "ugly-key-123": { @@ -619,6 +656,7 @@ const modelConfig = getModelConfig(originalModel, userConfig); // Lookup "gpt-5 ### Test Case 3: How does plugin find config? **Config:** + ```json { "gpt-5-codex-low": { @@ -633,6 +671,7 @@ const modelConfig = getModelConfig(originalModel, userConfig); // Lookup "gpt-5 **Question:** How does plugin find the options? **Answer:** + 1. Plugin receives: `body.model = "gpt-5-codex-low"` 2. Plugin looks up: `userConfig.models["gpt-5-codex-low"]` ✅ 3. Plugin finds: `{ reasoningEffort: "low" }` ✅ @@ -647,7 +686,8 @@ const modelConfig = getModelConfig(originalModel, userConfig); // Lookup "gpt-5 ```json { - "gpt-5-codex": { // ❌ Can't have multiple variants + "gpt-5-codex": { + // ❌ Can't have multiple variants "id": "gpt-5-codex" } } @@ -698,7 +738,7 @@ This plugin supports both camelCase and snake_case cache key fields for maximum // Host provides snake_case (metadata) { - "prompt_cache_key": "cache-key-123", + "prompt_cache_key": "cache-key-123", "messages": [...] } @@ -707,6 +747,7 @@ const cacheKey = request.prompt_cache_key || request.promptCacheKey; ``` **Priority Order:** + 1. `prompt_cache_key` (snake_case) - from host or metadata 2. `promptCacheKey` (camelCase) - from OpenCode SDK 3. Fallback to generation if neither present diff --git a/docs/reasoning-effort-levels-update.md b/docs/reasoning-effort-levels-update.md index 0594ca8..99d27af 100644 --- a/docs/reasoning-effort-levels-update.md +++ b/docs/reasoning-effort-levels-update.md @@ -9,13 +9,15 @@ Update documentation to clearly explain all available reasoning effort levels fo Based on codebase analysis: ### Already Implemented ✅ + - `xhigh` reasoning effort is supported in code (`lib/types.ts:53`, `lib/request/request-transformer.ts:327`) - Tests cover `xhigh` handling (`test/request-transformer.test.ts:141-162`) - README.md mentions `xhigh` for Codex Max (`README.md:453,464,543,548`) - Configuration files include proper reasoning levels -- AGENTS.md documents `xhigh` exclusivity to Codex Max +- AGENTS.md documents how `xhigh` is handled for Codex Max and GPT-5.2 ### Documentation Gaps Identified + 1. README.md could be clearer about the complete range of reasoning levels 2. Need to ensure all reasoning levels (`none`, `low`, `medium`, `high`, `xhigh`) are clearly documented 3. Configuration examples should show the full spectrum @@ -23,17 +25,19 @@ Based on codebase analysis: ## Files to Update ### Primary Documentation + - `README.md` - Main user-facing documentation - `docs/development/CONFIG_FIELDS.md` - Developer configuration reference ### Configuration Examples (Already Up-to-Date) + - `config/full-opencode.json` - Complete configuration with all reasoning levels - `config/minimal-opencode.json` - Minimal configuration ## Definition of Done - [x] All reasoning effort levels (`none`, `low`, `medium`, `high`, `xhigh`) are clearly documented -- [x] `xhigh` exclusivity to `gpt-5.1-codex-max` is clearly explained +- [x] `xhigh` support for `gpt-5.1-codex-max` and `gpt-5.2` is clearly explained - [x] Automatic downgrade behavior for unsupported models is documented - [x] Configuration examples show the complete range of reasoning levels - [x] Documentation is consistent across all files @@ -42,30 +46,33 @@ Based on codebase analysis: ### Reasoning Effort Levels by Model Type -| Model Type | Supported Levels | Notes | -|------------|----------------|-------| -| `gpt-5.1-codex-max` | `low`, `medium`, `high`, `xhigh` | `xhigh` is exclusive to this model | -| `gpt-5.1-codex` | `low`, `medium`, `high` | `xhigh` auto-downgrades to `high` | -| `gpt-5.1-codex-mini` | `low`, `medium`, `high` | `xhigh` auto-downgrades to `high` | -| `gpt-5.1` (general) | `none`, `low`, `medium`, `high` | `none` only supported on general models | -| `gpt-5-codex` | `low`, `medium`, `high` | `minimal` auto-normalizes to `low` | -| `gpt-5` (legacy) | `minimal`, `low`, `medium`, `high` | `none` auto-normalizes to `minimal` | +| Model Type | Supported Levels | Notes | +| -------------------- | ---------------------------------- | ---------------------------------------------- | +| `gpt-5.1-codex-max` | `low`, `medium`, `high`, `xhigh` | `xhigh` supported (matches Codex CLI flagship) | +| `gpt-5.2` | `low`, `medium`, `high`, `xhigh` | `xhigh` supported; `none`/`minimal` → `low` | +| `gpt-5.1-codex` | `low`, `medium`, `high` | `xhigh` auto-downgrades to `high` | +| `gpt-5.1-codex-mini` | `low`, `medium`, `high` | `xhigh` auto-downgrades to `high` | +| `gpt-5.1` (general) | `none`, `low`, `medium`, `high` | `none` only supported on general models | +| `gpt-5-codex` | `low`, `medium`, `high` | `minimal` auto-normalizes to `low` | +| `gpt-5` (legacy) | `minimal`, `low`, `medium`, `high` | `none` auto-normalizes to `minimal` | ### Automatic Normalization Rules -1. **`xhigh` handling**: Only allowed on `gpt-5.1-codex-max`, others downgrade to `high` -2. **`none` handling**: Only supported on GPT-5.1 general models, legacy gpt-5 normalizes to `minimal` +1. **`xhigh` handling**: Allowed on `gpt-5.1-codex-max` and `gpt-5.2`; all other models downgrade to `high` +2. **`none` handling**: Only supported on GPT-5.1 general models (`gpt-5.2` maps to `low`; legacy gpt-5 normalizes to `minimal`) 3. **`minimal` handling**: Normalizes to `low` for Codex models (not supported by API) ## Changes Made ### README.md Updates + - Enhanced reasoning effort documentation table -- Added clearer explanation of `xhigh` exclusivity +- Added clearer explanation of how `xhigh` works across models - Updated model variant descriptions to include reasoning level ranges - Improved configuration examples section ### CONFIG_FIELDS.md Updates + - Added `xhigh` to the reasoning effort documentation - Clarified which models support which levels - Documented automatic normalization behavior @@ -73,6 +80,7 @@ Based on codebase analysis: ## Testing Verification All reasoning effort levels are already tested in: + - `test/request-transformer.test.ts:141-162` - `xhigh` handling tests - `test/request-transformer.test.ts:125-153` - Basic reasoning config tests - Integration tests cover full configuration flow @@ -81,4 +89,4 @@ All reasoning effort levels are already tested in: - **Users**: Clearer understanding of available reasoning levels and model capabilities - **Developers**: Better documentation for configuration options -- **Support**: Reduced confusion about reasoning effort limitations per model \ No newline at end of file +- **Support**: Reduced confusion about reasoning effort limitations per model diff --git a/index.ts b/index.ts index b0630be..a871093 100644 --- a/index.ts +++ b/index.ts @@ -46,6 +46,7 @@ import { getCodexInstructions } from "./lib/prompts/codex.js"; import { warmCachesOnStartup, areCachesWarm } from "./lib/cache/cache-warming.js"; import { createCodexFetcher } from "./lib/request/codex-fetcher.js"; import { SessionManager } from "./lib/session/session-manager.js"; +import { startDashboardServer } from "./lib/server/dashboard.js"; import type { UserConfig } from "./lib/types.js"; /** @@ -71,6 +72,9 @@ export const OpenAIAuthPlugin: Plugin = async ({ client, directory }: PluginInpu "The OpenAI Codex plugin is intended for personal use with your own ChatGPT Plus/Pro subscription. Ensure your usage complies with OpenAI's Terms of Service.", ); }, 5000); + + startDashboardServer(); + return { auth: { provider: PROVIDER_ID, diff --git a/lib/metrics/request-metrics.ts b/lib/metrics/request-metrics.ts new file mode 100644 index 0000000..5485dc3 --- /dev/null +++ b/lib/metrics/request-metrics.ts @@ -0,0 +1,186 @@ +export type RequestMetricsSnapshot = { + totalRequests: number; + promptCacheKey: { + withKey: number; + withoutKey: number; + }; + tools: { + requestsWithTools: number; + totalTools: number; + toolChoiceCounts: Record; + parallelToolCalls: number; + }; + models: Record; + reasoning: { + withReasoning: number; + effort: Record; + summary: Record; + textVerbosity: Record; + }; + recentRequests: RequestSummary[]; +}; + +export type RequestSummary = { + timestamp: number; + url: string; + model?: string; + promptCacheKey: boolean; + toolCount: number; + toolChoice?: string; + parallelToolCalls?: boolean; + include?: string[]; + store?: boolean; + reasoningEffort?: string; + reasoningSummary?: string; + textVerbosity?: string; +}; + +export type RequestMetricsInput = { + url: string; + model?: string; + promptCacheKey: boolean; + toolCount: number; + toolChoice?: string; + parallelToolCalls?: boolean; + include?: string[]; + store?: boolean; + reasoningEffort?: string; + reasoningSummary?: string; + textVerbosity?: string; +}; + +const MAX_RECENT_REQUESTS = 50; + +class RequestMetricsCollector { + private totalRequests = 0; + private promptCacheWith = 0; + private promptCacheWithout = 0; + private toolRequests = 0; + private totalTools = 0; + private toolChoiceCounts: Record = {}; + private parallelToolCalls = 0; + private models: Record = {}; + private reasoningEffort: Record = {}; + private reasoningSummary: Record = {}; + private textVerbosity: Record = {}; + private reasoningWith = 0; + private recentRequests: RequestSummary[] = []; + + recordRequest(input: RequestMetricsInput): void { + this.totalRequests += 1; + + if (input.promptCacheKey) { + this.promptCacheWith += 1; + } else { + this.promptCacheWithout += 1; + } + + if (input.toolCount > 0) { + this.toolRequests += 1; + this.totalTools += input.toolCount; + } + + if (input.toolChoice) { + this.toolChoiceCounts[input.toolChoice] = (this.toolChoiceCounts[input.toolChoice] ?? 0) + 1; + } + + if (input.parallelToolCalls) { + this.parallelToolCalls += 1; + } + + if (input.model) { + this.models[input.model] = (this.models[input.model] ?? 0) + 1; + } + + if (input.reasoningEffort || input.reasoningSummary || input.textVerbosity) { + this.reasoningWith += 1; + } + + if (input.reasoningEffort) { + this.reasoningEffort[input.reasoningEffort] = (this.reasoningEffort[input.reasoningEffort] ?? 0) + 1; + } + + if (input.reasoningSummary) { + this.reasoningSummary[input.reasoningSummary] = + (this.reasoningSummary[input.reasoningSummary] ?? 0) + 1; + } + + if (input.textVerbosity) { + this.textVerbosity[input.textVerbosity] = (this.textVerbosity[input.textVerbosity] ?? 0) + 1; + } + + const summary: RequestSummary = { + timestamp: Date.now(), + url: input.url, + model: input.model, + promptCacheKey: input.promptCacheKey, + toolCount: input.toolCount, + toolChoice: input.toolChoice, + parallelToolCalls: input.parallelToolCalls, + include: input.include, + store: input.store, + reasoningEffort: input.reasoningEffort, + reasoningSummary: input.reasoningSummary, + textVerbosity: input.textVerbosity, + }; + + this.recentRequests.push(summary); + if (this.recentRequests.length > MAX_RECENT_REQUESTS) { + this.recentRequests.shift(); + } + } + + getSnapshot(): RequestMetricsSnapshot { + return { + totalRequests: this.totalRequests, + promptCacheKey: { + withKey: this.promptCacheWith, + withoutKey: this.promptCacheWithout, + }, + tools: { + requestsWithTools: this.toolRequests, + totalTools: this.totalTools, + toolChoiceCounts: { ...this.toolChoiceCounts }, + parallelToolCalls: this.parallelToolCalls, + }, + models: { ...this.models }, + reasoning: { + withReasoning: this.reasoningWith, + effort: { ...this.reasoningEffort }, + summary: { ...this.reasoningSummary }, + textVerbosity: { ...this.textVerbosity }, + }, + recentRequests: [...this.recentRequests], + }; + } + + reset(): void { + this.totalRequests = 0; + this.promptCacheWith = 0; + this.promptCacheWithout = 0; + this.toolRequests = 0; + this.totalTools = 0; + this.toolChoiceCounts = {}; + this.parallelToolCalls = 0; + this.models = {}; + this.reasoningEffort = {}; + this.reasoningSummary = {}; + this.textVerbosity = {}; + this.reasoningWith = 0; + this.recentRequests = []; + } +} + +const requestMetricsCollector = new RequestMetricsCollector(); + +export function recordRequestMetrics(input: RequestMetricsInput): void { + requestMetricsCollector.recordRequest(input); +} + +export function getRequestMetricsSnapshot(): RequestMetricsSnapshot { + return requestMetricsCollector.getSnapshot(); +} + +export function resetRequestMetrics(): void { + requestMetricsCollector.reset(); +} diff --git a/lib/prompts/codex.ts b/lib/prompts/codex.ts index fe77ca1..9702c5a 100644 --- a/lib/prompts/codex.ts +++ b/lib/prompts/codex.ts @@ -195,6 +195,56 @@ async function fetchInstructionsWithFallback( } } +function loadFromCacheOrBundled( + cacheFilePath: string, + cachedETag: string | null, + cachedTag: string | null, + cacheFileExists: boolean, +): string { + if (cacheFileExists) { + const cachedContent = readCachedInstructions( + cacheFilePath, + cachedETag || undefined, + cachedTag || undefined, + ); + if (cachedContent) { + return cachedContent; + } + logWarn("Cached instructions unavailable; falling back to bundled copy"); + } + return loadBundledInstructions(); +} + +function handleLatestTagFailure( + cacheFilePath: string, + cachedETag: string | null, + cachedTag: string | null, + cacheFileExists: boolean, + error: unknown, +): string { + logWarn("Failed to get latest release tag; falling back to existing cache or bundled copy", { + error, + }); + return loadFromCacheOrBundled(cacheFilePath, cachedETag, cachedTag, cacheFileExists); +} + +function checkFreshCache( + cacheFilePath: string, + cachedETag: string | null, + cachedTag: string | null, +): string | null { + const cachedContent = readCachedInstructions( + cacheFilePath, + cachedETag || undefined, + cachedTag || undefined, + ); + if (cachedContent) { + return cachedContent; + } + logWarn("Cached Codex instructions were empty; attempting to refetch"); + return null; +} + /** * Fetch Codex instructions from GitHub with ETag-based caching * Uses HTTP conditional requests to efficiently check for updates @@ -226,36 +276,17 @@ export async function getCodexInstructions(): Promise { const cacheFileExists = fileExistsAndNotEmpty(cacheFilePath); if (cacheIsFresh(cachedTimestamp, cacheFileExists)) { - const cachedContent = readCachedInstructions( - cacheFilePath, - cachedETag || undefined, - cachedTag || undefined, - ); - if (cachedContent) { - return cachedContent; + const freshCache = checkFreshCache(cacheFilePath, cachedETag, cachedTag); + if (freshCache) { + return freshCache; } - logWarn("Cached Codex instructions were empty; attempting to refetch"); } let latestTag: string | undefined; try { latestTag = await getLatestReleaseTag(); } catch (error) { - logWarn("Failed to get latest release tag; falling back to existing cache or bundled copy", { - error, - }); - if (cacheFileExists) { - const cachedContent = readCachedInstructions( - cacheFilePath, - cachedETag || undefined, - cachedTag || undefined, - ); - if (cachedContent) { - return cachedContent; - } - logWarn("Cached instructions unavailable; falling back to bundled copy"); - } - return loadBundledInstructions(); + return handleLatestTagFailure(cacheFilePath, cachedETag, cachedTag, cacheFileExists, error); } if (!latestTag) { diff --git a/lib/request/codex-fetcher.ts b/lib/request/codex-fetcher.ts index 424fa8f..5bb3a70 100644 --- a/lib/request/codex-fetcher.ts +++ b/lib/request/codex-fetcher.ts @@ -3,6 +3,7 @@ import type { Auth } from "@opencode-ai/sdk"; import { maybeHandleCodexCommand } from "../commands/codex-metrics.js"; import { LOG_STAGES } from "../constants.js"; import { logRequest } from "../logger.js"; +import { recordRequestMetrics } from "../metrics/request-metrics.js"; import { recordSessionResponseFromHandledResponse } from "../session/response-recorder.js"; import type { SessionManager } from "../session/session-manager.js"; import type { PluginConfig, UserConfig } from "../types.js"; @@ -40,14 +41,63 @@ export function createCodexFetcher(deps: CodexFetcherDeps) { pluginConfig, } = deps; - return async function codexFetch(input: Request | string | URL, init?: RequestInit): Promise { - let currentAuth = await getAuth(); + async function ensureValidAuth(): Promise<{ auth: Auth; response?: Response }> { + const currentAuth = await getAuth(); if (shouldRefreshToken(currentAuth)) { const refreshResult = await refreshAndUpdateToken(currentAuth, client); if (!refreshResult.success) { - return refreshResult.response; + return { auth: currentAuth, response: refreshResult.response }; } - currentAuth = refreshResult.auth; + return { auth: refreshResult.auth }; + } + return { auth: currentAuth }; + } + + function extractRequestMetrics(requestUrl: string, body: Record) { + const promptCacheKey = Boolean(body.prompt_cache_key ?? body.promptCacheKey); + const tools = Array.isArray(body.tools) ? (body.tools as unknown[]) : []; + const toolChoiceRaw = body.tool_choice; + const toolChoice = + typeof toolChoiceRaw === "string" + ? toolChoiceRaw + : toolChoiceRaw && typeof toolChoiceRaw === "object" && "type" in toolChoiceRaw + ? (toolChoiceRaw as { type?: unknown }).type + : undefined; + const parallelToolCalls = + typeof body.parallel_tool_calls === "boolean" ? (body.parallel_tool_calls as boolean) : undefined; + const includeRaw = body.include; + const include = Array.isArray(includeRaw) + ? (includeRaw as unknown[]).filter((value): value is string => typeof value === "string") + : undefined; + const store = typeof body.store === "boolean" ? (body.store as boolean) : undefined; + const reasoning = body.reasoning as { effort?: unknown; summary?: unknown } | undefined; + const text = body.text as { verbosity?: unknown } | undefined; + let safeUrl = String(requestUrl); + try { + safeUrl = new URL(requestUrl).toString(); + } catch { + // keep derived string form + } + + return { + url: safeUrl, + model: typeof body.model === "string" ? (body.model as string) : undefined, + promptCacheKey, + toolCount: tools.length, + toolChoice: typeof toolChoice === "string" ? toolChoice : undefined, + parallelToolCalls, + include, + store, + reasoningEffort: typeof reasoning?.effort === "string" ? (reasoning.effort as string) : undefined, + reasoningSummary: typeof reasoning?.summary === "string" ? (reasoning.summary as string) : undefined, + textVerbosity: typeof text?.verbosity === "string" ? (text.verbosity as string) : undefined, + }; + } + + return async function codexFetch(input: Request | string | URL, init?: RequestInit): Promise { + const { auth: currentAuth, response: authErrorResponse } = await ensureValidAuth(); + if (authErrorResponse) { + return authErrorResponse; } const originalUrl = extractRequestUrl(input); @@ -69,15 +119,30 @@ export function createCodexFetcher(deps: CodexFetcherDeps) { } } - const hasTools = transformation?.body.tools !== undefined; - const requestInit = transformation?.updatedInit ?? init ?? {}; - const sessionContext = transformation?.sessionContext; + const transformedBody = transformation?.body; + let effectiveBody = transformedBody; + let effectiveContext = transformation?.sessionContext; + + if (sessionManager && transformedBody) { + const applyResult = sessionManager.applyRequest(transformedBody, effectiveContext); + effectiveBody = applyResult.body; + effectiveContext = applyResult.context ?? effectiveContext; + } + + if (effectiveBody) { + const metrics = extractRequestMetrics(url, effectiveBody as Record); + recordRequestMetrics(metrics); + } + + const hasTools = effectiveBody?.tools !== undefined; + const requestInit: RequestInit = { ...(transformation?.updatedInit ?? init ?? {}) }; + if (effectiveBody) { + requestInit.body = JSON.stringify(effectiveBody); + } const accessToken = currentAuth.type === "oauth" ? currentAuth.access : ""; const headers = createCodexHeaders(requestInit, accountId, accessToken, { - model: transformation?.body.model, - promptCacheKey: (transformation?.body as Record | undefined)?.prompt_cache_key as - | string - | undefined, + model: effectiveBody?.model, + promptCacheKey: effectiveBody?.prompt_cache_key, }); const response = await fetch(url, { ...requestInit, headers }); @@ -96,7 +161,7 @@ export function createCodexFetcher(deps: CodexFetcherDeps) { await recordSessionResponseFromHandledResponse({ sessionManager, - sessionContext, + sessionContext: effectiveContext, handledResponse, }); diff --git a/lib/request/fetch-helpers.ts b/lib/request/fetch-helpers.ts index 41108f4..0f90dac 100644 --- a/lib/request/fetch-helpers.ts +++ b/lib/request/fetch-helpers.ts @@ -165,29 +165,35 @@ export async function transformRequestForCodex( sessionContext, ); - const appliedContext = - sessionManager?.applyRequest(transformResult.body, sessionContext) ?? sessionContext; + + let resultingBody = transformResult.body; + let appliedContext = sessionContext; + if (sessionManager) { + const applyResult = sessionManager.applyRequest(transformResult.body, sessionContext); + resultingBody = applyResult.body; + appliedContext = applyResult.context ?? sessionContext; + } logRequest(LOG_STAGES.AFTER_TRANSFORM, { url, originalModel, - normalizedModel: transformResult.body.model, - hasTools: !!transformResult.body.tools, - hasInput: !!transformResult.body.input, - inputLength: transformResult.body.input?.length, - reasoning: transformResult.body.reasoning as unknown, - textVerbosity: transformResult.body.text?.verbosity, - include: transformResult.body.include, - body: transformResult.body as unknown as Record, + normalizedModel: resultingBody.model, + hasTools: !!resultingBody.tools, + hasInput: !!resultingBody.input, + inputLength: resultingBody.input?.length, + reasoning: resultingBody.reasoning as unknown, + textVerbosity: resultingBody.text?.verbosity, + include: resultingBody.include, + body: resultingBody as unknown as Record, }); const updatedInit: RequestInit = { ...init, - body: JSON.stringify(transformResult.body), + body: JSON.stringify(resultingBody), }; return { - body: transformResult.body, + body: resultingBody, updatedInit, sessionContext: appliedContext, }; diff --git a/lib/request/model-config.ts b/lib/request/model-config.ts index df5c832..2bd9419 100644 --- a/lib/request/model-config.ts +++ b/lib/request/model-config.ts @@ -12,6 +12,7 @@ export function normalizeModel(model: string | undefined): string { const contains = (needle: string) => sanitized.includes(needle); const hasGpt51 = contains("gpt-5-1") || sanitized.includes("gpt51"); + const hasGpt52 = contains("gpt-5-2") || sanitized.includes("gpt52"); const hasCodexMax = contains("codex-max") || contains("codexmax"); if (contains("gpt-5-1-codex-mini") || (hasGpt51 && contains("codex-mini"))) { @@ -29,6 +30,9 @@ export function normalizeModel(model: string | undefined): string { if (hasGpt51) { return "gpt-5.1"; } + if (hasGpt52) { + return "gpt-5.2"; + } if (contains("gpt-5-codex-mini") || contains("codex-mini-latest")) { return "gpt-5.1-codex-mini"; } @@ -56,6 +60,7 @@ type ModelFlags = { normalized: string; normalizedOriginal: string; isGpt51: boolean; + isGpt52: boolean; isCodexMini: boolean; isCodexMax: boolean; isCodexFamily: boolean; @@ -66,6 +71,7 @@ function classifyModel(originalModel: string | undefined): ModelFlags { const normalized = normalizeModel(originalModel); const normalizedOriginal = originalModel?.toLowerCase() ?? normalized; const isGpt51 = normalized.startsWith("gpt-5.1"); + const isGpt52 = normalized.startsWith("gpt-5.2"); const isCodexMiniSlug = normalized === "gpt-5.1-codex-mini" || normalized === "codex-mini-latest"; const isLegacyCodexMini = normalizedOriginal.includes("codex-mini-latest"); const isCodexMini = @@ -88,6 +94,7 @@ function classifyModel(originalModel: string | undefined): ModelFlags { normalized, normalizedOriginal, isGpt51, + isGpt52, isCodexMini, isCodexMax, isCodexFamily, @@ -99,6 +106,9 @@ function defaultEffortFor(flags: ModelFlags): ReasoningConfig["effort"] { if (flags.isGpt51 && !flags.isCodexFamily && !flags.isCodexMini) { return "none"; } + if (flags.isGpt52) { + return "medium"; + } if (flags.isCodexMini) { return "medium"; } @@ -112,7 +122,7 @@ function applyRequestedEffort( requested: ReasoningConfig["effort"], flags: ModelFlags, ): ReasoningConfig["effort"] { - if (requested === "xhigh" && !flags.isCodexMax) { + if (requested === "xhigh" && !(flags.isCodexMax || flags.isGpt52)) { return "high"; } return requested; @@ -143,6 +153,13 @@ function normalizeEffortForModel( return effort; } + if (flags.isGpt52) { + if (effort === "minimal" || effort === "none") { + return "low"; + } + return effort; + } + if (flags.isGpt51 && effort === "minimal") { return "none"; } diff --git a/lib/request/prompt-cache.ts b/lib/request/prompt-cache.ts index c70eccc..aee1191 100644 --- a/lib/request/prompt-cache.ts +++ b/lib/request/prompt-cache.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { logDebug, logInfo, logWarn } from "../logger.js"; import type { RequestBody } from "../types.js"; +import { formatPromptCacheKey } from "../utils/prompt-cache-key.js"; function stableStringify(value: unknown): string { if (value === null || typeof value !== "object") { @@ -41,16 +42,16 @@ function extractString(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function normalizeCacheKeyBase(base: string): string { +function sanitizeMetadataBase(base: string): string { const trimmed = base.trim(); if (!trimmed) { - return `cache_${randomUUID()}`; + return randomUUID(); } const sanitized = trimmed.replace(/\s+/g, "-"); return sanitized.startsWith("cache_") ? sanitized : `cache_${sanitized}`; } -function normalizeForkSuffix(forkId: string): string { +function sanitizeMetadataFork(forkId: string): string { const trimmed = forkId.trim(); if (!trimmed) return "fork"; return trimmed.replace(/\s+/g, "-"); @@ -174,9 +175,12 @@ export function ensurePromptCacheKey(body: RequestBody): PromptCacheKeyResult { const derived = derivePromptCacheKeyFromBody(body); if (derived.base) { - const baseKey = normalizeCacheKeyBase(derived.base); - const suffix = derived.forkId ? `-fork-${normalizeForkSuffix(derived.forkId)}` : ""; - const finalKey = `${baseKey}${suffix}`; + const finalKey = formatPromptCacheKey(derived.base, derived.forkId, { + sanitizeBase: sanitizeMetadataBase, + sanitizeFork: sanitizeMetadataFork, + prefix: "", + forkDelimiter: "-fork-", + }); body.prompt_cache_key = finalKey; return { key: finalKey, diff --git a/lib/request/request-transformer.ts b/lib/request/request-transformer.ts index 83fef9a..0aaa83b 100644 --- a/lib/request/request-transformer.ts +++ b/lib/request/request-transformer.ts @@ -145,9 +145,10 @@ export async function transformRequestBody( ...reasoningConfig, }; + const defaultTextVerbosity = normalizedModel.startsWith("gpt-5.2") ? "low" : "medium"; body.text = { ...body.text, - verbosity: modelConfig.textVerbosity || "medium", + verbosity: modelConfig.textVerbosity ?? defaultTextVerbosity, }; body.include = modelConfig.include || ["reasoning.encrypted_content"]; diff --git a/lib/server/dashboard.ts b/lib/server/dashboard.ts new file mode 100644 index 0000000..b3e2bc5 --- /dev/null +++ b/lib/server/dashboard.ts @@ -0,0 +1,167 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { logInfo, logWarn } from "../logger.js"; +import { getCachePerformanceReport } from "../cache/cache-metrics.js"; +import { getRequestMetricsSnapshot } from "../metrics/request-metrics.js"; + +const LOCALHOST = "127.0.0.1"; + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.statusCode = status; + res.setHeader("content-type", "application/json; charset=utf-8"); + res.end(JSON.stringify(body)); +} + +function sendHtml(res: ServerResponse, status: number, html: string): void { + res.statusCode = status; + res.setHeader("content-type", "text/html; charset=utf-8"); + res.end(html); +} + +function buildIndexHtml(): string { + return [ + "", + '', + "", + '', + "Codex Dashboard", + "", + "", + "", + "

Codex Dashboard

", + "

Local-only dashboard. Data auto-refreshes every 5s.

", + "
", + "

Metrics

", + '
Loading...
', + "
", + "
", + "

Recent Requests

", + "", + "", + '', + "
TimeModelURLPrompt CacheToolsReasoning
", + "
", + "", + "", + "", + ].join("\n"); +} + +function handleMetrics(_: IncomingMessage, res: ServerResponse): void { + const cacheReport = getCachePerformanceReport(); + const requestMetrics = getRequestMetricsSnapshot(); + sendJson(res, 200, { cacheReport, requestMetrics }); +} + +function handleRecent(_: IncomingMessage, res: ServerResponse): void { + const requestMetrics = getRequestMetricsSnapshot(); + sendJson(res, 200, { recentRequests: requestMetrics.recentRequests }); +} + +function handleHealth(_: IncomingMessage, res: ServerResponse): void { + sendJson(res, 200, { status: "ok" }); +} + +function handleIndex(_: IncomingMessage, res: ServerResponse): void { + sendHtml(res, 200, buildIndexHtml()); +} + +function route(req: IncomingMessage, res: ServerResponse): void { + const url = req.url ? new URL(req.url, "http://localhost") : null; + const path = url?.pathname || "/"; + + switch (path) { + case "/metrics": + handleMetrics(req, res); + return; + case "/recent": + handleRecent(req, res); + return; + case "/health": + handleHealth(req, res); + return; + case "/": + handleIndex(req, res); + return; + default: + sendJson(res, 404, { error: "not found" }); + } +} + +let serverStarted = false; +let serverPort: number | null = null; + +export function startDashboardServer(): void { + if (serverStarted) return; + if (process.env.NODE_ENV === "test") return; + + try { + const server = createServer(route); + server.listen(0, LOCALHOST, () => { + const address = server.address(); + if (address && typeof address === "object") { + serverPort = address.port; + logInfo("Codex dashboard server listening", { url: `http://${LOCALHOST}:${serverPort}` }); + } + }); + server.on("error", (error) => { + logWarn("Codex dashboard server failed to start", { + error: error instanceof Error ? error.message : String(error), + }); + }); + serverStarted = true; + } catch (error) { + logWarn("Codex dashboard server initialization failed", { + error: error instanceof Error ? error.message : String(error), + }); + } +} + +export function getDashboardPort(): number | null { + return serverPort; +} diff --git a/lib/session/session-manager.ts b/lib/session/session-manager.ts index 4dc0ad4..bc02367 100644 --- a/lib/session/session-manager.ts +++ b/lib/session/session-manager.ts @@ -1,9 +1,20 @@ -import { createHash, randomUUID } from "node:crypto"; +import { createHash } from "node:crypto"; import { SESSION_CONFIG } from "../constants.js"; import { logDebug, logWarn } from "../logger.js"; -import { PROMPT_CACHE_FORK_KEYS } from "../request/prompt-cache.js"; import type { CodexResponsePayload, InputItem, RequestBody, SessionContext, SessionState } from "../types.js"; -import { cloneInputItems } from "../utils/clone.js"; +import { + computeHash, + itemsEqual, + longestSharedPrefixLength, + isSystemLike, + extractConversationId, + extractForkIdentifier, + buildSessionKey, + createSessionState, +} from "./session-utils.js"; + +const ENV_MARKER_REGEX = + /|<\/env>||<\/files>|here is some useful information about the environment/i; export interface SessionManagerOptions { enabled: boolean; @@ -15,72 +26,6 @@ export interface SessionManagerOptions { // Clone utilities now imported from ../utils/clone.ts -function computeHash(items: InputItem[]): string { - try { - return createHash("sha1").update(JSON.stringify(items)).digest("hex"); - } catch { - return createHash("sha1").update(`fallback_${items.length}`).digest("hex"); - } -} - -function itemsEqual(a: InputItem | undefined, b: InputItem | undefined): boolean { - try { - return JSON.stringify(a) === JSON.stringify(b); - } catch { - return false; - } -} - -function longestSharedPrefixLength(previous: InputItem[], current: InputItem[]): number { - if (previous.length === 0 || current.length === 0) { - return 0; - } - - const limit = Math.min(previous.length, current.length); - let length = 0; - - for (let i = 0; i < limit; i += 1) { - if (!itemsEqual(previous[i], current[i])) { - break; - } - length += 1; - } - - return length; -} - -function sanitizeCacheKey(candidate: string): string { - const trimmed = candidate.trim(); - if (trimmed.length === 0) { - return `cache_${randomUUID()}`; - } - return trimmed; -} - -function isSystemLike(item: InputItem | undefined): boolean { - if (!item || typeof item.role !== "string") { - return false; - } - const role = item.role.toLowerCase(); - return role === "system" || role === "developer"; -} - -function isToolMessage(item: InputItem | undefined): boolean { - if (!item) return false; - const role = typeof item.role === "string" ? item.role.toLowerCase() : ""; - const type = typeof item.type === "string" ? item.type.toLowerCase() : ""; - const hasToolCall = - "tool_call_id" in (item as Record) || "tool_calls" in (item as Record); - return ( - role === "tool" || - type === "tool" || - type === "tool_call" || - type === "tool_result" || - type === "function" || - hasToolCall - ); -} - function fingerprintInputItem(item: InputItem | undefined): string | undefined { if (!item) return undefined; try { @@ -90,7 +35,7 @@ function fingerprintInputItem(item: InputItem | undefined): string | undefined { } } -function summarizeRoles(items: InputItem[]): string[] { +function _summarizeRoles(items: InputItem[]): string[] { const roles = new Set(); for (const item of items) { if (typeof item.role === "string" && item.role.trim()) { @@ -100,7 +45,7 @@ function summarizeRoles(items: InputItem[]): string[] { return Array.from(roles); } -function findSuffixReuseStart(previous: InputItem[], current: InputItem[]): number | null { +function _findSuffixReuseStart(previous: InputItem[], current: InputItem[]): number | null { if (previous.length === 0 || current.length === 0 || current.length > previous.length) { return null; } @@ -121,53 +66,62 @@ type PrefixChangeAnalysis = { details: Record; }; -function analyzePrefixChange( +function _analyzePrefixChange( previous: InputItem[], current: InputItem[], sharedPrefixLength: number, ): PrefixChangeAnalysis { const firstPrevious = previous[sharedPrefixLength]; const firstIncoming = current[sharedPrefixLength]; - const suffixReuseStart = findSuffixReuseStart(previous, current); - const removedSegment = - suffixReuseStart !== null && suffixReuseStart > 0 ? previous.slice(0, suffixReuseStart) : []; - const removedToolCount = removedSegment.filter((item) => isToolMessage(item)).length; - if (suffixReuseStart !== null && removedSegment.length > 0) { + if (isSystemLike(firstPrevious) && isSystemLike(firstIncoming)) { + return { + cause: "system_prompt_changed", + details: { + mismatchIndex: sharedPrefixLength, + previousFingerprint: fingerprintInputItem(firstPrevious), + incomingFingerprint: fingerprintInputItem(firstIncoming), + previousRole: firstPrevious.role, + incomingRole: firstIncoming.role, + }, + }; + } + + if (isSystemLike(firstPrevious) && !isSystemLike(firstIncoming)) { return { cause: "history_pruned", details: { mismatchIndex: sharedPrefixLength, - suffixReuseStart, - removedCount: removedSegment.length, - removedToolCount, - removedRoles: summarizeRoles(removedSegment), + previousFingerprint: fingerprintInputItem(firstPrevious), + incomingFingerprint: fingerprintInputItem(firstIncoming), + previousRole: firstPrevious.role, + incomingRole: firstIncoming.role, }, }; } - if (isSystemLike(firstPrevious) || isSystemLike(firstIncoming)) { + if (!isSystemLike(firstPrevious) && isSystemLike(firstIncoming)) { return { cause: "system_prompt_changed", details: { mismatchIndex: sharedPrefixLength, - previousRole: firstPrevious?.role, - incomingRole: firstIncoming?.role, previousFingerprint: fingerprintInputItem(firstPrevious), incomingFingerprint: fingerprintInputItem(firstIncoming), + previousRole: firstPrevious?.role, + incomingRole: firstIncoming.role, }, }; } - if (firstPrevious?.role === "user" && firstIncoming?.role === "user") { + if (!isSystemLike(firstPrevious) && !isSystemLike(firstIncoming)) { return { cause: "user_message_changed", details: { mismatchIndex: sharedPrefixLength, previousFingerprint: fingerprintInputItem(firstPrevious), incomingFingerprint: fingerprintInputItem(firstIncoming), - previousRole: firstPrevious.role, - incomingRole: firstIncoming.role, + previousRole: firstPrevious?.role, + incomingRole: firstIncoming?.role, }, }; } @@ -194,77 +148,6 @@ function buildPrefixForkIds( }; } -function extractConversationId(body: RequestBody): string | undefined { - const metadata = body.metadata as Record | undefined; - const bodyAny = body as Record; - const possibleKeys = [ - "conversation_id", - "conversationId", - "thread_id", - "threadId", - "session_id", - "sessionId", - "chat_id", - "chatId", - ]; - - for (const key of possibleKeys) { - const fromMetadata = metadata?.[key]; - if (typeof fromMetadata === "string" && fromMetadata.length > 0) { - return fromMetadata; - } - - const fromBody = bodyAny[key]; - if (typeof fromBody === "string" && fromBody.length > 0) { - return fromBody; - } - } - - return undefined; -} - -function extractForkIdentifier(body: RequestBody): string | undefined { - const metadata = body.metadata as Record | undefined; - const bodyAny = body as Record; - const normalize = (value: unknown): string | undefined => { - if (typeof value !== "string") { - return undefined; - } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - }; - - for (const key of PROMPT_CACHE_FORK_KEYS) { - const fromMetadata = normalize(metadata?.[key]); - if (fromMetadata) { - return fromMetadata; - } - const fromBody = normalize(bodyAny[key]); - if (fromBody) { - return fromBody; - } - } - - return undefined; -} - -function buildSessionKey(conversationId: string, forkId: string | undefined): string { - if (!forkId) { - return conversationId; - } - return `${conversationId}::fork::${forkId}`; -} - -// Keep in sync with ensurePromptCacheKey logic in request-transformer.ts so session-managed -// and stateless flows derive identical cache keys. -function buildPromptCacheKey(conversationId: string, forkId: string | undefined): string { - const sanitized = sanitizeCacheKey(conversationId); - if (!forkId) { - return sanitized; - } - return `${sanitized}::fork::${forkId}`; -} - export interface SessionMetricsSnapshot { enabled: boolean; totalSessions: number; @@ -276,6 +159,11 @@ export interface SessionMetricsSnapshot { }>; } +export interface SessionApplyResult { + body: RequestBody; + context?: SessionContext; +} + export class SessionManager { private readonly options: SessionManagerOptions; @@ -295,220 +183,99 @@ export class SessionManager { const conversationId = extractConversationId(body); const forkId = extractForkIdentifier(body); if (!conversationId) { - // Fall back to host-provided prompt_cache_key if no metadata ID is available - const hostCacheKey = (body as any).prompt_cache_key || (body as any).promptCacheKey; + const hostCacheKey = body.prompt_cache_key || body.promptCacheKey; if (hostCacheKey && typeof hostCacheKey === "string") { - // Use the existing cache key as session identifier to maintain continuity - const existing = this.sessions.get(hostCacheKey); - if (existing) { - return { - sessionId: hostCacheKey, - enabled: true, - preserveIds: true, - isNew: false, - state: existing, - }; - } - - const state: SessionState = { - id: hostCacheKey, - promptCacheKey: sanitizeCacheKey(hostCacheKey), - store: this.options.forceStore ?? false, - lastInput: [], - lastPrefixHash: null, - lastUpdated: Date.now(), - }; - - this.sessions.set(hostCacheKey, state); - this.pruneSessions(); - return { - sessionId: hostCacheKey, - enabled: true, - preserveIds: true, - isNew: true, - state, - }; + const existingState = this.sessions.get(hostCacheKey); + const state = existingState ?? this.resetSessionInternal(hostCacheKey); + return state ? this.buildContext(state, !existingState) : undefined; } return undefined; } const sessionKey = buildSessionKey(conversationId, forkId); - const promptCacheKey = buildPromptCacheKey(conversationId, forkId); - const existing = this.findExistingSession(sessionKey); - if (existing) { - return { - sessionId: existing.id, - enabled: true, - preserveIds: true, - isNew: false, - state: existing, - }; - } - const state: SessionState = { - id: sessionKey, - promptCacheKey, - store: this.options.forceStore ?? false, - lastInput: [], - lastPrefixHash: null, - lastUpdated: Date.now(), - }; + if (existing) { + const currentInput = Array.isArray(body.input) ? body.input : []; + const analysis = this.analyzeInputChange(existing.lastInput, currentInput); + if (analysis.cause !== "unknown") { + logWarn("SessionManager: prefix mismatch detected", { + sessionId: existing.id, + prefixCause: analysis.cause, + ...analysis.details, + }); + } - this.sessions.set(sessionKey, state); - this.pruneSessions(); + if (analysis.cause === "system_prompt_changed") { + const prefixForkIds = buildPrefixForkIds(existing.id, existing.promptCacheKey, existing.lastInput); + const forkState = this.resetSessionInternal(prefixForkIds.sessionId, false); + return forkState ? this.buildContext(forkState, true) : undefined; + } + } - return { - sessionId: sessionKey, - enabled: true, - preserveIds: true, - isNew: true, - state, - }; + const state = existing || this.resetSessionInternal(sessionKey); + return state ? this.buildContext(state, !existing) : undefined; } - public applyRequest(body: RequestBody, context: SessionContext | undefined): SessionContext | undefined { - if (!context?.enabled) { - return context; + public recordResponse(session: string | SessionContext, response: CodexResponsePayload): void { + if (!this.options.enabled) { + return; } - const state = context.state; - // eslint-disable-next-line no-param-reassign - body.prompt_cache_key = state.promptCacheKey; - if (state.store) { - // eslint-disable-next-line no-param-reassign - body.store = true; + const sessionId = typeof session === "string" ? session : session.sessionId; + const state = typeof session === "string" ? this.sessions.get(sessionId) : session.state; + if (!state) { + return; } - const input = cloneInputItems(body.input || []); - const inputHash = computeHash(input); - - if (state.lastInput.length === 0) { - state.lastInput = input; - state.lastPrefixHash = inputHash; - state.lastUpdated = Date.now(); - logDebug("SessionManager: initialized session", { + const cachedTokens = response.usage?.cached_tokens; + if (typeof cachedTokens === "number") { + state.lastCachedTokens = cachedTokens; + logDebug("SessionManager: response usage", { sessionId: state.id, - promptCacheKey: state.promptCacheKey, - inputCount: input.length, + cachedTokens, }); - return context; } + state.lastUpdated = Date.now(); + } - const sharedPrefixLength = longestSharedPrefixLength(state.lastInput, input); - const hasFullPrefixMatch = sharedPrefixLength === state.lastInput.length; - - if (!hasFullPrefixMatch) { - const prefixAnalysis = analyzePrefixChange(state.lastInput, input, sharedPrefixLength); - if (sharedPrefixLength === 0) { - logWarn("SessionManager: prefix mismatch detected, regenerating cache key", { - sessionId: state.id, - promptCacheKey: state.promptCacheKey, - sharedPrefixLength, - previousItems: state.lastInput.length, - incomingItems: input.length, - previousHash: state.lastPrefixHash, - incomingHash: inputHash, - prefixCause: prefixAnalysis.cause, - ...prefixAnalysis.details, - }); + public applyRequest(body: RequestBody, context?: SessionContext): SessionApplyResult { + const clonedBody = this.cloneRequestBody(body); - const refreshed = this.resetSessionInternal(state.id, true); - if (!refreshed) { - return undefined; - } - refreshed.lastInput = input; - refreshed.lastPrefixHash = inputHash; - refreshed.lastUpdated = Date.now(); - // eslint-disable-next-line no-param-reassign - body.prompt_cache_key = refreshed.promptCacheKey; - if (refreshed.store) { - // eslint-disable-next-line no-param-reassign - body.store = true; - } - return { - sessionId: refreshed.id, - enabled: true, - preserveIds: true, - isNew: true, - state: refreshed, - }; - } + if (!this.options.enabled || !context) { + return { body: clonedBody, context }; + } - const sharedPrefix = input.slice(0, sharedPrefixLength); - const { sessionId: forkSessionId, promptCacheKey: forkPromptCacheKey } = buildPrefixForkIds( - state.id, - state.promptCacheKey, - sharedPrefix, - ); - const forkState: SessionState = { - id: forkSessionId, - promptCacheKey: forkPromptCacheKey, - store: state.store, - lastInput: input, - lastPrefixHash: inputHash, - lastUpdated: Date.now(), - lastCachedTokens: state.lastCachedTokens, - bridgeInjected: state.bridgeInjected, - }; - - this.sessions.set(forkSessionId, forkState); - logWarn("SessionManager: prefix mismatch detected, forking session", { - sessionId: state.id, - promptCacheKey: state.promptCacheKey, - forkSessionId, - forkPromptCacheKey, - sharedPrefixLength, - previousItems: state.lastInput.length, - incomingItems: input.length, - previousHash: state.lastPrefixHash, - incomingHash: inputHash, - prefixCause: prefixAnalysis.cause, - ...prefixAnalysis.details, - }); - // eslint-disable-next-line no-param-reassign - body.prompt_cache_key = forkPromptCacheKey; - if (forkState.store) { - // eslint-disable-next-line no-param-reassign - body.store = true; - } - return { - sessionId: forkSessionId, - enabled: true, - preserveIds: true, - isNew: true, - state: forkState, - }; + const existingState = this.sessions.get(context.sessionId) ?? context.state; + if (!existingState) { + return { body: clonedBody, context }; } - state.lastInput = input; - state.lastPrefixHash = inputHash; - state.lastUpdated = Date.now(); + const nextInput = Array.isArray(clonedBody.input) ? this.cloneInputItems(clonedBody.input) : []; + const newState: SessionState = { + ...existingState, + lastInput: nextInput, + lastPrefixHash: nextInput.length ? computeHash(nextInput) : null, + lastUpdated: Date.now(), + }; + this.sessions.set(newState.id, newState); - return context; - } + const updatedContext: SessionContext = { + ...context, + isNew: false, + state: newState, + }; - public recordResponse( - context: SessionContext | undefined, - payload: CodexResponsePayload | undefined, - ): void { - if (!context?.enabled || !payload) { - return; + if (newState.promptCacheKey) { + clonedBody.prompt_cache_key = newState.promptCacheKey; + clonedBody.promptCacheKey = newState.promptCacheKey; } - const state = context.state; - const cachedTokens = payload.usage?.cached_tokens; - if (typeof cachedTokens === "number") { - state.lastCachedTokens = cachedTokens; - logDebug("SessionManager: response usage", { - sessionId: state.id, - cachedTokens, - }); - } - state.lastUpdated = Date.now(); + return { body: clonedBody, context: updatedContext }; } public getMetrics(limit = 5): SessionMetricsSnapshot { + this.pruneSessions(); const maxEntries = Math.max(0, limit); const recentSessions = Array.from(this.sessions.values()) .sort((a, b) => b.lastUpdated - a.lastUpdated) @@ -527,6 +294,16 @@ export class SessionManager { }; } + private buildContext(state: SessionState, isNew: boolean): SessionContext { + return { + sessionId: state.id, + enabled: this.options.enabled, + preserveIds: true, + isNew, + state, + }; + } + private findExistingSession(sessionKey: string): SessionState | undefined { const direct = this.sessions.get(sessionKey); let best = direct; @@ -584,20 +361,103 @@ export class SessionManager { private resetSessionInternal(sessionId: string, forceRandomKey = false): SessionState | undefined { const existing = this.sessions.get(sessionId); - const keySeed = existing?.id ?? sessionId; - const promptCacheKey = forceRandomKey - ? `cache_${randomUUID()}` - : sanitizeCacheKey(keySeed === sessionId ? sessionId : keySeed); - const state: SessionState = { - id: sessionId, - promptCacheKey, - store: this.options.forceStore ?? false, - lastInput: [], - lastPrefixHash: null, - lastUpdated: Date.now(), - }; + const state = createSessionState(sessionId, this.options.forceStore ?? false, forceRandomKey, existing); this.sessions.set(sessionId, state); return state; } + + private analyzeInputChange(previous: InputItem[], current: InputItem[]): PrefixChangeAnalysis { + const normalizedPrevious = this.normalizeInputForComparison(previous); + const normalizedCurrent = this.normalizeInputForComparison(current); + const sharedPrefixLength = longestSharedPrefixLength(normalizedPrevious, normalizedCurrent); + const baseAnalysis = _analyzePrefixChange(normalizedPrevious, normalizedCurrent, sharedPrefixLength); + const details: Record = { + ...baseAnalysis.details, + sharedPrefixLength, + }; + + if (baseAnalysis.cause === "history_pruned") { + const removedCount = Math.max(0, normalizedPrevious.length - normalizedCurrent.length); + if (removedCount > 0) { + details.removedCount = removedCount; + details.removedRoles = _summarizeRoles(normalizedPrevious.slice(0, removedCount)); + } + const suffixReuseStart = _findSuffixReuseStart(normalizedPrevious, normalizedCurrent); + if (suffixReuseStart !== null) { + details.suffixReuseStart = suffixReuseStart; + } + } + + return { + cause: baseAnalysis.cause, + details, + }; + } + + private cloneRequestBody(body: RequestBody): RequestBody { + const cloned: RequestBody = { + ...body, + metadata: body.metadata ? { ...body.metadata } : undefined, + include: body.include ? [...body.include] : undefined, + text: body.text ? { ...body.text } : undefined, + reasoning: body.reasoning ? { ...body.reasoning } : undefined, + }; + + if (Array.isArray(body.input)) { + cloned.input = this.cloneInputItems(body.input); + } + + return cloned; + } + + private cloneInputItems(input: InputItem[]): InputItem[] { + try { + return JSON.parse(JSON.stringify(input)) as InputItem[]; + } catch { + return input.map((item) => ({ ...item })); + } + } + + private normalizeInputForComparison(items: InputItem[]): InputItem[] { + return items.filter((item) => !this.isEnvContextMessage(item)); + } + + private extractContentText(content: unknown): string { + if (typeof content === "string") { + return content; + } + if (Array.isArray(content)) { + return content + .map((segment) => { + if (typeof segment === "string") { + return segment; + } + if (segment && typeof segment === "object" && "text" in segment) { + const value = (segment as { text?: unknown }).text; + return typeof value === "string" ? value : ""; + } + return ""; + }) + .join("\n"); + } + return ""; + } + + private isEnvContextMessage(item: InputItem | undefined): boolean { + if (!item || typeof item.role !== "string") { + return false; + } + const role = item.role.toLowerCase(); + if (role !== "system" && role !== "developer") { + return false; + } + + const normalizedText = this.extractContentText(item.content).toLowerCase(); + if (!normalizedText) { + return false; + } + + return ENV_MARKER_REGEX.test(normalizedText); + } } diff --git a/lib/session/session-utils.ts b/lib/session/session-utils.ts new file mode 100644 index 0000000..46bf49d --- /dev/null +++ b/lib/session/session-utils.ts @@ -0,0 +1,164 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { PROMPT_CACHE_FORK_KEYS } from "../request/prompt-cache.js"; +import type { InputItem, RequestBody, SessionState } from "../types.js"; +import { formatPromptCacheKey } from "../utils/prompt-cache-key.js"; + +export function computeHash(items: InputItem[]): string { + try { + return createHash("sha1").update(JSON.stringify(items)).digest("hex"); + } catch { + const roleSummary = items + .map((item) => { + if (typeof item?.role === "string" && item.role.trim()) { + return item.role.trim().toLowerCase(); + } + if (typeof (item as { id?: unknown }).id === "string") { + return `id:${(item as { id: string }).id}`; + } + return typeof item; + }) + .join("|"); + const nonce = randomBytes(8).toString("hex"); + return createHash("sha1").update(`fallback_${items.length}_${roleSummary}_${nonce}`).digest("hex"); + } +} + +export function itemsEqual(a: InputItem | undefined, b: InputItem | undefined): boolean { + try { + return JSON.stringify(a) === JSON.stringify(b); + } catch { + return false; + } +} + +export function longestSharedPrefixLength(previous: InputItem[], current: InputItem[]): number { + if (previous.length === 0 || current.length === 0) { + return 0; + } + + const limit = Math.min(previous.length, current.length); + let length = 0; + + for (let i = 0; i < limit; i += 1) { + if (!itemsEqual(previous[i], current[i])) { + break; + } + length += 1; + } + + return length; +} + +export function sanitizeCacheKey(candidate: string): string { + const trimmed = candidate.trim(); + if (trimmed.length === 0) { + return `cache_${randomUUID()}`; + } + return trimmed; +} + +export function isSystemLike(item: InputItem | undefined): boolean { + if (!item || typeof item.role !== "string") { + return false; + } + const role = item.role.toLowerCase(); + return role === "system" || role === "developer"; +} + +export function isToolMessage(item: InputItem | undefined): boolean { + if (!item) return false; + const role = typeof item.role === "string" ? item.role.toLowerCase() : ""; + const type = typeof item.type === "string" ? item.type.toLowerCase() : ""; + return role === "tool" || type === "tool_call" || type === "tool_result"; +} + +export function extractConversationId(body: RequestBody): string | undefined { + const metadata = body.metadata as Record | undefined; + const bodyAny = body as Record; + const possibleKeys = [ + "conversation_id", + "conversationId", + "thread_id", + "threadId", + "session_id", + "sessionId", + "chat_id", + "chatId", + ]; + + for (const key of possibleKeys) { + const fromMetadata = metadata?.[key]; + if (typeof fromMetadata === "string" && fromMetadata.length > 0) { + return fromMetadata; + } + + const fromBody = bodyAny[key]; + if (typeof fromBody === "string" && fromBody.length > 0) { + return fromBody; + } + } + + return undefined; +} + +export function extractForkIdentifier(body: RequestBody): string | undefined { + const metadata = body.metadata as Record | undefined; + const bodyAny = body as Record; + const normalize = (value: unknown): string | undefined => { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; + }; + + for (const key of PROMPT_CACHE_FORK_KEYS) { + const fromMetadata = normalize(metadata?.[key]); + if (fromMetadata) { + return fromMetadata; + } + const fromBody = normalize(bodyAny[key]); + if (fromBody) { + return fromBody; + } + } + + return undefined; +} + +export function buildSessionKey(conversationId: string, forkId: string | undefined): string { + if (!forkId) { + return conversationId; + } + return `${conversationId}::fork::${forkId}`; +} + +// Keep in sync with ensurePromptCacheKey logic in request-transformer.ts so session-managed +// and stateless flows derive identical cache keys. +export function buildPromptCacheKey(conversationId: string, forkId: string | undefined): string { + return formatPromptCacheKey(conversationId, forkId, { + prefix: "", + forkDelimiter: "::fork::", + sanitizeBase: sanitizeCacheKey, + sanitizeFork: sanitizeCacheKey, + }); +} + +export function createSessionState( + sessionId: string, + forceStore: boolean, + forceRandomKey = false, + existing?: SessionState, +): SessionState { + const keySeed = existing?.id ?? sessionId; + const promptCacheKey = forceRandomKey ? `cache_${randomUUID()}` : sanitizeCacheKey(keySeed); + + return { + id: sessionId, + promptCacheKey, + store: forceStore, + lastInput: [], + lastPrefixHash: null, + lastUpdated: Date.now(), + }; +} diff --git a/lib/types.ts b/lib/types.ts index da74ab4..472474d 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -175,6 +175,8 @@ export interface RequestBody { metadata?: Record; /** Stable key to enable prompt-token caching on Codex backend */ prompt_cache_key?: string; + /** camelCase alias for prompt_cache_key preserved for backwards compatibility */ + promptCacheKey?: string; max_output_tokens?: number; max_completion_tokens?: number; [key: string]: unknown; diff --git a/lib/update/auto-update.ts b/lib/update/auto-update.ts new file mode 100644 index 0000000..dbae95e --- /dev/null +++ b/lib/update/auto-update.ts @@ -0,0 +1,259 @@ +import type { OpencodeClient } from "@opencode-ai/sdk"; +import { existsSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { logInfo, logWarn } from "../logger.js"; +import { CACHE_FILES } from "../utils/cache-config.js"; +import { getOpenCodePath, safeReadFile, safeWriteFile } from "../utils/file-system-utils.js"; + +const require = createRequire(import.meta.url); +const packageInfo = require("../../package.json") as { version?: string }; + +const REGISTRY_URL = "https://registry.npmjs.org/@openhax/codex"; +const REGISTRY_TIMEOUT_MS = 5000; +const AUTO_UPDATE_TTL_MS = 15 * 60 * 1000; // match cache TTL +const PACKAGE_VERSION = typeof packageInfo.version === "string" ? packageInfo.version : null; + +const UPDATE_STATE_PATH = getOpenCodePath("cache", CACHE_FILES.AUTO_UPDATE_STATE); + +interface UpdateState { + lastChecked?: number; + lastNotifiedVersion?: string; + lastNotifiedAt?: number; + lastError?: string; +} + +function readUpdateState(): UpdateState { + const content = safeReadFile(UPDATE_STATE_PATH); + if (!content) return {}; + try { + return JSON.parse(content) as UpdateState; + } catch { + return {}; + } +} + +function writeUpdateState(state: UpdateState): void { + safeWriteFile(UPDATE_STATE_PATH, JSON.stringify(state)); +} + +function shouldThrottle(state: UpdateState, now: number): boolean { + return typeof state.lastChecked === "number" && now - state.lastChecked < AUTO_UPDATE_TTL_MS; +} + +async function parseLocalVersion(): Promise { + if (typeof PACKAGE_VERSION === "string") { + return PACKAGE_VERSION; + } + + logWarn("Failed to locate local package version", { + error: "version not found", + }); + return null; +} + +const NUMERIC_IDENTIFIER = /^\d+$/; + +function parseSemver(version: string): { core: number[]; prerelease: (number | string)[] } { + const sanitized = version.trim().replace(/^v/i, ""); + const [corePart, prereleasePart] = sanitized.split("-", 2); + const core = corePart.split(".").map((segment) => Number.parseInt(segment, 10) || 0); + const prerelease = prereleasePart + ? prereleasePart + .split(".") + .map((identifier) => (NUMERIC_IDENTIFIER.test(identifier) ? Number(identifier) : identifier)) + : []; + return { core, prerelease }; +} + +function compareCoreParts(coreA: number[], coreB: number[]): number { + const maxLength = Math.max(coreA.length, coreB.length); + for (let index = 0; index < maxLength; index += 1) { + const partA = coreA[index] ?? 0; + const partB = coreB[index] ?? 0; + if (partA > partB) return 1; + if (partA < partB) return -1; + } + return 0; +} + +function comparePrereleaseParts(a: (number | string)[], b: (number | string)[]): number { + if (a.length === 0 && b.length === 0) { + return 0; + } + if (a.length === 0) { + return 1; + } + if (b.length === 0) { + return -1; + } + + const maxPre = Math.max(a.length, b.length); + for (let index = 0; index < maxPre; index += 1) { + const idA = a[index]; + const idB = b[index]; + if (idA === undefined) return -1; + if (idB === undefined) return 1; + if (typeof idA === "number" && typeof idB === "number") { + if (idA > idB) return 1; + if (idA < idB) return -1; + continue; + } + if (typeof idA === "number") { + return -1; + } + if (typeof idB === "number") { + return 1; + } + if (idA > idB) return 1; + if (idA < idB) return -1; + } + + return 0; +} + +function compareSemver(a: string, b: string): number { + const parsedA = parseSemver(a); + const parsedB = parseSemver(b); + const coreComparison = compareCoreParts(parsedA.core, parsedB.core); + if (coreComparison !== 0) { + return coreComparison; + } + return comparePrereleaseParts(parsedA.prerelease, parsedB.prerelease); +} + +function isNewerVersion(current: string, latest: string): boolean { + return compareSemver(latest, current) > 0; +} + +async function fetchLatestVersion(): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REGISTRY_TIMEOUT_MS); + try { + const response = await fetch(REGISTRY_URL, { + headers: { + accept: "application/vnd.npm.install-v1+json", + }, + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`registry responded ${response.status}`); + } + const body = (await response.json()) as { "dist-tags"?: { latest?: string } }; + return body?.["dist-tags"]?.latest ?? null; + } catch (error) { + const isAbort = error instanceof Error && error.name === "AbortError"; + logWarn("Failed to fetch latest version from npm", { + error: isAbort ? "request timed out" : error instanceof Error ? error.message : String(error), + }); + return null; + } finally { + clearTimeout(timeout); + } +} + +function showToast(client: OpencodeClient | undefined, message: string): void { + try { + if (client?.tui?.showToast) { + client.tui.showToast({ + body: { + title: "Codex update available", + message, + variant: "info", + }, + }); + } + } catch (error) { + logWarn("Failed to show toast", { + error: error instanceof Error ? error.message : String(error), + }); + } +} + +function removePath(path: string, removed: string[], failed: string[]): void { + try { + rmSync(path, { recursive: true, force: true }); + removed.push(path); + } catch (error) { + failed.push(`${path}: ${error instanceof Error ? error.message : String(error)}`); + } +} + +function clearOldInstallArtifacts(): { removed: string[]; failed: string[] } { + const removed: string[] = []; + const failed: string[] = []; + + const pluginCacheRoot = getOpenCodePath("cache"); + const pluginPath = join(pluginCacheRoot, "node_modules", "@openhax", "codex"); + const cacheFiles = [ + getOpenCodePath("cache", CACHE_FILES.CODEX_INSTRUCTIONS), + getOpenCodePath("cache", CACHE_FILES.CODEX_INSTRUCTIONS_META), + getOpenCodePath("cache", CACHE_FILES.OPENCODE_CODEX), + getOpenCodePath("cache", CACHE_FILES.OPENCODE_CODEX_META), + ]; + + if (existsSync(pluginPath)) { + removePath(pluginPath, removed, failed); + } + + for (const cacheFile of cacheFiles) { + if (existsSync(cacheFile)) { + removePath(cacheFile, removed, failed); + } + } + + return { removed, failed }; +} + +export async function runAutoUpdateCheck(client?: OpencodeClient): Promise { + const now = Date.now(); + const state = readUpdateState(); + if (shouldThrottle(state, now)) return; + + writeUpdateState({ ...state, lastChecked: now, lastError: undefined }); + + const localVersion = await parseLocalVersion(); + if (!localVersion) { + writeUpdateState({ ...state, lastChecked: now, lastError: "local version unavailable" }); + return; + } + + const latestVersion = await fetchLatestVersion(); + if (!latestVersion) { + writeUpdateState({ ...state, lastChecked: now, lastError: "npm fetch failed" }); + return; + } + + if (!isNewerVersion(localVersion, latestVersion)) { + writeUpdateState({ ...state, lastChecked: now, lastError: undefined }); + return; + } + + if ( + state.lastNotifiedVersion === latestVersion && + state.lastNotifiedAt && + now - state.lastNotifiedAt < 24 * 60 * 60 * 1000 + ) { + writeUpdateState({ ...state, lastChecked: now, lastError: undefined }); + return; + } + + const message = `New @openhax/codex ${latestVersion} available (current ${localVersion}). Restart OpenCode to apply.`; + logInfo(message); + showToast(client, message); + + const cleanup = clearOldInstallArtifacts(); + if (cleanup.removed.length) { + logInfo("Cleared old Codex install artifacts", { removed: cleanup.removed }); + } + if (cleanup.failed.length) { + logWarn("Failed to remove some artifacts during update prep", { errors: cleanup.failed }); + } + + writeUpdateState({ + lastChecked: now, + lastNotifiedVersion: latestVersion, + lastNotifiedAt: now, + lastError: cleanup.failed.length ? cleanup.failed.join("; ") : undefined, + }); +} diff --git a/lib/utils/cache-config.ts b/lib/utils/cache-config.ts index 319bd2e..41f9f5f 100644 --- a/lib/utils/cache-config.ts +++ b/lib/utils/cache-config.ts @@ -37,6 +37,8 @@ export const CACHE_FILES = { OPENCODE_CODEX: `${PLUGIN_PREFIX}-opencode-prompt.txt`, /** OpenCode prompt metadata file */ OPENCODE_CODEX_META: `${PLUGIN_PREFIX}-opencode-prompt-meta.json`, + /** Auto-update state file */ + AUTO_UPDATE_STATE: `${PLUGIN_PREFIX}-update-state.json`, } as const; /** diff --git a/lib/utils/prompt-cache-key.ts b/lib/utils/prompt-cache-key.ts new file mode 100644 index 0000000..5a5c5a2 --- /dev/null +++ b/lib/utils/prompt-cache-key.ts @@ -0,0 +1,53 @@ +import { randomUUID } from "node:crypto"; + +export interface PromptCacheKeyOptions { + /** Prefix to prepend to the sanitized base (default: "cache_") */ + prefix?: string; + /** Delimiter that separates the base from the fork id (default: "-fork-") */ + forkDelimiter?: string; + /** Custom base sanitizer. Defaults to trimming + collapsing whitespace. */ + sanitizeBase?: (value: string) => string; + /** Custom fork sanitizer. Defaults to trimming + collapsing whitespace. */ + sanitizeFork?: (value: string) => string; +} + +function defaultSanitizeBase(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return randomUUID(); + } + return trimmed.replace(/\s+/g, "-"); +} + +function defaultSanitizeFork(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return `fork-${randomUUID()}`; + } + return trimmed.replace(/\s+/g, "-"); +} + +export function formatPromptCacheKey( + base: string, + forkId?: string, + options: PromptCacheKeyOptions = {}, +): string { + const sanitizeBase = options.sanitizeBase ?? defaultSanitizeBase; + const sanitizeFork = options.sanitizeFork ?? defaultSanitizeFork; + const prefix = options.prefix ?? "cache_"; + const forkDelimiter = options.forkDelimiter ?? "-fork-"; + + const sanitizedBase = sanitizeBase(base); + const baseWithPrefix = prefix + ? sanitizedBase.startsWith(prefix) + ? sanitizedBase + : `${prefix}${sanitizedBase}` + : sanitizedBase; + + if (!forkId) { + return baseWithPrefix; + } + + const sanitizedFork = sanitizeFork(forkId); + return `${baseWithPrefix}${forkDelimiter}${sanitizedFork}`; +} diff --git a/package-lock.json b/package-lock.json index 36843d4..0365ff3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openhax/codex", - "version": "0.4.1", + "version": "0.4.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openhax/codex", - "version": "0.4.1", + "version": "0.4.4", "license": "GPL-3.0-only", "dependencies": { "@openauthjs/openauth": "^0.4.3", diff --git a/scripts/test-all-models.sh b/scripts/test-all-models.sh index 79e19f2..c1960c4 100755 --- a/scripts/test-all-models.sh +++ b/scripts/test-all-models.sh @@ -174,9 +174,11 @@ update_config "full" test_model "gpt-5.1-low" "gpt-5.1" "low" "auto" "low" test_model "gpt-5.1-medium" "gpt-5.1" "medium" "auto" "medium" test_model "gpt-5.1-high" "gpt-5.1" "high" "detailed" "high" - + test_model "gpt-5.2" "gpt-5.2" "medium" "auto" "low" + test_model "gpt-5-codex-low" "gpt-5-codex" "low" "auto" "medium" + test_model "gpt-5-codex-medium" "gpt-5-codex" "medium" "auto" "medium" test_model "gpt-5-codex-high" "gpt-5-codex" "high" "detailed" "medium" test_model "gpt-5-minimal" "gpt-5" "minimal" "auto" "low" diff --git a/spec/config-env-review.md b/spec/config-env-review.md new file mode 100644 index 0000000..6bfe3fd --- /dev/null +++ b/spec/config-env-review.md @@ -0,0 +1,31 @@ +# Config and Env Var Review + +Prompt: review all config and env vars used in this system. + +## Files to Inspect + +- lib/config.ts:12-91 (plugin defaults, CODEX_MODE env override, appendEnvContext) +- lib/logger.ts:8-90 (logging env flags and numeric limits) +- lib/request/request-transformer.ts:25-158 (appendEnvContext fallback to env) +- lib/request/fetch-helpers.ts:120-167 (appendEnvContext override when transforming requests) +- lib/server/dashboard.ts:41-168 (NODE_ENV guard for dashboard server) +- scripts/detect-release-type.mjs:18-31 (RELEASE_BASE_REF, GITHUB_SHA) +- scripts/sync-github-secrets.mjs:5-120 (GITHUB_REPOSITORY inference, required secret env vars) +- scripts/review-response-context.mjs:7-110 (GITHUB_EVENT_PATH, GITHUB_OUTPUT) +- docs/configuration.md (documented config/env behaviour and defaults) + +## Existing Issues / PRs + +- Not checked for this review. + +## Definition of Done + +- Inventory every configuration field (plugin config + user config options) and all environment variables referenced in code or docs. +- Provide file/line references for each config/env item. +- Clarify defaults, precedence, and how env vars alter behaviour. +- Summarize findings for the user. + +## Requirements + +- No code changes needed unless documentation gaps are found. +- Keep notes in this spec if new findings arise during review. diff --git a/spec/gpt-52-support.md b/spec/gpt-52-support.md new file mode 100644 index 0000000..ca96e39 --- /dev/null +++ b/spec/gpt-52-support.md @@ -0,0 +1,52 @@ +# Spec: GPT-5.2 support parity + +## Context + +- The latest Codex CLI already exposes the frontier `gpt-5.2` preset with low/medium/high/**extra high** reasoning (see `openai/codex@main:codex-rs/core/src/openai_models/model_presets.rs:97-123`) and custom prompt scaffolding (`codex-rs/core/src/openai_models/model_family.rs:326-339`). +- This plugin still normalizes unknown GPT-5.\* requests to `gpt-5`/`gpt-5.1` (`lib/request/model-config.ts:3-44`) and only allows `xhigh` on `gpt-5.1-codex-max` (`lib/request/model-config.ts:98-155`, `test/request-transformer.test.ts:23-166`), so Codex CLI users cannot target `gpt-5.2` through OpenCode. +- Documentation claims that `xhigh` is exclusive to Codex Max (`README.md:450-461`, `AGENTS.md:7-160`, `docs/reasoning-effort-levels-update.md:36-56`), which is outdated once `gpt-5.2` is available. + +## Existing issues / PRs + +- No tracked issue or PR in this repo yet; feature request came directly from user instructions after reviewing upstream Codex CLI releases. + +## References + +- Normalization & reasoning heuristics: `lib/request/model-config.ts:3-170` +- Request transformation + tests: `lib/request/request-transformer.ts:1-160`, `test/request-transformer.test.ts:1-300` +- Config tests: `test/config.test.ts:100-200` +- Docs mentioning supported models & reasoning tiers: `README.md:21-580`, `AGENTS.md:1-200`, `docs/reasoning-effort-levels-update.md:1-80`, `docs/configuration.md:1-200` +- Upstream behavior: `openai/codex@main:codex-rs/core/src/openai_models/model_presets.rs:97-123`, `codex-rs/core/src/openai_models/model_family.rs:326-339`, `codex-rs/core/gpt_5_2_prompt.md` + +## Requirements / Definition of Done + +1. `normalizeModel()` recognizes all `gpt-5.2*` variants (including `/model` prefixes and suffix presets) and preserves the canonical slug `gpt-5.2` when targeting that model. +2. `classifyModel()` / `getReasoningConfig()` treat `gpt-5.2` as a frontier family with: + - default `reasoningEffort` of `medium` (no "none" option), + - support for `xhigh` without downgrading, + - clamping of `none`/`minimal` to `low`, matching Codex CLI presets. +3. Requests selecting `gpt-5.2` propagate correct reasoning and verbosity defaults inside `transformRequestBody()` and keep Codex tooling toggles intact. +4. Unit tests cover normalization, reasoning clamps, and transformation results for `gpt-5.2`, plus regression tests ensuring other families keep previous behavior. +5. Documentation (README, AGENTS.md, reasoning docs) updates to mention `gpt-5.2`, its reasoning tiers, and the broader availability of `xhigh` (Codex Max + GPT-5.2). +6. Add release notes / change summary acknowledging the new model, and ensure `npm test` passes. + +## Plan + +**Phase 1 – Research (complete)** + +- Use `gh` to inspect upstream Codex CLI commits/files for `gpt-5.2` presets and reasoning fences. + +**Phase 2 – Implementation** + +1. Extend `lib/request/model-config.ts` normalization + flagging logic (including `applyRequestedEffort` / `normalizeEffortForModel`) for `gpt-5.2`. +2. Update `transformRequestBody` tests (`test/request-transformer.test.ts`, `test/config.test.ts`) covering new defaults and `xhigh` handling. +3. Refresh docs (`README.md`, `AGENTS.md`, `docs/reasoning-effort-levels-update.md`, other references) to explain `gpt-5.2` and the revised `xhigh` policy. + +**Phase 3 – Validation** + +- Run `npm test` and capture output; update this spec + final response with results. + +## Change Log + +- 2025-12-12: Initial spec drafted for GPT-5.2 normalization, reasoning, tests, and documentation updates. +- 2025-12-12: Implemented normalization/reasoning changes, added config + script entries, updated docs, and verified tests for GPT-5.2 support. diff --git a/spec/issue-67-auto-update.md b/spec/issue-67-auto-update.md new file mode 100644 index 0000000..549a8d6 --- /dev/null +++ b/spec/issue-67-auto-update.md @@ -0,0 +1,30 @@ +# Issue 67 - Auto-update triage + +## Issue + +- #67 Auto-update: check npm latest, clear old install, toast user (open) +- No related PRs found. + +## Relevant code and docs + +- index.ts:66-137 — plugin entry; startup hook (cache warming, instructions fetch) is likely place to trigger an update check without blocking. +- lib/logger.ts:75-218 — toast and warn logging helpers; `tui.showToast` wrapper and console fallback. +- lib/utils/file-system-utils.ts:15-48 — helpers for `~/.opencode` paths and safe file I/O (useful for locating cached installs/configs). +- docs/index.md:54-62 — current manual update steps (sed + rm in `~/.cache/opencode`). +- README.md:669 — notes that plugins live under `~/.cache/opencode`; Codex assets under `~/.opencode`; advises clearing both for new releases. +- package.json:2-4,31-46 — package name/version (0.4.4) and scripts; no auto-update logic today. + +## Requirements from issue + +- Detect newer `@openhax/codex` via npm `dist-tags.latest` vs local installed version. +- Trigger at startup/interval with rate limiting/backoff similar to cache protections; avoid registry hammering. +- If newer version: remove existing install/cache first to avoid mixed artifacts; install or prompt install. +- Notify user with toast including version; log fallback when TUI unavailable. +- Offline/registry failures: warn and skip; already on latest: no-op without toast spam. +- Failed update should not break plugin or leave corrupted state (prefer rollback/leave current intact). + +## Definition of done (triage) + +- Documented scope/risks/assumptions for auto-update flow. +- Identified insertion points and supporting utilities for version detection, cache clearing, notification, and throttling. +- Listed open questions/blockers for implementation. diff --git a/test/codex-fetcher.test.ts b/test/codex-fetcher.test.ts index 0658d2a..31c439f 100644 --- a/test/codex-fetcher.test.ts +++ b/test/codex-fetcher.test.ts @@ -45,11 +45,16 @@ vi.mock("../lib/session/response-recorder.js", () => ({ recordSessionResponseFromHandledResponse: recordSessionResponseMock, })); +vi.mock("../lib/metrics/request-metrics.js", () => ({ + __esModule: true, + recordRequestMetrics: vi.fn(), +})); + describe("createCodexFetcher", () => { const sessionManager = { recordResponse: vi.fn(), getContext: vi.fn(), - applyRequest: vi.fn(), + applyRequest: vi.fn((body, context) => ({ body, context })), } as unknown as SessionManager; beforeEach(() => { diff --git a/test/config.test.ts b/test/config.test.ts index e37052b..ef6408d 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -141,6 +141,24 @@ describe("Configuration Parsing", () => { expect(result.effort).toBe("low"); expect(result.summary).toBe("auto"); }); + + it("defaults gpt-5.2 to medium and supports xhigh", () => { + const defaults = getReasoningConfig("gpt-5.2", {}); + expect(defaults.effort).toBe("medium"); + expect(defaults.summary).toBe("auto"); + + const xhigh = getReasoningConfig("gpt-5.2", { reasoningEffort: "xhigh" }); + expect(xhigh.effort).toBe("xhigh"); + expect(xhigh.summary).toBe("auto"); + }); + + it("normalizes minimal/none to low for gpt-5.2", () => { + const none = getReasoningConfig("gpt-5.2", { reasoningEffort: "none" }); + expect(none.effort).toBe("low"); + + const minimal = getReasoningConfig("gpt-5.2", { reasoningEffort: "minimal" }); + expect(minimal.effort).toBe("low"); + }); }); describe("Model-specific behavior", () => { diff --git a/test/fetch-helpers.test.ts b/test/fetch-helpers.test.ts index 05b0333..9983680 100644 --- a/test/fetch-helpers.test.ts +++ b/test/fetch-helpers.test.ts @@ -293,7 +293,7 @@ describe("Fetch Helpers Module", () => { const appliedContext = { ...sessionContext, isNew: false }; const sessionManager = { getContext: vi.fn().mockReturnValue(sessionContext), - applyRequest: vi.fn().mockReturnValue(appliedContext), + applyRequest: vi.fn().mockReturnValue({ body: transformed, context: appliedContext }), }; const result = await transformRequestForCodex( @@ -334,7 +334,7 @@ describe("Fetch Helpers Module", () => { }; const sessionManager = { getContext: vi.fn().mockReturnValue(sessionContext), - applyRequest: vi.fn().mockReturnValue(sessionContext), + applyRequest: vi.fn().mockReturnValue({ body: transformed, context: sessionContext }), }; await transformRequestForCodex( @@ -374,7 +374,7 @@ describe("Fetch Helpers Module", () => { }; const sessionManager = { getContext: vi.fn().mockReturnValue(sessionContext), - applyRequest: vi.fn().mockReturnValue(sessionContext), + applyRequest: vi.fn().mockReturnValue({ body: transformed, context: sessionContext }), }; await transformRequestForCodex( diff --git a/test/index.test.ts b/test/index.test.ts index 181189f..9281931 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -21,7 +21,7 @@ const logWarnMock = vi.hoisted(() => vi.fn()); const logErrorMock = vi.hoisted(() => vi.fn()); const sessionManagerInstance = vi.hoisted(() => ({ getContext: vi.fn(() => ({ sessionId: "session-1", preserveIds: true, enabled: true })), - applyRequest: vi.fn((_body, ctx) => ({ ...ctx, applied: true })), + applyRequest: vi.fn((_body, ctx) => ({ body: _body, context: { ...ctx, applied: true } })), recordResponse: vi.fn(), })); const SessionManagerMock = vi.hoisted(() => vi.fn(() => sessionManagerInstance)); diff --git a/test/request-metrics.test.ts b/test/request-metrics.test.ts new file mode 100644 index 0000000..8cd0856 --- /dev/null +++ b/test/request-metrics.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + getRequestMetricsSnapshot, + recordRequestMetrics, + resetRequestMetrics, +} from "../lib/metrics/request-metrics.js"; + +describe("request metrics", () => { + beforeEach(() => { + resetRequestMetrics(); + }); + + it("tracks basic counts and recent requests", () => { + recordRequestMetrics({ + url: "https://chatgpt.com/backend-api/codex/responses", + model: "gpt-5", + promptCacheKey: true, + toolCount: 2, + toolChoice: "required", + parallelToolCalls: true, + include: ["reasoning.encrypted_content"], + store: false, + reasoningEffort: "high", + reasoningSummary: "auto", + textVerbosity: "medium", + }); + + const snapshot = getRequestMetricsSnapshot(); + expect(snapshot.totalRequests).toBe(1); + expect(snapshot.promptCacheKey.withKey).toBe(1); + expect(snapshot.promptCacheKey.withoutKey).toBe(0); + expect(snapshot.tools.requestsWithTools).toBe(1); + expect(snapshot.tools.totalTools).toBe(2); + expect(snapshot.tools.toolChoiceCounts.required).toBe(1); + expect(snapshot.tools.parallelToolCalls).toBe(1); + expect(snapshot.models["gpt-5"]).toBe(1); + expect(snapshot.reasoning.withReasoning).toBe(1); + expect(snapshot.reasoning.effort.high).toBe(1); + expect(snapshot.reasoning.summary.auto).toBe(1); + expect(snapshot.reasoning.textVerbosity.medium).toBe(1); + expect(snapshot.recentRequests).toHaveLength(1); + expect(snapshot.recentRequests[0].toolCount).toBe(2); + }); + + it("limits recent requests to max capacity", () => { + for (let index = 0; index < 60; index += 1) { + recordRequestMetrics({ + url: `https://example.com/${index}`, + model: "gpt-5", + promptCacheKey: index % 2 === 0, + toolCount: 0, + }); + } + + const snapshot = getRequestMetricsSnapshot(); + expect(snapshot.recentRequests.length).toBeLessThanOrEqual(50); + expect(snapshot.totalRequests).toBe(60); + }); + + it("handles requests without optional fields", () => { + recordRequestMetrics({ + url: "https://chatgpt.com/backend-api/codex/responses", + promptCacheKey: false, + toolCount: 0, + }); + + const snapshot = getRequestMetricsSnapshot(); + expect(snapshot.totalRequests).toBe(1); + expect(snapshot.promptCacheKey.withoutKey).toBe(1); + expect(snapshot.tools.requestsWithTools).toBe(0); + expect(snapshot.models).toEqual({}); + expect(snapshot.recentRequests[0].model).toBeUndefined(); + }); + + it("tracks per-model distributions", () => { + const models = [ + { name: "gpt-5", count: 1 }, + { name: "gpt-5.1", count: 2 }, + { name: "gpt-5.2", count: 3 }, + ]; + + for (const { name, count } of models) { + for (let idx = 0; idx < count; idx += 1) { + recordRequestMetrics({ + url: `https://example.com/${name}/${idx}`, + model: name, + promptCacheKey: true, + toolCount: 0, + }); + } + } + + const snapshot = getRequestMetricsSnapshot(); + for (const { name, count } of models) { + expect(snapshot.models[name]).toBe(count); + } + expect(snapshot.totalRequests).toBe(models.reduce((sum, entry) => sum + entry.count, 0)); + }); +}); diff --git a/test/request-transformer.test.ts b/test/request-transformer.test.ts index 9198335..09e7642 100644 --- a/test/request-transformer.test.ts +++ b/test/request-transformer.test.ts @@ -96,6 +96,14 @@ describe("normalizeModel", () => { expect(normalizeModel("gpt51-codex-mini-high")).toBe("gpt-5.1-codex-mini"); }); + it("should normalize gpt-5.2 presets to gpt-5.2", async () => { + expect(normalizeModel("gpt-5.2")).toBe("gpt-5.2"); + expect(normalizeModel("gpt-5.2-high")).toBe("gpt-5.2"); + expect(normalizeModel("openai/gpt52")).toBe("gpt-5.2"); + expect(normalizeModel("gpt52")).toBe("gpt-5.2"); + expect(normalizeModel("openai/gpt-5.2")).toBe("gpt-5.2"); + }); + it("should handle mixed case", async () => { expect(normalizeModel("Gpt-5-Codex-Low")).toBe("gpt-5-codex"); expect(normalizeModel("GpT-5-MeDiUm")).toBe("gpt-5"); @@ -156,7 +164,7 @@ describe("getReasoningConfig (gpt-5.1-codex-max)", () => { expect(none.effort).toBe("low"); }); - it("downgrades xhigh to high on other models", async () => { + it("downgrades xhigh to high on unsupported models", async () => { const codex = getReasoningConfig("gpt-5.1-codex", { reasoningEffort: "xhigh" }); expect(codex.effort).toBe("high"); @@ -165,6 +173,24 @@ describe("getReasoningConfig (gpt-5.1-codex-max)", () => { }); }); +describe("getReasoningConfig (gpt-5.2)", () => { + it("defaults to medium and keeps xhigh", async () => { + const defaults = getReasoningConfig("gpt-5.2", {}); + expect(defaults.effort).toBe("medium"); + + const xhigh = getReasoningConfig("gpt-5.2", { reasoningEffort: "xhigh" }); + expect(xhigh.effort).toBe("xhigh"); + }); + + it("clamps none or minimal to low", async () => { + const none = getReasoningConfig("gpt-5.2", { reasoningEffort: "none" }); + expect(none.effort).toBe("low"); + + const minimal = getReasoningConfig("gpt-5.2", { reasoningEffort: "minimal" }); + expect(minimal.effort).toBe("low"); + }); +}); + describe("filterInput", () => { it("should handle null/undefined in filterInput", async () => { expect(filterInput(null as any)).toBeNull(); @@ -970,8 +996,8 @@ describe("transformRequestBody", () => { { preserveIds: sessionOne.preserveIds }, sessionOne, ); - sessionManager.applyRequest(firstTransform.body, sessionOne); - const cacheKey = firstTransform.body.prompt_cache_key; + const firstApply = sessionManager.applyRequest(firstTransform.body, sessionOne); + const cacheKey = firstApply.body.prompt_cache_key; expect(firstTransform.body.input?.[0].role).toBe("developer"); @@ -990,10 +1016,11 @@ describe("transformRequestBody", () => { { preserveIds: sessionTwo.preserveIds }, sessionTwo, ); - const appliedContext = sessionManager.applyRequest(secondTransform.body, sessionTwo); + const applied = sessionManager.applyRequest(secondTransform.body, sessionTwo); + const appliedContext = applied.context ?? sessionTwo; expect(secondTransform.body.input?.[0].role).toBe("developer"); - expect(secondTransform.body.prompt_cache_key).toBe(cacheKey); + expect(applied.body.prompt_cache_key).toBe(cacheKey); expect(appliedContext?.isNew).toBe(false); }); @@ -1157,9 +1184,9 @@ describe("transformRequestBody", () => { expect(result.reasoning?.effort).toBe("xhigh"); }); - it("should downgrade xhigh reasoning for non-codex-max models", async () => { + it("should keep xhigh reasoning effort for gpt-5.2", async () => { const body: RequestBody = { - model: "gpt-5.1-codex", + model: "gpt-5.2", input: [], }; const userConfig: UserConfig = { @@ -1171,7 +1198,33 @@ describe("transformRequestBody", () => { const result = await transformRequestBody(body, codexInstructions, userConfig, true, { preserveIds: false, }); - expect(result.reasoning?.effort).toBe("high"); + expect(result.reasoning?.effort).toBe("xhigh"); + }); + + it("should downgrade xhigh reasoning for non-codex-max models", async () => { + const codexBody: RequestBody = { + model: "gpt-5.1-codex", + input: [], + }; + const userConfig: UserConfig = { + global: { + reasoningEffort: "xhigh", + }, + models: {}, + }; + const codexResult = await transformRequestBody(codexBody, codexInstructions, userConfig, true, { + preserveIds: false, + }); + expect(codexResult.reasoning?.effort).toBe("high"); + + const generalBody: RequestBody = { + model: "gpt-5", + input: [], + }; + const generalResult = await transformRequestBody(generalBody, codexInstructions, userConfig, true, { + preserveIds: false, + }); + expect(generalResult.reasoning?.effort).toBe("high"); }); it("should apply default text verbosity", async () => { @@ -1183,6 +1236,15 @@ describe("transformRequestBody", () => { expect(result.text?.verbosity).toBe("medium"); }); + it("should default gpt-5.2 text verbosity to low", async () => { + const body: RequestBody = { + model: "gpt-5.2", + input: [], + }; + const result = await transformRequestBody(body, codexInstructions); + expect(result.text?.verbosity).toBe("low"); + }); + it("should apply user text verbosity", async () => { const body: RequestBody = { model: "gpt-5", @@ -1802,7 +1864,7 @@ describe("transformRequestBody", () => { const mockSessionManager = { getContext: () => null, - applyRequest: () => null, + applyRequest: (requestBody: RequestBody) => ({ body: requestBody, context: undefined }), } as any; const result = await transformRequestBody( diff --git a/test/session-manager.test.ts b/test/session-manager.test.ts index 6f5d84f..bbf1c60 100644 --- a/test/session-manager.test.ts +++ b/test/session-manager.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { SESSION_CONFIG } from "../lib/constants.js"; import { SessionManager } from "../lib/session/session-manager.js"; import * as logger from "../lib/logger.js"; -import type { RequestBody, SessionContext } from "../lib/types.js"; +import type { RequestBody } from "../lib/types.js"; interface BodyOptions { forkId?: string; @@ -49,26 +49,29 @@ describe("SessionManager", () => { const manager = new SessionManager({ enabled: true }); const body = createBody("conv-123"); - const context = manager.getContext(body) as SessionContext; - manager.applyRequest(body, context); + const context = manager.getContext(body)!; + const { body: updatedBody, context: updatedContext } = manager.applyRequest(body, context); - expect(body.prompt_cache_key).toBe("conv-123"); - expect(context.state.lastInput.length).toBe(1); + expect(updatedContext).toBeDefined(); + expect(updatedBody.prompt_cache_key).toBe("conv-123"); + expect(updatedContext!.state.lastInput.length).toBe(1); }); it("maintains prefix across turns and reuses context", () => { const manager = new SessionManager({ enabled: true }); const firstBody = createBody("conv-456"); - let context = manager.getContext(firstBody) as SessionContext; - context = manager.applyRequest(firstBody, context) as SessionContext; + let context = manager.getContext(firstBody)!; + const firstApply = manager.applyRequest(firstBody, context); + context = firstApply.context!; const secondBody = createBody("conv-456", 2); - let nextContext = manager.getContext(secondBody) as SessionContext; + let nextContext = manager.getContext(secondBody)!; expect(nextContext.isNew).toBe(false); - nextContext = manager.applyRequest(secondBody, nextContext) as SessionContext; + const secondApply = manager.applyRequest(secondBody, nextContext); + nextContext = secondApply.context!; - expect(secondBody.prompt_cache_key).toBe("conv-456"); + expect(secondApply.body.prompt_cache_key).toBe("conv-456"); expect(nextContext.state.lastInput.length).toBe(2); expect(nextContext.state.promptCacheKey).toBe(context.state.promptCacheKey); }); @@ -78,7 +81,7 @@ describe("SessionManager", () => { const manager = new SessionManager({ enabled: true }); const baseBody = createBody("conv-789", 2); - const context = manager.getContext(baseBody) as SessionContext; + const context = manager.getContext(baseBody)!; manager.applyRequest(baseBody, context); const changedBody: RequestBody = { @@ -89,7 +92,7 @@ describe("SessionManager", () => { ], }; - const nextContext = manager.getContext(changedBody) as SessionContext; + const nextContext = manager.getContext(changedBody)!; manager.applyRequest(changedBody, nextContext); const warnCall = warnSpy.mock.calls.find( @@ -117,7 +120,7 @@ describe("SessionManager", () => { ], }; - const context = manager.getContext(baseBody) as SessionContext; + const context = manager.getContext(baseBody)!; manager.applyRequest(baseBody, context); const nextBody: RequestBody = { @@ -128,7 +131,7 @@ describe("SessionManager", () => { ], }; - const nextContext = manager.getContext(nextBody) as SessionContext; + const nextContext = manager.getContext(nextBody)!; manager.applyRequest(nextBody, nextContext); const warnCall = warnSpy.mock.calls.find( @@ -164,7 +167,7 @@ describe("SessionManager", () => { ], }; - const context = manager.getContext(fullBody) as SessionContext; + const context = manager.getContext(fullBody)!; manager.applyRequest(fullBody, context); const prunedBody: RequestBody = { @@ -172,7 +175,7 @@ describe("SessionManager", () => { input: fullBody.input ? fullBody.input.slice(4) : [], }; - const prunedContext = manager.getContext(prunedBody) as SessionContext; + const prunedContext = manager.getContext(prunedBody)!; manager.applyRequest(prunedBody, prunedContext); const warnCall = warnSpy.mock.calls.find( @@ -192,18 +195,19 @@ describe("SessionManager", () => { const manager = new SessionManager({ enabled: true }); const body = createBody("conv-usage"); - const context = manager.getContext(body) as SessionContext; - manager.applyRequest(body, context); + const context = manager.getContext(body)!; + const applyResult = manager.applyRequest(body, context); + const updatedContext = applyResult.context!; - manager.recordResponse(context, { usage: { cached_tokens: 42 } }); + manager.recordResponse(updatedContext, { usage: { cached_tokens: 42 } }); - expect(context.state.lastCachedTokens).toBe(42); + expect(updatedContext.state.lastCachedTokens).toBe(42); }); it("reports metrics snapshot with recent sessions", () => { const manager = new SessionManager({ enabled: true }); const body = createBody("conv-metrics"); - const context = manager.getContext(body) as SessionContext; + const context = manager.getContext(body)!; manager.applyRequest(body, context); const metrics = manager.getMetrics(); @@ -220,7 +224,7 @@ describe("SessionManager", () => { prompt_cache_key: "fallback_cache_key", }; - const context = manager.getContext(body) as SessionContext; + const context = manager.getContext(body)!; expect(context.enabled).toBe(true); expect(context.isNew).toBe(true); expect(context.state.promptCacheKey).toBe("fallback_cache_key"); @@ -236,7 +240,7 @@ describe("SessionManager", () => { input: [], prompt_cache_key: cacheKey, }; - const firstContext = manager.getContext(firstBody) as SessionContext; + const firstContext = manager.getContext(firstBody)!; expect(firstContext.isNew).toBe(true); // Second request reuses session @@ -245,7 +249,7 @@ describe("SessionManager", () => { input: [{ type: "message", role: "user", content: "second" }], prompt_cache_key: cacheKey, }; - const secondContext = manager.getContext(secondBody) as SessionContext; + const secondContext = manager.getContext(secondBody)!; expect(secondContext.isNew).toBe(false); expect(secondContext.state.promptCacheKey).toBe(firstContext.state.promptCacheKey); }); @@ -253,19 +257,20 @@ describe("SessionManager", () => { it("creates fork-specific sessions with derived cache keys", () => { const manager = new SessionManager({ enabled: true }); const firstAlpha = createBody("conv-fork", 1, { forkId: "alpha" }); - let alphaContext = manager.getContext(firstAlpha) as SessionContext; + let alphaContext = manager.getContext(firstAlpha)!; expect(alphaContext.isNew).toBe(true); - alphaContext = manager.applyRequest(firstAlpha, alphaContext) as SessionContext; + const alphaApply = manager.applyRequest(firstAlpha, alphaContext); + alphaContext = alphaApply.context!; expect(alphaContext.state.promptCacheKey).toBe("conv-fork::fork::alpha"); const repeatAlpha = createBody("conv-fork", 2, { forkId: "alpha" }); - let repeatedContext = manager.getContext(repeatAlpha) as SessionContext; + const repeatedContext = manager.getContext(repeatAlpha)!; expect(repeatedContext.isNew).toBe(false); - repeatedContext = manager.applyRequest(repeatAlpha, repeatedContext) as SessionContext; - expect(repeatAlpha.prompt_cache_key).toBe("conv-fork::fork::alpha"); + const repeatApply = manager.applyRequest(repeatAlpha, repeatedContext); + expect(repeatApply.body.prompt_cache_key).toBe("conv-fork::fork::alpha"); const betaBody = createBody("conv-fork", 1, { forkId: "beta" }); - const betaContext = manager.getContext(betaBody) as SessionContext; + const betaContext = manager.getContext(betaBody)!; expect(betaContext.isNew).toBe(true); expect(betaContext.state.promptCacheKey).toBe("conv-fork::fork::beta"); }); @@ -273,16 +278,16 @@ describe("SessionManager", () => { it("derives fork ids from parent conversation hints", () => { const manager = new SessionManager({ enabled: true }); const parentBody = createBody("conv-fork-parent", 1, { parentConversationId: "parent-conv" }); - const parentContext = manager.getContext(parentBody) as SessionContext; + const parentContext = manager.getContext(parentBody)!; expect(parentContext.isNew).toBe(true); expect(parentContext.state.promptCacheKey).toBe("conv-fork-parent::fork::parent-conv"); - manager.applyRequest(parentBody, parentContext); - expect(parentBody.prompt_cache_key).toBe("conv-fork-parent::fork::parent-conv"); + const parentApply = manager.applyRequest(parentBody, parentContext); + expect(parentApply.body.prompt_cache_key).toBe("conv-fork-parent::fork::parent-conv"); const snakeParentBody = createBody("conv-fork-parent", 1, { parent_conversation_id: "parent-snake", }); - const snakeParentContext = manager.getContext(snakeParentBody) as SessionContext; + const snakeParentContext = manager.getContext(snakeParentBody)!; expect(snakeParentContext.isNew).toBe(true); expect(snakeParentContext.state.promptCacheKey).toBe("conv-fork-parent::fork::parent-snake"); }); @@ -290,8 +295,9 @@ describe("SessionManager", () => { it("evicts sessions that exceed idle TTL", () => { const manager = new SessionManager({ enabled: true }); const body = createBody("conv-expire"); - let context = manager.getContext(body) as SessionContext; - context = manager.applyRequest(body, context) as SessionContext; + let context = manager.getContext(body)!; + const expireApply = manager.applyRequest(body, context); + context = expireApply.context!; context.state.lastUpdated = Date.now() - SESSION_CONFIG.IDLE_TTL_MS - 1000; manager.pruneIdleSessions(Date.now()); @@ -306,10 +312,12 @@ describe("SessionManager", () => { const totalSessions = SESSION_CONFIG.MAX_ENTRIES + 5; for (let index = 0; index < totalSessions; index += 1) { const body = createBody(`conv-cap-${index}`); - const context = manager.getContext(body) as SessionContext; - manager.applyRequest(body, context); + const context = manager.getContext(body)!; + + const applyResult = manager.applyRequest(body, context); + const appliedContext = applyResult.context ?? context; - context.state.lastUpdated -= index; // ensure ordering + appliedContext.state.lastUpdated -= index; // ensure ordering } const metrics = manager.getMetrics(SESSION_CONFIG.MAX_ENTRIES + 10);