mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
feat: ChatGPT subscription (codex) models — provider, model list, composer wiring
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 <noreply@anthropic.com>
This commit is contained in:
parent
a1db0e395a
commit
6c5324eeac
10 changed files with 542 additions and 6 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -94,9 +94,12 @@ const providerDisplayNames: Record<string, string> = {
|
|||
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() }
|
||||
|
|
|
|||
165
apps/x/packages/core/src/models/codex.test.ts
Normal file
165
apps/x/packages/core/src/models/codex.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
303
apps/x/packages/core/src/models/codex.ts
Normal file
303
apps/x/packages/core/src/models/codex.ts
Normal file
|
|
@ -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<string> | null = null;
|
||||
function codexClientVersion(): Promise<string> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
} 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<string, unknown>
|
||||
: {};
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
|
@ -78,6 +78,11 @@ export async function resolveProviderConfig(name: string): Promise<z.infer<typeo
|
|||
if (name === "rowboat") {
|
||||
return { flavor: "rowboat" };
|
||||
}
|
||||
if (name === "codex") {
|
||||
// ChatGPT subscription: auth lives in chatgpt-auth.json (core auth
|
||||
// layer), never in models.json — which may not exist at all.
|
||||
return { flavor: "codex" };
|
||||
}
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
const cfg = await repo.getConfig();
|
||||
const entry = cfg.providers?.[name];
|
||||
|
|
|
|||
|
|
@ -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<typeof Provider>): ProviderV4 {
|
|||
}) as unknown as ProviderV4;
|
||||
case "rowboat":
|
||||
return getGatewayProvider();
|
||||
case "codex":
|
||||
return getCodexProvider();
|
||||
default:
|
||||
throw new Error(`Unsupported provider flavor: ${config.flavor}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue