Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions client/src/components/providers/ProviderCard.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});

2 changes: 1 addition & 1 deletion client/src/utils/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion client/src/utils/providers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 2 additions & 2 deletions data.reference/providers.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions server/lib/aiToolkit/defaults/providers.sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
20 changes: 20 additions & 0 deletions server/lib/aiToolkit/internal/codex.js
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 26 additions & 0 deletions server/lib/aiToolkit/internal/codex.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
8 changes: 8 additions & 0 deletions server/lib/aiToolkit/internal/modelFetchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions server/lib/aiToolkit/internal/modelFetchers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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',
];
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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();
});

Expand All @@ -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);
Expand All @@ -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]);
});
Expand Down
112 changes: 109 additions & 3 deletions server/lib/aiToolkit/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <id>`.
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 = {
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Loading