From 6c5324eeac9cac2f5274b9985268476ce38e2c6e Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:21:29 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20ChatGPT=20subscription=20(codex)=20mode?= =?UTF-8?q?ls=20=E2=80=94=20provider,=20model=20list,=20composer=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the model-call half of "Use your ChatGPT subscription": a new credential-less "codex" provider flavor (like "rowboat") that runs calls against the Codex backend using the OAuth session from chatgpt-auth. - core/models/codex.ts: AI SDK OpenAI provider in Responses mode. A wrapLanguageModel middleware injects providerOptions.openai.store=false on every call so the SDK emits full self-contained items (text + reasoning with encrypted_content) instead of item_reference entries the stateless backend 404s. A wire-level fetch wrapper adds auth (bearer + chatgpt-account-id) and Cloudflare headers (originator), and backstops the contract: store:false, stream forced with SSE aggregation for non-streaming callers, max_output_tokens dropped, encrypted- reasoning include, friendly usage-limit errors - live model discovery via /codex/models, gated by client_version (mirrored from the local codex CLI's models_cache.json so the catalog matches what `codex` shows); visibility "hide" utility models filtered; display names carried through; hardcoded fallback list for offline - createProvider/resolveProviderConfig/mapReasoningEffort codex cases - models:list merges the "OpenAI Codex" catalog while signed in with ChatGPT; chatgpt:statusChanged push event on sign-in/out - composer picker shows the group gated on the session and refreshes on status changes Co-Authored-By: Claude Fable 5 --- apps/x/apps/main/src/ipc.ts | 27 +- .../components/chat-input-with-mentions.tsx | 17 +- apps/x/packages/core/src/models/codex.test.ts | 165 ++++++++++ apps/x/packages/core/src/models/codex.ts | 303 ++++++++++++++++++ apps/x/packages/core/src/models/defaults.ts | 5 + apps/x/packages/core/src/models/models.ts | 3 + .../core/src/models/reasoning.test.ts | 7 + apps/x/packages/core/src/models/reasoning.ts | 7 + apps/x/packages/shared/src/ipc.ts | 9 + apps/x/packages/shared/src/models.ts | 5 +- 10 files changed, 542 insertions(+), 6 deletions(-) create mode 100644 apps/x/packages/core/src/models/codex.test.ts create mode 100644 apps/x/packages/core/src/models/codex.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 3759a6a5..8d0c8879 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -41,6 +41,7 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js'; import type { IModelConfigRepo } from '@x/core/dist/models/repo.js'; import type { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { getChatGPTStatus, signOutChatGPT } from '@x/core/dist/auth/chatgpt-auth.js'; +import { listCodexModels } from '@x/core/dist/models/codex.js'; import { signInWithChatGPT, cancelChatGPTSignIn } from './chatgpt-signin.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js'; @@ -1234,10 +1235,21 @@ export function setupIpcHandlers() { } }, 'models:list': async () => { - if (await isSignedIn()) { - return await listGatewayModels(); + const base = (await isSignedIn()) + ? await listGatewayModels() + : await listOnboardingModels(); + // ChatGPT-subscription (codex) models are additive and independent of + // Rowboat sign-in; their failure must never break the main list. + try { + const chatgpt = await getChatGPTStatus(); + if (chatgpt.signedIn) { + const codex = await listCodexModels(); + return { providers: [...base.providers, ...codex.providers] }; + } + } catch (error) { + console.warn('[Codex] Listing subscription models failed:', error); } - return await listOnboardingModels(); + return base; }, 'models:test': async (_event, args) => { return await testModelConnection(args.provider, args.model); @@ -1291,7 +1303,13 @@ export function setupIpcHandlers() { return await getChatGPTStatus(); }, 'chatgpt:signIn': async () => { - return await signInWithChatGPT(); + const result = await signInWithChatGPT(); + if (result.signedIn) { + // Model lists gate on sign-in state (composer picker, models:list) — + // push the change so they refresh without polling. + broadcastToWindows('chatgpt:statusChanged', { signedIn: true }); + } + return result; }, 'chatgpt:cancelSignIn': async () => { await cancelChatGPTSignIn(); @@ -1300,6 +1318,7 @@ export function setupIpcHandlers() { 'chatgpt:signOut': async () => { try { await signOutChatGPT(); + broadcastToWindows('chatgpt:statusChanged', { signedIn: false }); return { success: true }; } catch (error) { console.error('[ChatGPTAuth] Sign-out failed:', error); diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 9e6d76b4..be1bba98 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -94,9 +94,12 @@ const providerDisplayNames: Record = { aigateway: 'AI Gateway', 'openai-compatible': 'OpenAI-Compatible', rowboat: 'Rowboat', + // Matches what other subscription clients call this provider; the auth + // itself is "Sign in with ChatGPT" (Plus/Pro subscription). + codex: 'OpenAI Codex', } -type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" +type ProviderName = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible" | "rowboat" | "codex" interface ConfiguredModel { provider: ProviderName @@ -540,6 +543,12 @@ function ChatInputInner({ groups.push({ kind: 'catalog', flavor: 'rowboat', models: catalog['rowboat'] }) } + // ChatGPT subscription (codex): models:list only carries this catalog + // while signed in with ChatGPT, so presence is the gate. + if ((catalog['codex'] || []).length > 0) { + groups.push({ kind: 'catalog', flavor: 'codex', models: catalog['codex'] }) + } + try { const result = await window.ipc.invoke('workspace:readFile', { path: 'config/models.json' }) const parsed = JSON.parse(result.data) @@ -615,6 +624,12 @@ function ChatInputInner({ loadModelConfig() }, [isActive, loadModelConfig]) + // ChatGPT subscription models appear/disappear with the ChatGPT session. + useEffect(() => { + const cleanup = window.ipc.on('chatgpt:statusChanged', () => { loadModelConfig() }) + return cleanup + }, [loadModelConfig]) + // Reload when model config changes (e.g. from settings dialog) useEffect(() => { const handler = () => { loadModelConfig() } diff --git a/apps/x/packages/core/src/models/codex.test.ts b/apps/x/packages/core/src/models/codex.test.ts new file mode 100644 index 00000000..8fe0e70a --- /dev/null +++ b/apps/x/packages/core/src/models/codex.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + normalizeRequestBody, + aggregateSseResponse, + listCodexModels, + codexStoreMiddleware, + CODEX_BASE_URL, +} from './codex.js'; +import type { LanguageModelV4, LanguageModelV4CallOptions } from '@ai-sdk/provider'; + +vi.mock('../auth/chatgpt-auth.js', () => ({ + getChatGPTAccessToken: vi.fn(async () => 'test-access-token'), + getChatGPTStatus: vi.fn(async () => ({ signedIn: true, accountId: 'acct-123' })), +})); + +describe('normalizeRequestBody', () => { + it('enforces the Codex wire contract on a bare request', () => { + const { body, forcedStream } = normalizeRequestBody(JSON.stringify({ + model: 'gpt-5.5', + input: [], + store: true, + max_output_tokens: 4096, + })); + const parsed = JSON.parse(body); + expect(parsed.store).toBe(false); + expect(parsed).not.toHaveProperty('max_output_tokens'); + expect(parsed.instructions).toBe('You are a helpful assistant.'); + expect(parsed.include).toContain('reasoning.encrypted_content'); + expect(parsed.reasoning).toEqual({ summary: 'auto' }); + expect(parsed.stream).toBe(true); + expect(forcedStream).toBe(true); + }); + + it('preserves caller instructions, reasoning fields, and existing includes', () => { + const { body, forcedStream } = normalizeRequestBody(JSON.stringify({ + instructions: 'Be terse.', + include: ['message.output_text.logprobs'], + reasoning: { effort: 'high', summary: 'detailed' }, + stream: true, + })); + const parsed = JSON.parse(body); + expect(parsed.instructions).toBe('Be terse.'); + expect(parsed.include).toEqual( + expect.arrayContaining(['message.output_text.logprobs', 'reasoning.encrypted_content']), + ); + expect(parsed.reasoning).toEqual({ effort: 'high', summary: 'detailed' }); + expect(forcedStream).toBe(false); + }); + + it('passes non-JSON bodies through untouched', () => { + expect(normalizeRequestBody('not json')).toEqual({ body: 'not json', forcedStream: false }); + }); +}); + +function sseResponse(events: string[]): Response { + const body = events.map((e) => `data: ${e}`).join('\n\n') + '\n\n'; + return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } }); +} + +describe('aggregateSseResponse', () => { + it('returns the response.completed payload as plain JSON', async () => { + const res = await aggregateSseResponse(sseResponse([ + JSON.stringify({ type: 'response.created' }), + JSON.stringify({ type: 'response.completed', response: { id: 'resp_1', output: [] } }), + ])); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('application/json'); + expect(await res.json()).toEqual({ id: 'resp_1', output: [] }); + }); + + it('surfaces response.failed as an error response', async () => { + const res = await aggregateSseResponse(sseResponse([ + JSON.stringify({ type: 'response.failed', response: { error: { message: 'quota exceeded' } } }), + ])); + expect(res.status).toBe(502); + const body = await res.json() as { error: { message: string } }; + expect(body.error.message).toBe('quota exceeded'); + }); + + it('errors when the stream ends without a terminal event', async () => { + const res = await aggregateSseResponse(sseResponse([ + JSON.stringify({ type: 'response.created' }), + ])); + expect(res.status).toBe(502); + }); +}); + +describe('codexStoreMiddleware', () => { + it('injects providerOptions.openai.store=false so the SDK never emits item references', async () => { + const params = { + prompt: [], + providerOptions: { openai: { reasoningEffort: 'high' } }, + } as unknown as LanguageModelV4CallOptions; + const out = await codexStoreMiddleware.transformParams!({ + type: 'stream', + params, + model: {} as LanguageModelV4, + }); + expect(out.providerOptions).toEqual({ + openai: { reasoningEffort: 'high', store: false }, + }); + }); + + it('overrides a caller-supplied store=true', async () => { + const params = { + prompt: [], + providerOptions: { openai: { store: true } }, + } as unknown as LanguageModelV4CallOptions; + const out = await codexStoreMiddleware.transformParams!({ + type: 'generate', + params, + model: {} as LanguageModelV4, + }); + expect(out.providerOptions?.openai?.store).toBe(false); + }); +}); + +describe('listCodexModels', () => { + const realFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = realFetch; + vi.restoreAllMocks(); + }); + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('discovers models with auth + originator headers, filtering hidden and sorting by priority', async () => { + let seenHeaders: Headers | undefined; + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toContain(`${CODEX_BASE_URL}/models?client_version=`); + seenHeaders = new Headers(init?.headers); + return new Response(JSON.stringify({ + models: [ + { slug: 'gpt-5.4', priority: 2, visibility: 'list' }, + { slug: 'codex-auto-review', priority: 0, visibility: 'hide' }, + { slug: 'gpt-5.5', priority: 1, visibility: 'list', display_name: 'GPT-5.5' }, + ], + }), { status: 200 }); + }) as typeof fetch; + + const result = await listCodexModels(); + expect(result.providers).toHaveLength(1); + expect(result.providers[0]?.id).toBe('codex'); + expect(result.providers[0]?.name).toBe('OpenAI Codex'); + expect(result.providers[0]?.models).toEqual([ + { id: 'gpt-5.5', name: 'GPT-5.5', reasoning: true }, + { id: 'gpt-5.4', reasoning: true }, + ]); + expect(seenHeaders?.get('Authorization')).toBe('Bearer test-access-token'); + expect(seenHeaders?.get('chatgpt-account-id')).toBe('acct-123'); + expect(seenHeaders?.get('originator')).toBe('codex_cli_rs'); + }); + + it('falls back to the hardcoded list when discovery fails', async () => { + globalThis.fetch = vi.fn(async () => { + throw new Error('offline'); + }) as typeof fetch; + + const result = await listCodexModels(); + const ids = result.providers[0]?.models.map((m) => m.id); + expect(ids).toContain('gpt-5.6-sol'); + expect(result.providers[0]?.models.every((m) => m.reasoning === true)).toBe(true); + }); +}); diff --git a/apps/x/packages/core/src/models/codex.ts b/apps/x/packages/core/src/models/codex.ts new file mode 100644 index 00000000..f14fec1f --- /dev/null +++ b/apps/x/packages/core/src/models/codex.ts @@ -0,0 +1,303 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { ProviderV4, LanguageModelV4Middleware } from '@ai-sdk/provider'; +import { createOpenAI } from '@ai-sdk/openai'; +import { wrapLanguageModel } from 'ai'; +import { getChatGPTAccessToken, getChatGPTStatus } from '../auth/chatgpt-auth.js'; + +// "ChatGPT subscription" model provider (flavor "codex"): runs model calls +// against the Codex backend that powers the Codex CLI, authorized by the +// "Sign in with ChatGPT" OAuth session (auth/chatgpt-auth.ts) instead of an +// API key. Like the "rowboat" gateway flavor, it has no models.json entry — +// resolveProviderConfig returns a bare { flavor: "codex" } and auth is +// injected per request here. +// +// The backend speaks ONLY the OpenAI Responses API, with quirks that every +// third-party client (Codex CLI itself, Zed, others) handles the same way — +// see codexFetch. Wire shape cross-checked against the open-source Codex CLI +// and known-working third-party integrations on 2026-07-17. + +export const CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'; + +// Cloudflare in front of chatgpt.com challenges requests whose `originator` +// isn't a known first-party Codex client, regardless of valid auth — so we +// identify as codex_cli_rs, the same approach other subscription clients +// ship. +const CODEX_ORIGINATOR = 'codex_cli_rs'; + +// The backend gates the model catalog by client_version — a version that +// predates a model family never sees it (e.g. 0.142.x gets no gpt-5.6-*). +// When the codex CLI is installed locally, mirror the version its own cache +// records so our catalog matches what `codex` shows in the terminal; +// otherwise fall back to a pinned recent release. +const CODEX_DEFAULT_CLIENT_VERSION = '0.144.5'; +let clientVersionPromise: Promise | null = null; +function codexClientVersion(): Promise { + clientVersionPromise ??= (async () => { + try { + const cachePath = path.join(os.homedir(), '.codex', 'models_cache.json'); + const parsed = JSON.parse(await fs.readFile(cachePath, 'utf-8')) as { client_version?: string }; + if (typeof parsed.client_version === 'string' && /^\d+\.\d+\.\d+$/.test(parsed.client_version)) { + return parsed.client_version; + } + } catch { /* no local codex CLI install */ } + return CODEX_DEFAULT_CLIENT_VERSION; + })(); + return clientVersionPromise; +} + +// Used when live discovery (listCodexModels) is unreachable. Discovery wins +// outright when it returns anything: the backend rejects retired slugs with +// HTTP 400, so appending stale hardcoded ids to a live list only creates +// broken picker rows. +const CODEX_FALLBACK_MODELS = [ + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna', + 'gpt-5.5', + 'gpt-5.4', + 'gpt-5.4-mini', +]; + +/** + * Wire-level normalization for the Codex backend. Applied via fetch (not + * providerOptions) so EVERY caller — chat turns, generateText one-shots, + * generateObject classifiers, connection tests — gets a valid request + * without knowing codex specifics: + * + * - `store: false` is mandatory (the backend rejects store:true). + * - `stream: true` is mandatory (stream:false is rejected outright); + * non-streaming callers get their SSE aggregated back into a plain JSON + * response below. + * - `max_output_tokens` is rejected ("Unsupported parameter") — dropped. + * - `instructions` must be non-empty. + * - `include: ["reasoning.encrypted_content"]` keeps reasoning usable + * across tool-call steps despite store:false (the AI SDK relays the + * encrypted blobs back on subsequent requests). + * - a reasoning summary is requested by default so the UI has visible + * reasoning even when the user picked no explicit effort. + */ +export function normalizeRequestBody(raw: string): { body: string; forcedStream: boolean } { + let parsed: Record; + try { + const value: unknown = JSON.parse(raw); + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return { body: raw, forcedStream: false }; + } + parsed = value as Record; + } catch { + return { body: raw, forcedStream: false }; + } + + parsed.store = false; + delete parsed.max_output_tokens; + if (typeof parsed.instructions !== 'string' || parsed.instructions.length === 0) { + parsed.instructions = 'You are a helpful assistant.'; + } + const include = new Set(Array.isArray(parsed.include) ? parsed.include as unknown[] : []); + include.add('reasoning.encrypted_content'); + parsed.include = [...include]; + const reasoning = (parsed.reasoning !== null && typeof parsed.reasoning === 'object') + ? parsed.reasoning as Record + : {}; + reasoning.summary ??= 'auto'; + parsed.reasoning = reasoning; + + let forcedStream = false; + if (parsed.stream !== true) { + parsed.stream = true; + forcedStream = true; + } + return { body: JSON.stringify(parsed), forcedStream }; +} + +/** + * Collapse a forced SSE stream back into the plain JSON response the + * non-streaming caller expects: the terminal `response.completed` event + * carries the full response object. + */ +export async function aggregateSseResponse(res: Response): Promise { + const text = await res.text(); + let completed: unknown; + let failure: string | undefined; + for (const line of text.split('\n')) { + if (!line.startsWith('data:')) continue; + const payload = line.slice(5).trim(); + if (!payload || payload === '[DONE]') continue; + let event: { type?: string; response?: unknown; message?: string }; + try { + event = JSON.parse(payload) as typeof event; + } catch { + continue; + } + if (event.type === 'response.completed') { + completed = event.response; + } else if (event.type === 'response.failed') { + const error = (event.response as { error?: { message?: string } } | undefined)?.error; + failure = error?.message ?? 'Codex request failed'; + } else if (event.type === 'error') { + failure = event.message ?? 'Codex request failed'; + } + } + const headers = new Headers(res.headers); + headers.set('content-type', 'application/json'); + headers.delete('content-length'); + headers.delete('content-encoding'); + if (completed !== undefined) { + return new Response(JSON.stringify(completed), { status: 200, headers }); + } + return new Response( + JSON.stringify({ error: { message: failure ?? 'Codex stream ended without a completed response' } }), + { status: 502, headers }, + ); +} + +/** + * Rewrite subscription-limit errors into something a user can act on. The + * backend's 429 carries `plan_type` / `resets_at` (unix seconds) on the + * error object; the raw message is unhelpful. Everything else passes + * through untouched (body re-wrapped since reading consumed it). + */ +async function normalizeErrorResponse(res: Response): Promise { + const raw = await res.text(); + const headers = new Headers(res.headers); + headers.delete('content-length'); + headers.delete('content-encoding'); + let body = raw; + if (res.status === 429) { + try { + const parsed = JSON.parse(raw) as { + error?: { message?: string; plan_type?: string; resets_at?: number }; + }; + if (parsed.error) { + const plan = parsed.error.plan_type ? ` (${parsed.error.plan_type} plan)` : ''; + let when = ''; + if (typeof parsed.error.resets_at === 'number') { + const minutes = Math.max(1, Math.ceil((parsed.error.resets_at * 1000 - Date.now()) / 60_000)); + when = minutes >= 120 ? ` Try again in ~${Math.round(minutes / 60)}h.` : ` Try again in ~${minutes} min.`; + } + parsed.error.message = `You have hit your ChatGPT subscription usage limit${plan}.${when}`; + body = JSON.stringify(parsed); + } + } catch { /* keep the original body */ } + } + return new Response(body, { status: res.status, statusText: res.statusText, headers }); +} + +// Auth + identity + Cloudflare headers on every request; body normalization +// on Responses calls. Throws ChatGPTAuthRequiredError when signed out — +// callers surface "Sign in with ChatGPT". +const codexFetch: typeof fetch = async (input, init) => { + const token = await getChatGPTAccessToken(); + const { accountId } = await getChatGPTStatus(); + const headers = new Headers(init?.headers); + headers.set('Authorization', `Bearer ${token}`); + if (accountId) headers.set('chatgpt-account-id', accountId); + headers.set('originator', CODEX_ORIGINATOR); + headers.set('User-Agent', `${CODEX_ORIGINATOR}/${await codexClientVersion()} (Rowboat)`); + + let body = init?.body; + let forcedStream = false; + if (typeof body === 'string') { + ({ body, forcedStream } = normalizeRequestBody(body)); + } + + const res = await fetch(input, { ...init, headers, ...(body === undefined ? {} : { body }) }); + if (!res.ok) return normalizeErrorResponse(res); + if (forcedStream) return aggregateSseResponse(res); + return res; +}; + +/** + * The SDK must BELIEVE store is false — forcing it only on the wire is not + * enough. When the SDK thinks store is on (its default), it echoes prior + * assistant text/reasoning parts as `item_reference` entries, and the + * stateless Codex backend 404s them ("Items are not persisted when `store` + * is set to false"). With providerOptions.openai.store=false its message + * converter emits full items instead — text with content, reasoning with + * the round-tripped encrypted_content — so every request is + * self-contained. Injected via middleware so no caller has to know. + */ +export const codexStoreMiddleware: LanguageModelV4Middleware = { + specificationVersion: 'v4', + transformParams: async ({ params }) => ({ + ...params, + providerOptions: { + ...params.providerOptions, + openai: { + ...params.providerOptions?.openai, + store: false, + }, + }, + }), +}; + +/** + * AI SDK provider for the codex flavor. `languageModel()` on the OpenAI + * provider resolves to the Responses API model (the only API the Codex + * backend speaks); auth is injected per request by codexFetch, so the + * apiKey here is a placeholder that never reaches the wire. + */ +export function getCodexProvider(): ProviderV4 { + const provider = createOpenAI({ + baseURL: CODEX_BASE_URL, + apiKey: 'chatgpt-subscription', + fetch: codexFetch, + }); + return { + ...provider, + languageModel: (modelId: string) => wrapLanguageModel({ + model: provider.languageModel(modelId), + middleware: codexStoreMiddleware, + }), + }; +} + +type ProviderSummary = { + id: string; + name: string; + models: Array<{ + id: string; + name?: string; + reasoning?: boolean; + }>; +}; + +/** + * Models available to the signed-in ChatGPT subscription, shaped like + * listGatewayModels for the models:list merge. Live discovery against the + * backend's own catalog; the hardcoded fallback covers offline/errors. + * Every codex model is a reasoning model, so the flag is set directly + * (models.dev doesn't know this flavor). + */ +export async function listCodexModels(): Promise<{ providers: ProviderSummary[] }> { + let discovered: Array<{ id: string; name?: string }> = []; + try { + const res = await codexFetch(`${CODEX_BASE_URL}/models?client_version=${await codexClientVersion()}`); + if (res.ok) { + const body = await res.json() as { + models?: Array<{ slug?: string; display_name?: string; visibility?: string; priority?: number }>; + }; + discovered = (body.models ?? []) + // Utility models (e.g. codex-auto-review) carry visibility + // "hide"; picker-visible ones carry "list". + .filter((m): m is { slug: string; display_name?: string; priority?: number } => + typeof m.slug === 'string' && m.slug.length > 0 + && m.visibility !== 'hide' && m.visibility !== 'hidden') + .sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER)) + .map((m) => ({ id: m.slug, ...(m.display_name ? { name: m.display_name } : {}) })); + } + } catch (error) { + console.warn('[Codex] Model discovery failed; using fallback list:', error instanceof Error ? error.message : error); + } + const models = (discovered.length > 0 ? discovered : CODEX_FALLBACK_MODELS.map((id) => ({ id }))) + .map((m) => ({ ...m, reasoning: true })); + return { + providers: [{ + id: 'codex', + name: 'OpenAI Codex', + models, + }], + }; +} diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index 183faeac..89527ebf 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -78,6 +78,11 @@ export async function resolveProviderConfig(name: string): Promise("modelConfigRepo"); const cfg = await repo.getConfig(); const entry = cfg.providers?.[name]; diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index e124c924..a87bd52d 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -9,6 +9,7 @@ import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js"; import z from "zod"; import { getGatewayProvider } from "./gateway.js"; +import { getCodexProvider } from "./codex.js"; import { getDefaultModelAndProvider, resolveProviderConfig } from "./defaults.js"; import { getChatModelIds } from "./models-dev.js"; import { withUseCase } from "../analytics/use_case.js"; @@ -81,6 +82,8 @@ export function createProvider(config: z.infer): ProviderV4 { }) as unknown as ProviderV4; case "rowboat": return getGatewayProvider(); + case "codex": + return getCodexProvider(); default: throw new Error(`Unsupported provider flavor: ${config.flavor}`); } diff --git a/apps/x/packages/core/src/models/reasoning.test.ts b/apps/x/packages/core/src/models/reasoning.test.ts index ad6af975..eff171c2 100644 --- a/apps/x/packages/core/src/models/reasoning.test.ts +++ b/apps/x/packages/core/src/models/reasoning.test.ts @@ -68,6 +68,13 @@ describe("mapReasoningEffort", () => { expect(mapReasoningEffort("rowboat", "x/y", "high", false)).toBeUndefined(); }); + it("maps codex like openai but forgiving on unknown support (models.dev has no codex flavor)", () => { + expect(mapReasoningEffort("codex", "gpt-5.5", "high", undefined)).toEqual({ + providerOptions: { openai: { reasoningEffort: "high" } }, + }); + expect(mapReasoningEffort("codex", "gpt-5.5", "medium", false)).toBeUndefined(); + }); + it("sends nothing for flavors without a safe parameter", () => { expect(mapReasoningEffort("ollama", "gpt-oss", "high", true)).toBeUndefined(); expect(mapReasoningEffort("openai-compatible", "my-vllm-model", "high", true)).toBeUndefined(); diff --git a/apps/x/packages/core/src/models/reasoning.ts b/apps/x/packages/core/src/models/reasoning.ts index c85e09c3..9d3e2480 100644 --- a/apps/x/packages/core/src/models/reasoning.ts +++ b/apps/x/packages/core/src/models/reasoning.ts @@ -95,6 +95,13 @@ export function mapReasoningEffort( if (supportsReasoning === false) return undefined; return { providerOptions: { openrouter: { reasoning: { effort } } } }; } + case "codex": { + // Every ChatGPT-subscription model reasons, but models.dev has + // no "codex" flavor so support reads as unknown — map unless + // known-false rather than failing closed. + if (supportsReasoning === false) return undefined; + return { providerOptions: { openai: { reasoningEffort: effort } } }; + } default: return undefined; } diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index fc29e83b..2d657b77 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -821,6 +821,15 @@ const ipcSchemas = { success: z.boolean(), }), }, + // Push event (main → renderer): ChatGPT sign-in state changed. Model + // pickers listen and refresh — subscription models appear/disappear with + // the session. + 'chatgpt:statusChanged': { + req: z.object({ + signedIn: z.boolean(), + }), + res: z.null(), + }, 'app:openUrl': { req: z.object({ url: z.string(), diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts index 5ed03104..f929491f 100644 --- a/apps/x/packages/shared/src/models.ts +++ b/apps/x/packages/shared/src/models.ts @@ -9,7 +9,10 @@ import { z } from "zod"; export const ReasoningEffort = z.enum(["low", "medium", "high"]); export const LlmProvider = z.object({ - flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat"]), + // "rowboat" (signed-in gateway) and "codex" (ChatGPT subscription via + // "Sign in with ChatGPT") are credential-less flavors: they never appear + // in models.json's providers map — auth lives in their own token stores. + flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible", "rowboat", "codex"]), apiKey: z.string().optional(), baseURL: z.string().optional(), headers: z.record(z.string(), z.string()).optional(),