From d443a42b1f3191d07c631bf90ab65fb0a4829aac Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Fri, 4 Sep 2026 20:13:10 +0000 Subject: [PATCH] feat(providers): refresh available Codex models, add refresh button, and add Astra pricing --- .../providers/ProviderCard.test.jsx | 16 ++ client/src/utils/providers.js | 2 +- client/src/utils/providers.test.js | 2 +- data.reference/providers.json | 4 +- .../aiToolkit/defaults/providers.sample.json | 4 +- server/lib/aiToolkit/internal/codex.js | 20 +++ server/lib/aiToolkit/internal/codex.test.js | 26 +++ .../lib/aiToolkit/internal/modelFetchers.js | 8 + .../aiToolkit/internal/modelFetchers.test.js | 24 ++- server/lib/aiToolkit/providers.js | 112 +++++++++++- server/lib/aiToolkit/providers.test.js | 166 +++++++++++++++++- .../providers.refreshCapability.test.js | 13 +- server/lib/cliChildEnv.test.js | 1 + server/lib/modelPricing.js | 2 + server/lib/modelPricing.test.js | 17 ++ server/lib/providerModels.js | 2 +- .../providers.refreshCapability.test.js | 16 +- 17 files changed, 403 insertions(+), 32 deletions(-) create mode 100644 server/lib/aiToolkit/internal/codex.js create mode 100644 server/lib/aiToolkit/internal/codex.test.js diff --git a/client/src/components/providers/ProviderCard.test.jsx b/client/src/components/providers/ProviderCard.test.jsx index e4e5f16223..81474b0566 100644 --- a/client/src/components/providers/ProviderCard.test.jsx +++ b/client/src/components/providers/ProviderCard.test.jsx @@ -133,3 +133,19 @@ describe('ProviderCard ChatGPT subscription', () => { expect(screen.queryByRole('link', { name: 'Open ChatGPT sign-in' })).toBeNull(); }); }); + +describe('ProviderCard model refresh', () => { + it('renders the Refresh Models button for a Codex provider with model-list capability', () => { + renderCard({ + id: 'codex', + name: 'Codex CLI', + type: 'cli', + command: 'codex', + models: ['gpt-6-astra'], + canRefreshModels: true, + enabled: true, + }); + expect(screen.getByRole('button', { name: 'Refresh Models' })).toBeInTheDocument(); + }); +}); + diff --git a/client/src/utils/providers.js b/client/src/utils/providers.js index 5c0caf1400..6bb75f4e73 100644 --- a/client/src/utils/providers.js +++ b/client/src/utils/providers.js @@ -410,7 +410,7 @@ export const CURSOR_EFFORT_LEVELS = Object.freeze(['low', 'medium', 'high', 'xhi // `max` clamps to `xhigh` here exactly as it does on the server. export const GROK_EFFORT_LEVELS = Object.freeze(['low', 'medium', 'high', 'xhigh']); -const CODEX_ULTRA_MODELS = new Set(['gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra']); +const CODEX_ULTRA_MODELS = new Set(['gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-6-astra']); const codexEffortLevelsForModel = (model) => CODEX_ULTRA_MODELS.has(String(model || '').trim().toLowerCase()) ? CODEX_ULTRA_EFFORT_LEVELS diff --git a/client/src/utils/providers.test.js b/client/src/utils/providers.test.js index 190d8bfcda..9781d0c291 100644 --- a/client/src/utils/providers.test.js +++ b/client/src/utils/providers.test.js @@ -1124,7 +1124,7 @@ describe('supportsModelRefresh', () => { expect(withButton).toEqual([ 'antigravity-cli', 'antigravity-tui', 'cerebras', 'claude-code', 'claude-code-bedrock', 'claude-ollama', 'claude-ollama-tui', - 'claude-sglang', 'claude-sglang-tui', 'cursor-cli', + 'claude-sglang', 'claude-sglang-tui', 'codex', 'codex-tui', 'cursor-cli', 'cursor-tui', 'grok', 'lmstudio', 'mtplx', 'nvidia-kimi', 'ollama', 'opencode-llama-tui', 'opencode-mtplx', 'opencode-mtplx-tui', 'opencode-ollama', diff --git a/data.reference/providers.json b/data.reference/providers.json index 22f65247f4..cf98bac1d1 100644 --- a/data.reference/providers.json +++ b/data.reference/providers.json @@ -63,7 +63,7 @@ "type": "cli", "command": "codex", "args": [], - "models": ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.3-codex-spark"], + "models": ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"], "defaultModel": "gpt-5.6-terra", "lightModel": "gpt-5.6-luna", "mediumModel": "gpt-5.6-terra", @@ -94,7 +94,7 @@ "type": "tui", "command": "codex", "args": ["--dangerously-bypass-approvals-and-sandbox"], - "models": ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.3-codex-spark"], + "models": ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"], "defaultModel": "gpt-5.6-terra", "lightModel": "gpt-5.6-luna", "mediumModel": "gpt-5.6-terra", diff --git a/server/lib/aiToolkit/defaults/providers.sample.json b/server/lib/aiToolkit/defaults/providers.sample.json index be0003db4c..3dac9a794c 100644 --- a/server/lib/aiToolkit/defaults/providers.sample.json +++ b/server/lib/aiToolkit/defaults/providers.sample.json @@ -463,7 +463,7 @@ "type": "cli", "command": "codex", "args": [], - "models": ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.3-codex-spark"], + "models": ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"], "defaultModel": "gpt-5.6-terra", "lightModel": "gpt-5.6-luna", "mediumModel": "gpt-5.6-terra", @@ -481,7 +481,7 @@ "type": "tui", "command": "codex", "args": ["--dangerously-bypass-approvals-and-sandbox"], - "models": ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.3-codex-spark"], + "models": ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"], "defaultModel": "gpt-5.6-terra", "lightModel": "gpt-5.6-luna", "mediumModel": "gpt-5.6-terra", diff --git a/server/lib/aiToolkit/internal/codex.js b/server/lib/aiToolkit/internal/codex.js new file mode 100644 index 0000000000..398cf721b8 --- /dev/null +++ b/server/lib/aiToolkit/internal/codex.js @@ -0,0 +1,20 @@ +/** + * Codex constants and command helpers for the aiToolkit. + * + * `CODEX_COMMAND` and `isCodexCommand` are duplicated from + * server/lib/codexAccount.js / server/lib/providerModels.js so the toolkit + * stays self-contained (no imports out to sibling PortOS modules — see + * ../AGENTS.md); keep in sync with upstream. + */ + +import { commandBasename } from './commandBasename.js'; + +export const CODEX_CLI_ID = 'codex'; +export const CODEX_TUI_ID = 'codex-tui'; +export const CODEX_COMMAND = 'codex'; + +// Match by normalized binary basename so a path- or `.exe`-configured provider +// (`/opt/homebrew/bin/codex`, `codex.exe`, `codex.cmd`) is still recognized. +export function isCodexCommand(command) { + return commandBasename(command) === CODEX_COMMAND; +} diff --git a/server/lib/aiToolkit/internal/codex.test.js b/server/lib/aiToolkit/internal/codex.test.js new file mode 100644 index 0000000000..a8b21a5167 --- /dev/null +++ b/server/lib/aiToolkit/internal/codex.test.js @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { CODEX_COMMAND, CODEX_CLI_ID, CODEX_TUI_ID, isCodexCommand } from './codex.js'; +import { CODEX_APP_SERVER_COMMAND as UPSTREAM_COMMAND } from '../../codexAccount.js'; + +describe('isCodexCommand', () => { + it('matches the bare binary, an absolute path, and a Windows .exe', () => { + expect(isCodexCommand('codex')).toBe(true); + expect(isCodexCommand('/opt/homebrew/bin/codex')).toBe(true); + expect(isCodexCommand('C:\\Tools\\codex.exe')).toBe(true); + expect(isCodexCommand('CODEX')).toBe(true); + }); + + it('rejects other binaries', () => { + expect(isCodexCommand('cursor-agent')).toBe(false); + expect(isCodexCommand('claude')).toBe(false); + expect(isCodexCommand('agy')).toBe(false); + expect(isCodexCommand(null)).toBe(false); + expect(isCodexCommand('')).toBe(false); + }); + + it('stays in lockstep with upstream command constant', () => { + expect(CODEX_COMMAND).toBe(UPSTREAM_COMMAND); + expect(CODEX_CLI_ID).toBe('codex'); + expect(CODEX_TUI_ID).toBe('codex-tui'); + }); +}); diff --git a/server/lib/aiToolkit/internal/modelFetchers.js b/server/lib/aiToolkit/internal/modelFetchers.js index f168c58e38..60fb0edcf2 100644 --- a/server/lib/aiToolkit/internal/modelFetchers.js +++ b/server/lib/aiToolkit/internal/modelFetchers.js @@ -39,6 +39,7 @@ */ import { ANTIGRAVITY_TUI_ID, isAntigravityCommand } from './antigravity.js'; import { CURSOR_TUI_ID, isCursorCommand } from './cursor.js'; +import { CODEX_TUI_ID, isCodexCommand } from './codex.js'; import { isOllamaBackedProvider, ollamaBaseFromProvider } from './ollamaBacked.js'; import { isGatewayBackedProvider } from './gateways.js'; @@ -116,6 +117,13 @@ export const MODEL_FETCHERS = [ tuiMatch: (p) => p?.id === CURSOR_TUI_ID || isCursorCommand(p?.command), fetch: '_fetchCursorModels', }, + { + key: 'codex', + cliMatch: (p) => isCodexCommand(p?.command), + cliNameMatch: (p) => displayName(p).includes('codex'), + tuiMatch: (p) => p?.id === CODEX_TUI_ID || isCodexCommand(p?.command), + fetch: '_fetchCodexModels', + }, { key: 'claude', // Raw-string equality, NOT a basename: `_fetchAnthropicModels` returns a diff --git a/server/lib/aiToolkit/internal/modelFetchers.test.js b/server/lib/aiToolkit/internal/modelFetchers.test.js index 698172e3a9..700e7d5996 100644 --- a/server/lib/aiToolkit/internal/modelFetchers.test.js +++ b/server/lib/aiToolkit/internal/modelFetchers.test.js @@ -25,6 +25,7 @@ const SHIPPED_REFRESHABLE = [ // Every hosted gateway refreshes through the same sibling `/models` probe — // one MODEL_FETCHERS row covers all of them (internal/gateways.js). 'opencode-openrouter', 'opencode-openrouter-tui', 'openrouter', + 'codex', 'codex-tui', // OpenCode Zen's API record is an ordinary OpenAI-compatible endpoint, so it // refreshes through the same `/models` probe. Its CLI/TUI wrappers do NOT: // they carry no namespace marker at all, which is what makes OpenCode resolve @@ -41,7 +42,7 @@ const SHIPPED_REFRESHABLE = [ 'claude-sglang', 'claude-sglang-tui', ]; const SHIPPED_NOT_REFRESHABLE = [ - 'claude-code-tui', 'claude-code-tui-bedrock', 'codex', 'codex-tui', + 'claude-code-tui', 'claude-code-tui-bedrock', 'grok-cli', 'grok-tui', 'kimi-cli', 'kimi-tui', 'opencode-zen-cli', 'opencode-zen-tui', ]; @@ -189,21 +190,32 @@ describe('resolveModelFetcher — the ordering the old chains encoded in prose', expect(resolveModelFetcher({ id: 'x', type: 'cli', command: 'cursor', name: 'Cursor' })).toBeNull(); }); + it('serves codex commands and names', () => { + expect(resolveModelFetcher({ id: 'codex', type: 'cli', command: 'codex', name: 'Codex CLI' }).fetch) + .toBe('_fetchCodexModels'); + expect(resolveModelFetcher({ id: 'x', type: 'cli', command: '/opt/homebrew/bin/codex', name: 'Custom' }).fetch) + .toBe('_fetchCodexModels'); + expect(resolveModelFetcher({ id: 'x', type: 'cli', command: 'weird', name: 'Codex Custom' }).fetch) + .toBe('_fetchCodexModels'); + }); + it('returns null for a CLI no vendor claims — the caller throws its own 400', () => { - for (const command of ['codex', 'kimi', 'grok']) { + for (const command of ['kimi', 'grok']) { expect(resolveModelFetcher({ id: command, type: 'cli', command, name: `${command} CLI` })).toBeNull(); } }); }); describe('resolveModelFetcher — the TUI arm never consults the display name', () => { - it('serves the three vendors whose --model applies to the interactive session', () => { + it('serves the vendors whose --model applies to the interactive session', () => { expect(resolveModelFetcher({ id: 'claude-ollama-tui', type: 'tui', ollamaBacked: true }).fetch) .toBe('_fetchOllamaToolCapableModels'); expect(resolveModelFetcher({ id: 'x', type: 'tui', command: '/opt/bin/agy' }).fetch) .toBe('_fetchAntigravityModels'); expect(resolveModelFetcher({ id: 'x', type: 'tui', command: 'cursor-agent' }).fetch) .toBe('_fetchCursorModels'); + expect(resolveModelFetcher({ id: 'x', type: 'tui', command: 'codex' }).fetch) + .toBe('_fetchCodexModels'); }); it('serves an MTPLX-backed OpenCode TUI from its local endpoint', () => { @@ -218,6 +230,8 @@ describe('resolveModelFetcher — the TUI arm never consults the display name', .toBe('_fetchCursorModels'); expect(resolveModelFetcher({ id: 'antigravity-tui', type: 'tui', command: '/opt/bin/agy-wrap' }).fetch) .toBe('_fetchAntigravityModels'); + expect(resolveModelFetcher({ id: 'codex-tui', type: 'tui', command: '/opt/bin/codex-wrap' }).fetch) + .toBe('_fetchCodexModels'); expect(resolveModelFetcher({ id: 'custom-tui', type: 'tui', command: '/opt/bin/cursor-wrap' })).toBeNull(); }); @@ -242,7 +256,7 @@ describe('canRefreshModels', () => { describe('withRefreshCapability', () => { it('returns a copy — the caller\'s (possibly cached, about-to-be-saved) record is untouched', () => { - const provider = { id: 'codex', type: 'cli', command: 'codex', name: 'Codex CLI' }; + const provider = { id: 'kimi-cli', type: 'cli', command: 'kimi', name: 'Kimi CLI' }; const decorated = withRefreshCapability(provider); expect(decorated).not.toBe(provider); expect(decorated.canRefreshModels).toBe(false); @@ -257,7 +271,7 @@ describe('withRefreshCapability', () => { it('decorates every entry of a list', () => { const list = withRefreshCapabilityList([ { id: 'a', type: 'api' }, - { id: 'b', type: 'cli', command: 'codex', name: 'Codex' }, + { id: 'b', type: 'cli', command: 'kimi', name: 'Kimi CLI' }, ]); expect(list.map((p) => p.canRefreshModels)).toEqual([true, false]); }); diff --git a/server/lib/aiToolkit/providers.js b/server/lib/aiToolkit/providers.js index b207c6133a..1eab79a500 100644 --- a/server/lib/aiToolkit/providers.js +++ b/server/lib/aiToolkit/providers.js @@ -4,7 +4,7 @@ import { join, dirname, delimiter, isAbsolute } from 'path'; import { atomicWrite } from './internal/atomicWrite.js'; import { assertSecretEndpoint, evaluateSecretEndpoint } from './endpointGuard.js'; import { fileURLToPath } from 'url'; -import { execFile } from 'child_process'; +import { execFile, spawn } from 'child_process'; import { promisify } from 'util'; import { ANTIGRAVITY_CLI_ID, @@ -182,9 +182,13 @@ const CODEX_MODEL_KEYS = ['defaultModel', 'lightModel', 'mediumModel', 'heavyMod // config (rather than the old "use ~/.codex/config.toml" sentinel) so PortOS // can pass the user's choice through as `codex --model `. const CODEX_MODELS = [ - 'gpt-5.6-luna', - 'gpt-5.6-terra', + 'gpt-6-astra', 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', 'gpt-5.3-codex-spark', ]; const CODEX_MODEL_DEFAULTS = { @@ -200,6 +204,13 @@ const PRIOR_CODEX_MODEL_CATALOGS = [ 'gpt-5.6-terra', 'gpt-5.6-sol', ], + // Prior 2026-08 catalog before GPT-6 Astra, GPT-5.5, GPT-5.4, GPT-5.4 Mini were added. + [ + 'gpt-5.6-luna', + 'gpt-5.6-terra', + 'gpt-5.6-sol', + 'gpt-5.3-codex-spark', + ], ]; const ANTIGRAVITY_MODEL_KEYS = ['defaultModel', 'lightModel', 'mediumModel', 'heavyModel']; // agy exposes a per-session `--model` flag and lists its catalog via @@ -1347,6 +1358,101 @@ export function createProviderService(config = {}) { return await this._execCliModelList(provider, CURSOR_COMMAND, parseCursorModelList); }, + /** + * Codex exposes its model catalog through the `codex app-server` JSON-RPC + * interface via the `model/list` RPC method. + * + * Throws on failure or timeout rather than falling back to static seeds, + * consistent with _execCliModelList and _fetchOllamaToolCapableModels. + */ + async _fetchCodexModels(provider) { + const bin = provider?.command || 'codex'; + const { command, args } = prepareWindowsSafeSpawn(bin, ['app-server']); + return new Promise((resolve, reject) => { + let settled = false; + let child; + const settle = (err, result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + child?.kill('SIGTERM'); + } catch {} + if (err) reject(err); + else resolve(result); + }; + + const timer = setTimeout(() => { + settle(new Error(`'${bin} app-server' timed out waiting for model catalog`)); + }, 15000); + timer.unref?.(); + + try { + child = spawn(command, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...provider?.envVars }, + windowsHide: true, + }); + } catch (err) { + settle(new Error(`'${bin} app-server' failed to spawn: ${err?.message || err}`)); + return; + } + + child.on('error', (err) => { + settle(new Error(`'${bin} app-server' failed: ${err?.message || err}`)); + }); + + child.stdin?.on('error', () => {}); + + child.on('exit', (code, signal) => { + settle(new Error(`'${bin} app-server' exited prematurely with code ${code ?? signal}`)); + }); + + let buffer = ''; + child.stdout?.on('data', (chunk) => { + buffer += chunk.toString(); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const msg = JSON.parse(trimmed); + if (msg.id === 1) { + child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method: 'initialized', params: {} }) + '\n'); + child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'model/list', params: {} }) + '\n'); + } else if (msg.id === 2) { + if (msg.error) { + settle(new Error(`'${bin} app-server' model/list error: ${msg.error.message || JSON.stringify(msg.error)}`)); + return; + } + const rawModels = msg.result?.data || msg.result?.models || []; + const ids = rawModels + .filter((m) => !m.hidden) + .map((m) => (typeof m === 'string' ? m : m?.id || m?.model)) + .filter(Boolean); + if (ids.length === 0) { + settle(new Error(`'${bin} app-server' returned no model ids`)); + return; + } + settle(null, [...new Set(ids)]); + return; + } + } catch {} + } + }); + + child.stdin?.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { clientInfo: { name: 'portos', version: '1.0.0' } }, + }) + '\n', + ); + }); + }, + async _fetchOllamaToolCapableModels(provider) { const base = ollamaBaseFromProvider(provider); const res = await fetch(`${base}/api/tags`, { signal: AbortSignal.timeout(8000) }).catch(() => null); diff --git a/server/lib/aiToolkit/providers.test.js b/server/lib/aiToolkit/providers.test.js index 9fa6f7efae..36ed17e3e3 100644 --- a/server/lib/aiToolkit/providers.test.js +++ b/server/lib/aiToolkit/providers.test.js @@ -322,9 +322,13 @@ describe('Provider Service', () => { const codex = await providerService.getProviderById('codex'); expect(codex.models).toEqual([ - 'gpt-5.6-luna', - 'gpt-5.6-terra', + 'gpt-6-astra', 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', 'gpt-5.3-codex-spark', ]); expect(codex.defaultModel).toBe('gpt-5.6-terra'); @@ -353,9 +357,13 @@ describe('Provider Service', () => { const codexTui = await providerService.getProviderById('codex-tui'); expect(codexTui.models).toEqual([ - 'gpt-5.6-luna', - 'gpt-5.6-terra', + 'gpt-6-astra', 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', 'gpt-5.3-codex-spark', ]); expect(codexTui.defaultModel).toBe('gpt-5.6-terra'); @@ -384,13 +392,54 @@ describe('Provider Service', () => { }); const codex = await providerService.getProviderById('codex'); - expect(codex.models).toEqual([...priorModels, 'gpt-5.3-codex-spark']); + expect(codex.models).toEqual([ + 'gpt-6-astra', + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.3-codex-spark', + ]); expect(codex.defaultModel).toBe('gpt-5.6-luna'); expect(codex.lightModel).toBe('gpt-5.6-sol'); expect(codex.mediumModel).toBe('gpt-5.6-luna'); expect(codex.heavyModel).toBe('gpt-5.6-terra'); }); + it('widens the 2026-08 seeded Codex catalog to include GPT-6 and GPT-5.4/5.5 models', async () => { + const priorModels = ['gpt-5.6-luna', 'gpt-5.6-terra', 'gpt-5.6-sol', 'gpt-5.3-codex-spark']; + await writeProvidersFile({ + activeProvider: 'codex', + providers: { + codex: { + id: 'codex', + name: 'Codex CLI', + type: 'cli', + command: 'codex', + models: [...priorModels], + defaultModel: 'gpt-5.6-terra', + lightModel: 'gpt-5.6-luna', + mediumModel: 'gpt-5.6-terra', + heavyModel: 'gpt-5.6-sol', + }, + }, + }); + + const codex = await providerService.getProviderById('codex'); + expect(codex.models).toEqual([ + 'gpt-6-astra', + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', + 'gpt-5.3-codex-spark', + ]); + }); + it('does not touch non-codex providers', async () => { await writeProvidersFile({ activeProvider: 'claude-code', @@ -1306,6 +1355,113 @@ describe('Provider Service', () => { }); }); + describe('Codex model refresh (`codex app-server`)', () => { + const writeFakeCodex = async (modelsResponse) => { + const path = join(TEST_DATA_DIR, 'fake-codex.js'); + const script = `#!/usr/bin/env node +const readline = require('readline'); +const rl = readline.createInterface({ input: process.stdin }); +rl.on('line', (line) => { + try { + const msg = JSON.parse(line); + if (msg.id === 1) { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 1, result: {} }) + '\\n'); + } else if (msg.id === 2) { + process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: 2, result: ${JSON.stringify(modelsResponse)} }) + '\\n'); + } + } catch {} +}); +`; + await writeFile(path, script); + await chmod(path, 0o755); + return path; + }; + + it.skipIf(process.platform === 'win32')('persists the live catalog for a Codex CLI provider', async () => { + const fakeCodex = await writeFakeCodex({ + data: [ + { id: 'gpt-6-astra' }, + { id: 'gpt-5.6-sol' }, + { id: 'hidden-model', hidden: true }, + ], + }); + const p = await providerService.createProvider({ + name: 'Codex CLI', + type: 'cli', + command: fakeCodex, + models: ['gpt-5.3-codex-spark'], + defaultModel: 'gpt-5.3-codex-spark', + }); + + const updated = await providerService.refreshProviderModels(p.id); + expect(updated).not.toBeNull(); + expect(updated.models).toEqual(['gpt-6-astra', 'gpt-5.6-sol']); + }); + + it.skipIf(process.platform === 'win32')('refreshes a Codex TUI provider too', async () => { + const fakeCodex = await writeFakeCodex({ + models: [ + { id: 'gpt-6-astra' }, + { id: 'gpt-5.6-terra' }, + ], + }); + const p = await providerService.createProvider({ + name: 'Codex TUI', + type: 'tui', + command: fakeCodex, + models: ['gpt-5.3-codex-spark'], + defaultModel: 'gpt-5.3-codex-spark', + }); + + const updated = await providerService.refreshProviderModels(p.id); + expect(updated).not.toBeNull(); + expect(updated.models).toEqual(['gpt-6-astra', 'gpt-5.6-terra']); + }); + + it.skipIf(process.platform === 'win32')('refreshes a shipped codex-tui repointed at a wrapper command', async () => { + const fakeCodex = await writeFakeCodex({ + data: [{ id: 'gpt-6-astra' }, { id: 'gpt-5.6-sol' }], + }); + const wrapper = join(TEST_DATA_DIR, 'codex-wrap'); + await writeFile(wrapper, `#!/bin/sh\nexec "${fakeCodex}" "$@"\n`); + await chmod(wrapper, 0o755); + + await writeFile(join(TEST_DATA_DIR, 'providers.json'), JSON.stringify({ + activeProvider: 'codex-tui', + providers: { + 'codex-tui': { + id: 'codex-tui', name: 'Codex TUI', type: 'tui', + command: wrapper, models: ['gpt-5.3-codex-spark'], defaultModel: 'gpt-5.3-codex-spark', + }, + }, + }, null, 2)); + + const updated = await providerService.refreshProviderModels('codex-tui'); + expect(updated, 'the id clause on the TUI arm matches').not.toBeNull(); + expect(updated.models).toEqual(['gpt-6-astra', 'gpt-5.6-sol']); + }); + + it('reports a failed codex app-server probe as a refresh failure, leaving the stored list intact', async () => { + const stored = ['gpt-6-astra', 'gpt-5.6-sol']; + const p = await providerService.createProvider({ + name: 'Codex CLI', + type: 'cli', + command: '/nonexistent/path/to/codex', + models: [...stored], + defaultModel: 'gpt-6-astra', + }); + + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const err = await providerService.refreshProviderModels(p.id).catch(e => e); + errSpy.mockRestore(); + + expect(err).toBeInstanceOf(Error); + expect(err.message).toMatch(/codex app-server' failed/); + const after = await providerService.getProviderById(p.id); + expect(after.models).toEqual(stored); + }); + }); + describe('MTPLX model refresh', () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/server/lib/aiToolkit/routes/providers.refreshCapability.test.js b/server/lib/aiToolkit/routes/providers.refreshCapability.test.js index 52bd44a195..d4522234ec 100644 --- a/server/lib/aiToolkit/routes/providers.refreshCapability.test.js +++ b/server/lib/aiToolkit/routes/providers.refreshCapability.test.js @@ -41,12 +41,14 @@ describe('#3620: providers route decorates canRefreshModels without persisting i it('GET / carries the flag per provider — true for a refreshable CLI, false otherwise', async () => { await createProvider({ id: 'claude-code', name: 'Claude Code CLI', type: 'cli', command: 'claude' }); await createProvider({ id: 'codex', name: 'Codex CLI', type: 'cli', command: 'codex' }); + await createProvider({ id: 'unsupported-cli', name: 'Unsupported CLI', type: 'cli', command: 'unsupported' }); const res = await request(app).get('/api/providers'); expect(res.status).toBe(200); const byId = Object.fromEntries(res.body.providers.map((p) => [p.id, p])); expect(byId['claude-code'].canRefreshModels).toBe(true); - expect(byId.codex.canRefreshModels).toBe(false); + expect(byId.codex.canRefreshModels).toBe(true); + expect(byId['unsupported-cli'].canRefreshModels).toBe(false); }); it('never writes the field to providers.json — not on create, not on update', async () => { @@ -72,15 +74,15 @@ describe('#3620: providers route decorates canRefreshModels without persisting i it('a POSTed canRefreshModels is not stored either, and does not fake the answer', async () => { const res = await createProvider({ - id: 'codex', name: 'Codex CLI', type: 'cli', command: 'codex', canRefreshModels: true, + id: 'unsupported-cli', name: 'Unsupported CLI', type: 'cli', command: 'unsupported', canRefreshModels: true, }); expect(res.status).toBe(201); - // Derived from the table, which has no codex row — the client's button + // Derived from the table, which has no row for unsupported — the client's button // must stay hidden regardless of what the payload claimed. expect(res.body.canRefreshModels).toBe(false); const stored = await readStored(); - expect(stored.providers.codex).not.toHaveProperty('canRefreshModels'); + expect(stored.providers['unsupported-cli']).not.toHaveProperty('canRefreshModels'); }); it('decorates GET /samples — a sample answers as the provider it will become', async () => { @@ -92,7 +94,8 @@ describe('#3620: providers route decorates canRefreshModels without persisting i } const byId = Object.fromEntries(res.body.providers.map((p) => [p.id, p])); expect(byId['claude-code']?.canRefreshModels).toBe(true); - expect(byId.codex?.canRefreshModels).toBe(false); + expect(byId.codex?.canRefreshModels).toBe(true); + expect(byId['kimi-cli']?.canRefreshModels).toBe(false); }); it('decorates GET /:id and GET /active too, so every read agrees', async () => { diff --git a/server/lib/cliChildEnv.test.js b/server/lib/cliChildEnv.test.js index ecdcdc7817..e053ad4c01 100644 --- a/server/lib/cliChildEnv.test.js +++ b/server/lib/cliChildEnv.test.js @@ -672,6 +672,7 @@ describe('no spawn site rebuilds the CLI child env by hand', () => { // composer, and the dormancy is why its missing CLAUDECODE strip / OpenCode // map is not a live PortOS gap someone needs to chase. ['lib/aiToolkit/runner.js', 'vendored toolkit — must not import out to other PortOS modules, and its spawn is dormant under PortOS\'s setCliRunner override'], + ['lib/aiToolkit/providers.js', 'vendored toolkit — must not import out to other PortOS modules; capability probe only (codex app-server), runs no models'], ]); // Two independent markers, because either one alone has a blind spot: a new diff --git a/server/lib/modelPricing.js b/server/lib/modelPricing.js index 0ef1437449..7a19beb301 100644 --- a/server/lib/modelPricing.js +++ b/server/lib/modelPricing.js @@ -75,6 +75,7 @@ const EXACT_RATES = { 'claude-sonnet-4-5': [3.0, 15.0], 'claude-haiku-4-5': [1.0, 5.0], // OpenAI (Codex CLI) + 'gpt-6-astra': [10.0, 50.0], 'gpt-5.6-sol': [5.0, 30.0], 'gpt-5.6-terra': [2.5, 15.0], 'gpt-5.6-luna': [1.0, 6.0], @@ -135,6 +136,7 @@ const FAMILY_RULES = [ // the proprietary `/gpt/i` rule below, which would bill it at ~10-20x. Every // size/host lands here (never `exact`) since the id alone can't price it. { test: /gpt-oss/i, rateModel: 'gpt-oss-120b (cerebras)' }, + { test: /astra/i, rateModel: 'gpt-6-astra' }, { test: /gpt/i, rateModel: 'gpt-5.4' }, { test: /grok-build/i, rateModel: 'grok-build-0.1' }, { test: /grok-4\.20/i, rateModel: 'grok-4.3' }, diff --git a/server/lib/modelPricing.test.js b/server/lib/modelPricing.test.js index c1ef8e7d1c..055b46ac21 100644 --- a/server/lib/modelPricing.test.js +++ b/server/lib/modelPricing.test.js @@ -99,6 +99,23 @@ describe('resolveModelRates', () => { expect(resolveModelRates('antigravity-cli', 'antigravity-configured-default')).toMatchObject({ rateModel: 'gemini-3.1-pro-preview', matched: 'family' }); }); + it('prices GPT-6 Astra at standard rates with derived cache tiers', () => { + expect(resolveModelRates('codex', 'gpt-6-astra')).toMatchObject({ + rateModel: 'gpt-6-astra', + inputPer1M: 10, + outputPer1M: 50, + cacheReadPer1M: 1.0, + cacheWritePer1M: 12.5, + matched: 'exact', + }); + expect(resolveModelRates('codex', 'astra')).toMatchObject({ + rateModel: 'gpt-6-astra', + inputPer1M: 10, + outputPer1M: 50, + matched: 'family', + }); + }); + it('resolves suffixed gpt-5.6 ids to their base rates', () => { expect(resolveModelRates('codex', 'gpt-5.6-terra-2026-06-01')).toMatchObject({ rateModel: 'gpt-5.6-terra', matched: 'family' }); }); diff --git a/server/lib/providerModels.js b/server/lib/providerModels.js index eea099dc27..adca7323e9 100644 --- a/server/lib/providerModels.js +++ b/server/lib/providerModels.js @@ -121,7 +121,7 @@ export const EFFORT_LEVELS = Object.freeze([...new Set([ ...CURSOR_EFFORT_LEVELS, ])]); -const CODEX_ULTRA_MODELS = new Set(['gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra']); +const CODEX_ULTRA_MODELS = new Set(['gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-6-astra']); const codexEffortLevelsForModel = (model) => CODEX_ULTRA_MODELS.has(String(model || '').trim().toLowerCase()) ? CODEX_ULTRA_EFFORT_LEVELS diff --git a/server/routes/providers.refreshCapability.test.js b/server/routes/providers.refreshCapability.test.js index b3ebcf32fe..959d64fc69 100644 --- a/server/routes/providers.refreshCapability.test.js +++ b/server/routes/providers.refreshCapability.test.js @@ -21,6 +21,7 @@ const CLAUDE_OLLAMA = { secretEnvVars: ['ANTHROPIC_BASE_URL'], }; const CODEX = { id: 'codex', name: 'Codex CLI', type: 'cli', command: 'codex', envVars: {} }; +const UNREFRESHABLE = { id: 'kimi-cli', name: 'Kimi CLI', type: 'cli', command: 'kimi', envVars: {} }; function appWith(providerService) { const toolkit = { services: { providers: providerService }, routes: { providers: Router() } }; @@ -34,14 +35,15 @@ function appWith(providerService) { describe('#3620: sanitized PortOS provider routes carry canRefreshModels', () => { it('GET / decorates each provider and still strips the apiKey', async () => { const app = appWith({ - getAllProviders: vi.fn().mockResolvedValue({ activeProvider: 'codex', providers: [CLAUDE_OLLAMA, CODEX] }), + getAllProviders: vi.fn().mockResolvedValue({ activeProvider: 'codex', providers: [CLAUDE_OLLAMA, CODEX, UNREFRESHABLE] }), }); const res = await request(app).get('/api/providers'); expect(res.status).toBe(200); const byId = Object.fromEntries(res.body.providers.map((p) => [p.id, p])); expect(byId['claude-ollama'].canRefreshModels).toBe(true); - expect(byId.codex.canRefreshModels).toBe(false); + expect(byId.codex.canRefreshModels).toBe(true); + expect(byId['kimi-cli'].canRefreshModels).toBe(false); expect(byId['claude-ollama'].apiKey).toBeUndefined(); expect(byId['claude-ollama'].hasApiKey).toBe(true); }); @@ -60,7 +62,7 @@ describe('#3620: sanitized PortOS provider routes carry canRefreshModels', () => }); it('decorates GET /active as well', async () => { - const app = appWith({ getActiveProvider: vi.fn().mockResolvedValue(CODEX) }); + const app = appWith({ getActiveProvider: vi.fn().mockResolvedValue(UNREFRESHABLE) }); const res = await request(app).get('/api/providers/active'); expect(res.status).toBe(200); @@ -68,17 +70,17 @@ describe('#3620: sanitized PortOS provider routes carry canRefreshModels', () => }); it('decorates the PUT /:id response without persisting the field', async () => { - const updateProvider = vi.fn().mockResolvedValue({ ...CODEX, enabled: false }); + const updateProvider = vi.fn().mockResolvedValue({ ...UNREFRESHABLE, enabled: false }); const app = appWith({ - getProviderById: vi.fn().mockResolvedValue(CODEX), + getProviderById: vi.fn().mockResolvedValue(UNREFRESHABLE), updateProvider, }); const res = await request(app) - .put('/api/providers/codex') + .put('/api/providers/kimi-cli') .send({ enabled: false, canRefreshModels: true }); expect(res.status).toBe(200); - // Derived, not echoed: the table has no codex row. + // Derived, not echoed: the table has no kimi row. expect(res.body.canRefreshModels).toBe(false); // `providerSchema.partial()` strips the unknown key before it reaches the // service, so it can never be written to providers.json.