Add Codex as a first-class provider (#20)

## Summary
- add Codex as a first-class provider in SoulForge
- use the official Codex app-server for browser login and live model
discovery, so ChatGPT/Codex subscriptions work directly in the app
- add `/codex login`, Codex-aware model picker login UX, and live
provider status refresh after login

## Testing
- `bun test tests/provider-status.test.ts tests/codex-provider.test.ts
tests/new-providers.test.ts tests/local-providers.test.ts
tests/custom-providers.test.ts`
- `bun run typecheck`
- `bun run dev -- --headless --model codex/gpt-5.2-codex --max-steps 3
--quiet \"Reply with exactly PONG.\"`
This commit is contained in:
itz4blitz authored and GitHub committed 2026-04-08 01:17:40 +02:00
1 parent e4f70d11bb
commit ac16c22a12
18 files changed
+1404 -11

No files matched your search

+15 -2
View File
@@ -183,7 +183,11 @@ function ShutdownSplash({
);
}
import type { ProviderStatus } from "../core/llm/provider.js";
import {
getCachedProviderStatuses,
type ProviderStatus,
subscribeProviderStatuses,
} from "../core/llm/provider.js";
import type { PrerequisiteStatus } from "../core/setup/prerequisites.js";
interface Props {
@@ -221,6 +225,9 @@ export function App({
// Subscribe to theme changes so the entire tree re-renders with new colors
useThemeStore((s) => s.name);
const t = useTheme();
const [providerStatuses, setProviderStatuses] = useState<ProviderStatus[]>(() => {
return getCachedProviderStatuses() ?? bootProviders;
});
const [shutdownPhase, setShutdownPhase] = useState(-1);
const savedSessionIdRef = useRef<string | null>(null);
@@ -255,6 +262,12 @@ export function App({
[renderer],
);
useEffect(() => {
setProviderStatuses(getCachedProviderStatuses() ?? bootProviders);
}, [bootProviders]);
useEffect(() => subscribeProviderStatuses(setProviderStatuses), []);
useEffect(() => {
const onSelection = (sel: Selection) => {
const text = sel.getSelectedText();
@@ -1155,7 +1168,7 @@ export function App({
editorVisible={editorVisible}
focusMode={focusMode}
anyModalOpen={anyModalOpen}
bootProviders={bootProviders}
bootProviders={providerStatuses}
bootPrereqs={bootPrereqs}
getWorkspaceSnapshot={getWorkspaceSnapshot}
editorIntegration={effectiveConfig.editorIntegration}
+20 -5
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { fuzzyMatch } from "../../core/history/fuzzy.js";
import { icon, providerIcon } from "../../core/icons.js";
import { PROVIDER_CONFIGS } from "../../core/llm/models.js";
import { getProvider } from "../../core/llm/providers/index.js";
import { useTheme } from "../../core/theme/index.js";
import { useAllProviderModels } from "../../hooks/useAllProviderModels.js";
import { isModelFree } from "../../stores/statusbar.js";
@@ -22,6 +23,9 @@ type Entry =
count: number;
noKey?: boolean;
error?: string;
noAuthLabel?: string;
authErrorLabel?: string;
badge?: string;
}
| {
kind: "model";
@@ -89,17 +93,17 @@ function HeaderRow({
)}
{!entry.avail && !entry.loading && (
<text fg={t.textDim} bg={bg}>
{" · no key — Enter to add"}
{entry.noAuthLabel ?? " · no key — Enter to add"}
</text>
)}
{entry.avail && !entry.loading && entry.count === 0 && entry.error && (
<text fg={t.error ?? t.brandSecondary} bg={bg}>
{" · invalid key"}
{entry.authErrorLabel ?? " · invalid key"}
</text>
)}
{entry.id === "copilot" && entry.avail && !entry.loading && (
{entry.badge && !entry.loading && (
<text fg={t.textFaint} bg={bg}>
{" · unofficial"}
{` · ${entry.badge}`}
</text>
)}
</PopupRow>
@@ -254,6 +258,9 @@ export function LlmSelector({ visible, activeModel, onSelect, onClose }: Props)
loading,
count: 0,
noKey: true,
noAuthLabel: cfg.noAuthLabel,
authErrorLabel: cfg.authErrorLabel,
badge: cfg.badge,
});
continue;
}
@@ -275,6 +282,9 @@ export function LlmSelector({ visible, activeModel, onSelect, onClose }: Props)
loading,
count: filtered.length,
error: pd?.error,
noAuthLabel: cfg.noAuthLabel,
authErrorLabel: cfg.authErrorLabel,
badge: cfg.badge,
});
for (const m of filtered) {
@@ -418,7 +428,12 @@ export function LlmSelector({ visible, activeModel, onSelect, onClose }: Props)
const e = ents[cursorRef.current];
if (e?.kind === "header" && (e.noKey || (e.error && e.count === 0))) {
onClose();
useUIStore.getState().openModal("apiKeySettings");
const provider = getProvider(e.id);
if (provider?.onRequestAuth) {
void provider.onRequestAuth();
} else {
useUIStore.getState().openModal("apiKeySettings");
}
} else if (e?.kind === "header") {
toggleCollapse(e.id);
} else if (e?.kind === "model") {
+14
View File
@@ -0,0 +1,14 @@
import { getProvider } from "../llm/providers/index.js";
import type { CommandHandler } from "./types.js";
async function handleCodexLogin(_input: string): Promise<void> {
const provider = getProvider("codex");
if (!provider?.onRequestAuth) {
throw new Error("Codex auth flow is not available.");
}
await provider.onRequestAuth();
}
export function register(map: Map<string, CommandHandler>): void {
map.set("/codex login", handleCodexLogin);
}
+9
View File
@@ -1,4 +1,5 @@
import { matchClaimsPrefix, register as registerClaims } from "./claims.js";
import { register as registerCodex } from "./codex.js";
import { matchConfigPrefix, register as registerConfig } from "./config.js";
import { matchContextPrefix, register as registerContext } from "./context.js";
import { register as registerDebug } from "./debug.js";
@@ -23,6 +24,7 @@ registerProxy(commandMap);
registerStorage(commandMap);
registerSecurity(commandMap);
registerClaims(commandMap);
registerCodex(commandMap);
const prefixMatchers = [
matchContextPrefix,
@@ -274,6 +276,13 @@ const COMMAND_DEFS: CommandDef[] = [
category: "Models",
tags: ["api", "auth"],
},
{
cmd: "/codex login",
ic: "openai",
desc: "Log in to Codex with your ChatGPT subscription",
category: "Models",
tags: ["codex", "auth", "oauth", "browser", "chatgpt"],
},
{
cmd: "/model-scope",
ic: "cog",
+6
View File
@@ -28,6 +28,9 @@ export interface ProviderConfig {
name: string;
envVar: string;
grouped?: boolean;
badge?: string;
noAuthLabel?: string;
authErrorLabel?: string;
fallbackModels?: ProviderModelInfo[];
}
@@ -37,6 +40,9 @@ function buildProviderConfigs(): ProviderConfig[] {
name: p.name,
envVar: p.envVar,
grouped: p.grouped,
badge: p.badge,
noAuthLabel: p.noAuthLabel,
authErrorLabel: p.authErrorLabel,
fallbackModels: p.fallbackModels,
}));
}
+11
View File
@@ -10,11 +10,21 @@ export interface ProviderStatus {
}
let cachedStatuses: ProviderStatus[] | null = null;
const providerStatusListeners = new Set<(statuses: ProviderStatus[]) => void>();
export function getCachedProviderStatuses(): ProviderStatus[] | null {
return cachedStatuses;
}
export function subscribeProviderStatuses(
listener: (statuses: ProviderStatus[]) => void,
): () => void {
providerStatusListeners.add(listener);
return () => {
providerStatusListeners.delete(listener);
};
}
export async function checkProviders(): Promise<ProviderStatus[]> {
const results = await Promise.all(
getAllProviders().map(async (p) => {
@@ -28,6 +38,7 @@ export async function checkProviders(): Promise<ProviderStatus[]> {
}),
);
cachedStatuses = results;
for (const listener of providerStatusListeners) listener(results);
return results;
}
+1
View File
@@ -0,0 +1 @@
export * from "./codex/index.js";
+157
View File
@@ -0,0 +1,157 @@
import type { InfoPopupLine } from "../../../../components/modals/InfoPopup.js";
import { useUIStore } from "../../../../stores/ui.js";
import { providerIcon } from "../../../icons.js";
import { getThemeTokens } from "../../../theme/index.js";
import { checkProviders } from "../../provider.js";
import {
type CodexAppServerClient,
getCodexLoginStatus,
startCodexAppServerSession,
} from "./client.js";
interface CodexLoginStartResponse {
type?: string;
loginId?: string;
authUrl?: string;
}
interface CodexLoginCompletedNotification {
loginId?: string | null;
success?: boolean;
error?: string | null;
}
export async function performCodexBrowserLogin(
client: CodexAppServerClient,
openUrl: (url: string) => boolean | Promise<boolean>,
onEvent?: (message: string) => void,
): Promise<void> {
onEvent?.("Starting Codex browser login...");
const response = (await client.request("account/login/start", {
type: "chatgpt",
})) as CodexLoginStartResponse;
if (response.type !== "chatgpt" || !response.loginId || !response.authUrl) {
throw new Error("Codex returned an unexpected browser login response");
}
onEvent?.("Opening browser for Codex login...");
const opened = await openUrl(response.authUrl);
if (opened) {
onEvent?.("Browser opened. Complete the login in ChatGPT.");
} else {
onEvent?.(`Could not open browser automatically. Open this URL manually: ${response.authUrl}`);
}
const completed = await client.waitForNotification<CodexLoginCompletedNotification>(
"account/login/completed",
(params) => params.loginId === response.loginId,
);
if (!completed.success) {
throw new Error(completed.error ?? "Codex authentication failed.");
}
onEvent?.("Codex authentication complete.");
}
function openUrlInBrowser(url: string): boolean {
try {
const command =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "explorer"
: "xdg-open";
return Bun.spawnSync([command, url], { stdout: "ignore", stderr: "ignore" }).exitCode === 0;
} catch {
return false;
}
}
export function runCodexBrowserLogin(onEvent?: (message: string) => void): {
promise: Promise<void>;
abort: () => void;
} {
let session: CodexAppServerClient | null = null;
let aborted = false;
const promise = (async () => {
const status = getCodexLoginStatus();
if (!status.installed) {
throw new Error("Codex CLI is not installed. Install Codex first.");
}
if (status.loggedIn) {
onEvent?.("Codex is already logged in.");
return;
}
session = await startCodexAppServerSession();
if (aborted) {
session.close();
throw new Error("Codex login cancelled.");
}
try {
await performCodexBrowserLogin(session, openUrlInBrowser, onEvent);
} finally {
session.close();
}
})();
return {
promise,
abort: () => {
aborted = true;
session?.close();
},
};
}
export async function requestCodexAuth(): Promise<void> {
const theme = getThemeTokens();
const lines: InfoPopupLine[] = [
{
type: "text",
label: "Starting Codex login...",
color: theme.textSecondary,
},
];
let handle: ReturnType<typeof runCodexBrowserLogin> | null = null;
const updatePopup = () => {
useUIStore.getState().openInfoPopup({
title: "Codex Login",
icon: providerIcon("codex"),
lines: [...lines],
onClose: () => handle?.abort(),
});
};
updatePopup();
handle = runCodexBrowserLogin((message) => {
lines.push({ type: "text", label: message, color: theme.textPrimary });
updatePopup();
});
try {
await handle.promise;
await checkProviders().catch(() => {});
lines.push({
type: "text",
label: "Select a Codex model with Ctrl+L or /models.",
color: theme.success,
});
updatePopup();
useUIStore.getState().openModal("llmSelector");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
lines.push({
type: "text",
label: `Error: ${message}`,
color: theme.brandSecondary,
});
updatePopup();
}
}
+286
View File
@@ -0,0 +1,286 @@
import { spawn, spawnSync } from "node:child_process";
import readline from "node:readline";
import type { ProviderModelInfo } from "../types.js";
export interface CodexLoginStatus {
installed: boolean;
loggedIn: boolean;
authMode: "chatgpt" | "api-key" | null;
message: string;
}
export interface CodexAppServerClient {
request(method: string, params: Record<string, unknown>, timeoutMs?: number): Promise<unknown>;
waitForNotification<T>(
method: string,
predicate: (params: T) => boolean,
timeoutMs?: number,
): Promise<T>;
close(): void;
}
export function parseCodexLoginStatus(status: number | null, output: string): CodexLoginStatus {
const message = output.trim() || (status === 0 ? "Logged in" : "Not logged in");
if (message.includes("Logged in using ChatGPT")) {
return { installed: true, loggedIn: true, authMode: "chatgpt", message };
}
if (message.includes("Logged in using an API key")) {
return { installed: true, loggedIn: true, authMode: "api-key", message };
}
if (message.includes("Not logged in")) {
return { installed: true, loggedIn: false, authMode: null, message };
}
return {
installed: true,
loggedIn: status === 0,
authMode: status === 0 ? "chatgpt" : null,
message,
};
}
export function parseCodexModelListResult(result: unknown): ProviderModelInfo[] {
const data =
result && typeof result === "object" && "data" in result
? (result as { data?: unknown }).data
: undefined;
if (!Array.isArray(data)) return [];
return data.flatMap((entry) => {
if (!entry || typeof entry !== "object") return [];
const item = entry as {
id?: unknown;
model?: unknown;
displayName?: unknown;
hidden?: unknown;
};
if (item.hidden === true) return [];
const id =
typeof item.id === "string" ? item.id : typeof item.model === "string" ? item.model : null;
if (!id) return [];
const name =
typeof item.displayName === "string" && item.displayName.trim() ? item.displayName : id;
return [{ id, name }];
});
}
export function isCodexInstalled(): boolean {
try {
return spawnSync("codex", ["--version"], { stdio: "ignore", timeout: 5_000 }).status === 0;
} catch {
return false;
}
}
export function getCodexLoginStatus(): CodexLoginStatus {
try {
const result = spawnSync("codex", ["login", "status"], {
timeout: 5_000,
encoding: "utf-8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
return {
installed: false,
loggedIn: false,
authMode: null,
message: result.error.message,
};
}
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
return parseCodexLoginStatus(result.status, output);
} catch (error) {
return {
installed: false,
loggedIn: false,
authMode: null,
message: error instanceof Error ? error.message : String(error),
};
}
}
export function assertCodexReady(): void {
const status = getCodexLoginStatus();
if (!status.installed) {
throw new Error("Codex CLI is not installed. Install Codex, then run `codex login`.");
}
if (!status.loggedIn) {
throw new Error("Codex is not logged in. Run `codex login` and try again.");
}
}
export async function startCodexAppServerSession(): Promise<CodexAppServerClient> {
const child = spawn("codex", ["app-server", "--listen", "stdio://"], {
stdio: ["pipe", "pipe", "pipe"],
});
if (!child.stdin || !child.stdout) {
child.kill();
throw new Error("Failed to start Codex app-server");
}
const stderrChunks: Buffer[] = [];
if (child.stderr) {
child.stderr.on("data", (chunk) => {
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
});
}
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
const send = (message: Record<string, unknown>) =>
child.stdin.write(`${JSON.stringify(message)}\n`);
let closed = false;
let nextRequestId = 1;
const pending = new Map<
number,
{
resolve: (value: unknown) => void;
reject: (error: Error) => void;
}
>();
const listeners = new Set<(method: string, params: unknown) => void>();
const shutdown = () => {
if (closed) return;
closed = true;
rl.close();
child.removeAllListeners();
for (const waiter of pending.values()) {
waiter.reject(new Error("Codex app-server connection closed"));
}
pending.clear();
listeners.clear();
try {
if (!child.killed) child.kill();
} catch {}
};
child.once("error", (error) => {
const err = error instanceof Error ? error : new Error(String(error));
for (const waiter of pending.values()) {
waiter.reject(err);
}
pending.clear();
});
child.once("exit", (code, signal) => {
const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
const detail = signal ? `signal ${signal}` : `code ${code ?? 0}`;
const err = new Error(
stderr
? `Codex app-server exited with ${detail}: ${stderr}`
: `Codex app-server exited with ${detail}`,
);
for (const waiter of pending.values()) {
waiter.reject(err);
}
pending.clear();
});
rl.on("line", (line) => {
let message: Record<string, unknown>;
try {
message = JSON.parse(line) as Record<string, unknown>;
} catch {
return;
}
const id = typeof message.id === "number" ? message.id : null;
if (id != null && pending.has(id)) {
const waiter = pending.get(id);
pending.delete(id);
if (!waiter) return;
if (message.error) {
waiter.reject(
new Error(
typeof message.error === "object"
? JSON.stringify(message.error)
: String(message.error),
),
);
return;
}
waiter.resolve(message.result);
return;
}
if (typeof message.method === "string") {
for (const listener of listeners) listener(message.method, message.params);
}
});
const request = (method: string, params: Record<string, unknown>, timeoutMs = 30_000) => {
if (closed) return Promise.reject(new Error("Codex app-server connection closed"));
const id = nextRequestId++;
return new Promise<unknown>((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(`Codex app-server ${method} timed out`));
}, timeoutMs);
pending.set(id, {
resolve: (value) => {
clearTimeout(timer);
resolve(value);
},
reject: (error) => {
clearTimeout(timer);
reject(error);
},
});
send({ id, method, params });
});
};
const waitForNotification = <T>(
method: string,
predicate: (params: T) => boolean,
timeoutMs = 300_000,
) =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
listeners.delete(listener);
reject(new Error(`Codex app-server notification ${method} timed out`));
}, timeoutMs);
const listener = (nextMethod: string, rawParams: unknown) => {
if (nextMethod !== method) return;
const params = rawParams as T;
if (!predicate(params)) return;
clearTimeout(timer);
listeners.delete(listener);
resolve(params);
};
listeners.add(listener);
});
try {
await request("initialize", {
clientInfo: { name: "soulforge", title: "SoulForge", version: "0.0.0" },
});
send({ method: "initialized", params: {} });
return { request, waitForNotification, close: shutdown };
} catch (error) {
shutdown();
throw error;
}
}
async function requestCodexAppServer(
method: string,
params: Record<string, unknown>,
): Promise<unknown> {
const session = await startCodexAppServerSession();
try {
return await session.request(method, params);
} finally {
session.close();
}
}
export async function fetchCodexModelsFromAppServer(): Promise<ProviderModelInfo[]> {
const result = await requestCodexAppServer("model/list", {});
return parseCodexModelListResult(result);
}
+67
View File
@@ -0,0 +1,67 @@
import type { ProviderDefinition, ProviderModelInfo } from "../types.js";
import { performCodexBrowserLogin, requestCodexAuth, runCodexBrowserLogin } from "./auth.js";
import {
assertCodexReady,
fetchCodexModelsFromAppServer,
getCodexLoginStatus,
parseCodexLoginStatus,
parseCodexModelListResult,
} from "./client.js";
import {
buildCodexSchema,
createCodexLanguageModel,
parseCodexResponse,
serializeCodexPrompt,
} from "./runner.js";
export const CODEX_FALLBACK_MODELS: ProviderModelInfo[] = [
{ id: "gpt-5.4", name: "GPT-5.4", contextWindow: 1_050_000 },
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex", contextWindow: 400_000 },
];
export const CODEX_CONTEXT_WINDOWS: [pattern: string, tokens: number][] = [
["gpt-5.4", 1_050_000],
["gpt-5.2-codex", 400_000],
];
export const codex: ProviderDefinition = {
id: "codex",
name: "Codex",
// Empty envVar marks providers that authenticate outside SoulForge and keeps them
// out of getProviderSecretEntries(), which only registers non-empty env-backed secrets.
envVar: "",
icon: "⌘",
asciiIcon: "C",
description: "OpenAI Codex via the official CLI. Output appears when each turn finishes.",
noAuthLabel: "login required — Enter to authenticate",
authErrorLabel: "login/session error",
badge: "non-streaming",
onRequestAuth: requestCodexAuth,
createModel(modelId: string) {
assertCodexReady();
return createCodexLanguageModel(modelId);
},
async fetchModels() {
const status = getCodexLoginStatus();
if (!status.installed || !status.loggedIn) return null;
return fetchCodexModelsFromAppServer();
},
fallbackModels: CODEX_FALLBACK_MODELS,
contextWindows: CODEX_CONTEXT_WINDOWS,
checkAvailability: async () => {
const status = getCodexLoginStatus();
return status.installed && status.loggedIn;
},
};
export {
buildCodexSchema,
createCodexLanguageModel,
parseCodexLoginStatus,
parseCodexModelListResult,
parseCodexResponse,
performCodexBrowserLogin,
requestCodexAuth,
runCodexBrowserLogin,
serializeCodexPrompt,
};
+486
View File
@@ -0,0 +1,486 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import readline from "node:readline";
import type {
JSONSchema7,
LanguageModelV2,
LanguageModelV2CallOptions,
LanguageModelV2CallWarning,
LanguageModelV2Content,
LanguageModelV2FinishReason,
LanguageModelV2StreamPart,
LanguageModelV2Usage,
} from "@ai-sdk/provider";
export interface CodexRunnerCall {
modelId: string;
prompt: string;
schema: JSONSchema7;
abortSignal?: AbortSignal;
}
export interface CodexRunnerResult {
text: string;
usage: LanguageModelV2Usage;
}
export interface CodexRunner {
run(call: CodexRunnerCall): Promise<CodexRunnerResult>;
}
interface ParsedCodexResponse {
finishReason: LanguageModelV2FinishReason;
content: LanguageModelV2Content[];
}
interface SerializedTool {
name: string;
description?: string;
inputSchema: JSONSchema7;
}
type PromptArrayContent = Exclude<LanguageModelV2CallOptions["prompt"][number]["content"], string>;
type PromptPart = PromptArrayContent[number];
type ToolResultPromptPart = Extract<PromptPart, { type: "tool-result" }>;
function getFunctionTools(options: LanguageModelV2CallOptions): SerializedTool[] {
return (options.tools ?? []).flatMap((tool) => {
if (tool.type !== "function") return [];
return [
{
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
},
];
});
}
function renderDataValue(data: unknown): string {
if (typeof data === "string") return data.startsWith("data:") ? "inline-data-url" : data;
if (data instanceof URL) return data.toString();
if (data instanceof Uint8Array) return `binary:${data.byteLength} bytes`;
return String(data);
}
function renderToolResult(output: ToolResultPromptPart): string {
switch (output.output.type) {
case "text":
case "error-text":
return output.output.value;
case "json":
case "error-json":
return JSON.stringify(output.output.value, null, 2);
case "content":
return output.output.value
.map((part: (typeof output.output.value)[number]) =>
part.type === "text"
? part.text
: `[media ${part.mediaType} ${Math.ceil(part.data.length / 4) * 3} bytes]`,
)
.join("\n");
default:
return JSON.stringify(output.output);
}
}
function renderMessageContent(content: PromptArrayContent): string {
return content
.map((part: PromptPart) => {
switch (part.type) {
case "text":
return part.text;
case "reasoning":
return `[reasoning]\n${part.text}`;
case "file":
return `[file mediaType=${part.mediaType}${part.filename ? ` filename=${part.filename}` : ""} data=${renderDataValue(part.data)}]`;
case "tool-call":
return `[assistant tool call ${part.toolName} id=${part.toolCallId}]\n${JSON.stringify(part.input, null, 2)}`;
case "tool-result":
return `[tool result ${part.toolName} id=${part.toolCallId}]\n${renderToolResult(part)}`;
default:
return JSON.stringify(part);
}
})
.join("\n\n");
}
function describeToolChoice(options: LanguageModelV2CallOptions): string {
const choice = options.toolChoice;
if (!choice || choice.type === "auto") {
return "Client tools are available for this step. If a tool helps you answer correctly, return finishReason=tool-calls and request it.";
}
if (choice.type === "none") return "Do not call any tools. Answer directly.";
if (choice.type === "required") return "You must call one or more tools before responding.";
return `You must call the tool named ${choice.toolName} before responding.`;
}
export function serializeCodexPrompt(options: LanguageModelV2CallOptions): string {
const tools = getFunctionTools(options);
const transcript = options.prompt
.map((message, index) => {
const role = message.role.toUpperCase();
const content =
typeof message.content === "string"
? message.content
: renderMessageContent(message.content);
return `${index + 1}. ${role}\n${content}`;
})
.join("\n\n");
const toolBlock =
tools.length === 0
? "No tools are available for this step."
: tools
.map(
(tool, index) =>
`${index + 1}. ${tool.name}${tool.description ? ` — ${tool.description}` : ""}\nInput schema:\n${JSON.stringify(tool.inputSchema, null, 2)}`,
)
.join("\n\n");
const jsonInstruction =
options.responseFormat?.type === "json"
? `If you choose finishReason=stop, the text field must contain raw JSON matching this schema exactly:\n${JSON.stringify(options.responseFormat.schema ?? {}, null, 2)}`
: "If you choose finishReason=stop, the text field must contain the assistant reply as plain text with no markdown wrapper.";
return [
"You are Codex running as the language-model backend for SoulForge.",
"Operate only as a model adapter.",
"Do not execute shell commands, edit files, browse the web, or use Codex internal tools.",
"The tools listed below are external client tools. You ARE allowed to request them by returning toolCalls in your JSON response.",
"Decide the next assistant step for the conversation transcript below.",
"Return ONLY valid JSON that matches the provided output schema.",
"When finishReason is tool-calls, leave text empty and fill toolCalls with the exact tool names and an inputJson string that is valid JSON for that tool.",
jsonInstruction,
describeToolChoice(options),
"",
"AVAILABLE TOOLS",
toolBlock,
"",
"CONVERSATION TRANSCRIPT",
transcript,
].join("\n");
}
export function buildCodexSchema(options: LanguageModelV2CallOptions): JSONSchema7 {
const tools = getFunctionTools(options);
const toolChoice = options.toolChoice;
const finishReasons: string[] = [];
if (!tools.length || toolChoice?.type === "none") finishReasons.push("stop");
else if (toolChoice?.type === "required" || toolChoice?.type === "tool")
finishReasons.push("tool-calls");
else finishReasons.push("stop", "tool-calls");
const allowedNames =
toolChoice?.type === "tool" ? [toolChoice.toolName] : tools.map((tool) => tool.name);
const toolCallItems: JSONSchema7 = tools.length
? {
type: "object",
additionalProperties: false,
properties: {
toolName: { type: "string", enum: allowedNames },
inputJson: { type: "string" },
},
required: ["toolName", "inputJson"],
}
: {
type: "object",
additionalProperties: false,
properties: {},
};
return {
type: "object",
additionalProperties: false,
properties: {
finishReason: { type: "string", enum: finishReasons },
reasoning: { type: "string" },
text: { type: "string" },
toolCalls: {
type: "array",
minItems:
tools.length && (toolChoice?.type === "required" || toolChoice?.type === "tool") ? 1 : 0,
maxItems: tools.length ? undefined : 0,
items: toolCallItems,
},
},
required: ["finishReason", "reasoning", "text", "toolCalls"],
};
}
export function parseCodexResponse(text: string): ParsedCodexResponse {
let parsed: {
finishReason?: string;
reasoning?: string;
text?: string;
toolCalls?: Array<{
toolName?: string;
input?: Record<string, unknown>;
inputJson?: string;
}>;
};
try {
parsed = JSON.parse(text) as typeof parsed;
} catch {
return { content: [{ type: "text", text }], finishReason: "stop" };
}
const content: LanguageModelV2Content[] = [];
if (parsed.reasoning?.trim()) {
content.push({ type: "reasoning", text: parsed.reasoning.trim() });
}
if (parsed.finishReason === "tool-calls") {
const toolCalls = Array.isArray(parsed.toolCalls) ? parsed.toolCalls : [];
for (const toolCall of toolCalls) {
if (!toolCall?.toolName) continue;
const inputJson =
typeof toolCall.inputJson === "string"
? toolCall.inputJson
: JSON.stringify(toolCall.input ?? {});
content.push({
type: "tool-call",
toolCallId: randomUUID(),
toolName: toolCall.toolName,
input: inputJson,
});
}
if (content.length === 0 || content.every((part) => part.type === "reasoning")) {
throw new Error("Codex returned finishReason=tool-calls without any tool calls");
}
return { content, finishReason: "tool-calls" };
}
if (parsed.text) {
content.push({ type: "text", text: parsed.text });
}
return { content, finishReason: "stop" };
}
function collectWarnings(options: LanguageModelV2CallOptions): LanguageModelV2CallWarning[] {
const warnings: LanguageModelV2CallWarning[] = [];
for (const setting of [
"maxOutputTokens",
"temperature",
"stopSequences",
"topP",
"topK",
"presencePenalty",
"frequencyPenalty",
"seed",
] as const) {
if (options[setting] !== undefined) {
warnings.push({
type: "unsupported-setting",
setting,
details: "Codex CLI ignores this setting",
});
}
}
return warnings;
}
class CodexCliRunner implements CodexRunner {
async run(call: CodexRunnerCall): Promise<CodexRunnerResult> {
const dir = await mkdtemp(join(tmpdir(), "soulforge-codex-"));
const schemaPath = join(dir, "schema.json");
await writeFile(schemaPath, JSON.stringify(call.schema, null, 2), "utf8");
const args = [
"exec",
"--json",
"--ephemeral",
"--skip-git-repo-check",
"--sandbox",
"read-only",
"--color",
"never",
"--config",
'approval_policy="never"',
"--cd",
process.cwd(),
"--output-schema",
schemaPath,
"--model",
call.modelId,
];
try {
return await runCodexProcess(args, call.prompt, call.abortSignal);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
}
async function runCodexProcess(
args: string[],
prompt: string,
abortSignal?: AbortSignal,
): Promise<CodexRunnerResult> {
const child = spawn("codex", args, { signal: abortSignal });
let spawnError: unknown | null = null;
child.once("error", (error) => {
spawnError = error;
});
if (!child.stdin || !child.stdout) {
child.kill();
throw new Error("Failed to start Codex CLI");
}
child.stdin.write(prompt);
child.stdin.end();
const stderrChunks: Buffer[] = [];
if (child.stderr) {
child.stderr.on("data", (chunk) => {
stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
});
}
const exitPromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(
(resolve) => {
child.once("exit", (code, signal) => resolve({ code, signal }));
},
);
const rl = readline.createInterface({ input: child.stdout, crlfDelay: Infinity });
let finalText = "";
let usage: LanguageModelV2Usage = {
inputTokens: undefined,
outputTokens: undefined,
totalTokens: undefined,
};
let turnFailure: string | null = null;
let streamFailure: string | null = null;
try {
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
let event: Record<string, unknown>;
try {
event = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue;
}
if (event.type === "item.completed") {
const item = event.item as { type?: string; text?: string } | undefined;
if (item?.type === "agent_message" && typeof item.text === "string") {
finalText = item.text;
}
} else if (event.type === "turn.completed") {
const rawUsage = event.usage as
| { input_tokens?: number; cached_input_tokens?: number; output_tokens?: number }
| undefined;
if (rawUsage) {
usage = {
inputTokens: rawUsage.input_tokens,
outputTokens: rawUsage.output_tokens,
totalTokens:
rawUsage.input_tokens != null && rawUsage.output_tokens != null
? rawUsage.input_tokens + rawUsage.output_tokens
: undefined,
cachedInputTokens: rawUsage.cached_input_tokens,
};
}
} else if (event.type === "turn.failed") {
const error = event.error as { message?: string } | undefined;
turnFailure = error?.message ?? "Codex turn failed";
} else if (event.type === "error") {
streamFailure = typeof event.message === "string" ? event.message : "Codex stream failed";
}
}
if (spawnError) throw spawnError;
const exit = await exitPromise;
if (turnFailure) throw new Error(turnFailure);
if (streamFailure) throw new Error(streamFailure);
if (exit.code !== 0 || exit.signal) {
const stderr = Buffer.concat(stderrChunks).toString("utf8").trim();
const detail = exit.signal ? `signal ${exit.signal}` : `code ${exit.code ?? 1}`;
throw new Error(`Codex exec exited with ${detail}${stderr ? `: ${stderr}` : ""}`);
}
if (!finalText) {
throw new Error("Codex exec returned no final agent message");
}
return { text: finalText, usage };
} finally {
rl.close();
child.removeAllListeners();
try {
if (!child.killed) child.kill();
} catch {}
}
}
export function createCodexLanguageModel(
modelId: string,
runner: CodexRunner = new CodexCliRunner(),
): LanguageModelV2 {
const warningsFor = (options: LanguageModelV2CallOptions) => collectWarnings(options);
return {
specificationVersion: "v2",
provider: "codex",
modelId,
supportedUrls: {},
async doGenerate(options) {
const warnings = warningsFor(options);
const result = await runner.run({
modelId,
prompt: serializeCodexPrompt(options),
schema: buildCodexSchema(options),
abortSignal: options.abortSignal,
});
const parsed = parseCodexResponse(result.text);
return {
content: parsed.content,
finishReason: parsed.finishReason,
usage: result.usage,
warnings,
};
},
async doStream(options) {
const generated = await this.doGenerate(options);
const stream = new ReadableStream<LanguageModelV2StreamPart>({
start(controller) {
controller.enqueue({ type: "stream-start", warnings: warningsFor(options) });
for (const part of generated.content) {
if (part.type === "reasoning") {
const id = `${modelId}-reasoning-${randomUUID()}`;
controller.enqueue({ type: "reasoning-start", id });
controller.enqueue({ type: "reasoning-delta", id, delta: part.text });
controller.enqueue({ type: "reasoning-end", id });
} else if (part.type === "text") {
const id = `${modelId}-text-${randomUUID()}`;
controller.enqueue({ type: "text-start", id });
controller.enqueue({ type: "text-delta", id, delta: part.text });
controller.enqueue({ type: "text-end", id });
} else if (part.type === "tool-call") {
controller.enqueue(part);
}
}
controller.enqueue({
type: "finish",
finishReason: generated.finishReason,
usage: generated.usage,
});
controller.close();
},
});
return { stream };
},
};
}
+1
View File
@@ -97,6 +97,7 @@ export const copilot: ProviderDefinition = {
keyUrl: "github.com/features/copilot",
asciiIcon: "CP",
description: "Free with Copilot sub",
badge: "unofficial",
createModel: createCopilotModel,
+3
View File
@@ -1,5 +1,6 @@
export { anthropic } from "./anthropic.js";
export { bedrock } from "./bedrock.js";
export { codex } from "./codex.js";
export { copilot } from "./copilot.js";
export { buildCustomProvider } from "./custom.js";
export { deepseek } from "./deepseek.js";
@@ -21,6 +22,7 @@ export { xai } from "./xai.js";
import { anthropic } from "./anthropic.js";
import { bedrock } from "./bedrock.js";
import { codex } from "./codex.js";
import { copilot } from "./copilot.js";
import { buildCustomProvider } from "./custom.js";
import { deepseek } from "./deepseek.js";
@@ -54,6 +56,7 @@ const BUILTIN_PROVIDERS: ProviderDefinition[] = [
bedrock,
fireworks,
minimax,
codex,
copilot,
githubModels,
openrouter,
+7
View File
@@ -19,6 +19,12 @@ export interface ProviderDefinition {
asciiIcon?: string;
/** Short description for wizard/UI (e.g. "Claude models"). */
description?: string;
/** Inline badge for provider selectors (e.g. "unofficial", "non-streaming"). */
badge?: string;
/** Custom label shown when the provider is unavailable and needs auth. */
noAuthLabel?: string;
/** Custom label shown when model loading fails after the provider is available. */
authErrorLabel?: string;
createModel(modelId: string): LanguageModel;
fetchModels(): Promise<ProviderModelInfo[] | null>;
fallbackModels: ProviderModelInfo[];
@@ -26,6 +32,7 @@ export interface ProviderDefinition {
grouped?: boolean;
custom?: boolean;
checkAvailability?(): Promise<boolean>;
onRequestAuth?(): Promise<void>;
onActivate?(): Promise<void>;
onDeactivate?(): void;
}
+283
View File
@@ -0,0 +1,283 @@
import type { LanguageModelV2CallOptions } from "@ai-sdk/provider";
import { describe, expect, test } from "bun:test";
import { getAllProviders, getProvider } from "../src/core/llm/providers/index.js";
import {
buildCodexSchema,
codex,
createCodexLanguageModel,
performCodexBrowserLogin,
parseCodexLoginStatus,
parseCodexModelListResult,
parseCodexResponse,
serializeCodexPrompt,
} from "../src/core/llm/providers/codex.js";
const BASE_OPTIONS: LanguageModelV2CallOptions = {
prompt: [
{ role: "system", content: "You are SoulForge." },
{ role: "user", content: [{ type: "text", text: "Read package.json" }] },
],
responseFormat: { type: "text" },
};
describe("codex provider", () => {
test("is registered as a builtin provider", () => {
expect(getProvider("codex")).toBeDefined();
expect(getAllProviders().filter((provider) => !provider.custom)).toHaveLength(19);
});
test("has provider metadata, fallback models, and shared auth hook", () => {
expect(codex.id).toBe("codex");
expect(codex.name).toBe("Codex");
expect(codex.envVar).toBe("");
expect(codex.description).toContain("Codex");
expect(codex.description).toContain("turn finishes");
expect(codex.noAuthLabel).toBe("login required — Enter to authenticate");
expect(codex.authErrorLabel).toBe("login/session error");
expect(codex.badge).toBe("non-streaming");
expect(codex.onRequestAuth).toBeDefined();
expect(codex.fallbackModels).toEqual([
{ id: "gpt-5.4", name: "GPT-5.4", contextWindow: 1_050_000 },
{ id: "gpt-5.2-codex", name: "GPT-5.2-Codex", contextWindow: 400_000 },
]);
expect(codex.contextWindows).toEqual([
["gpt-5.4", 1_050_000],
["gpt-5.2-codex", 400_000],
]);
});
test("parses official codex login status output", () => {
expect(parseCodexLoginStatus(0, "Logged in using ChatGPT")).toEqual({
installed: true,
loggedIn: true,
authMode: "chatgpt",
message: "Logged in using ChatGPT",
});
expect(parseCodexLoginStatus(0, "Logged in using an API key - sk-proj-***ABCDE")).toEqual({
installed: true,
loggedIn: true,
authMode: "api-key",
message: "Logged in using an API key - sk-proj-***ABCDE",
});
expect(parseCodexLoginStatus(1, "Not logged in")).toEqual({
installed: true,
loggedIn: false,
authMode: null,
message: "Not logged in",
});
});
test("parses official model/list output and drops hidden models", () => {
const models = parseCodexModelListResult({
data: [
{ id: "gpt-5.4", displayName: "gpt-5.4", hidden: false },
{ id: "gpt-5.2-codex", displayName: "gpt-5.2-codex", hidden: false },
{ id: "internal-test-model", displayName: "Internal", hidden: true },
],
nextCursor: null,
});
expect(models).toEqual([
{ id: "gpt-5.4", name: "gpt-5.4" },
{ id: "gpt-5.2-codex", name: "gpt-5.2-codex" },
]);
});
test("performs browser login via official app-server auth flow", async () => {
const calls: string[] = [];
const events: string[] = [];
let openedUrl = "";
await performCodexBrowserLogin(
{
request: async (method) => {
calls.push(method);
if (method === "account/login/start") {
return {
type: "chatgpt",
loginId: "login-123",
authUrl: "https://chatgpt.com/auth/callback",
};
}
throw new Error(`Unexpected request ${method}`);
},
waitForNotification: async (method, predicate) => {
calls.push(method);
const payload = { loginId: "login-123", success: true, error: null };
expect(predicate(payload)).toBe(true);
return payload;
},
},
async (url) => {
openedUrl = url;
return true;
},
(event) => events.push(event),
);
expect(calls).toEqual(["account/login/start", "account/login/completed"]);
expect(openedUrl).toBe("https://chatgpt.com/auth/callback");
expect(events).toEqual([
"Starting Codex browser login...",
"Opening browser for Codex login...",
"Browser opened. Complete the login in ChatGPT.",
"Codex authentication complete.",
]);
});
test("surfaces app-server login failures", async () => {
await expect(
performCodexBrowserLogin(
{
request: async () => ({
type: "chatgpt",
loginId: "login-123",
authUrl: "https://chatgpt.com/auth/callback",
}),
waitForNotification: async () => ({
loginId: "login-123",
success: false,
error: "Access denied",
}),
},
async () => true,
),
).rejects.toThrow("Access denied");
});
test("serializes prompt transcript and available tools for Codex", () => {
const prompt = serializeCodexPrompt({
...BASE_OPTIONS,
tools: [
{
type: "function",
name: "read_file",
description: "Read a file from disk",
inputSchema: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
},
],
toolChoice: { type: "auto" },
});
expect(prompt).toContain("You are Codex running as the language-model backend for SoulForge");
expect(prompt).toContain("SYSTEM");
expect(prompt).toContain("Read package.json");
expect(prompt).toContain("read_file");
expect(prompt).toContain("Read a file from disk");
});
test("builds a schema that allows tool calls when tools are available", () => {
const schema = buildCodexSchema({
...BASE_OPTIONS,
tools: [
{
type: "function",
name: "read_file",
description: "Read a file from disk",
inputSchema: { type: "object" },
},
],
toolChoice: { type: "auto" },
});
expect(schema.type).toBe("object");
expect(JSON.stringify(schema)).toContain("tool-calls");
expect(JSON.stringify(schema)).toContain("read_file");
});
test("parses stop responses into reasoning and text content", () => {
const parsed = parseCodexResponse(
JSON.stringify({
finishReason: "stop",
reasoning: "Need no tools.",
text: "Done.",
toolCalls: [],
}),
);
expect(parsed.finishReason).toBe("stop");
expect(parsed.content).toEqual([
{ type: "reasoning", text: "Need no tools." },
{ type: "text", text: "Done." },
]);
});
test("parses tool-call responses into AI SDK tool call parts", () => {
const parsed = parseCodexResponse(
JSON.stringify({
finishReason: "tool-calls",
reasoning: "Need file contents.",
text: "",
toolCalls: [{ toolName: "read_file", inputJson: JSON.stringify({ path: "package.json" }) }],
}),
);
expect(parsed.finishReason).toBe("tool-calls");
expect(parsed.content[0]).toEqual({ type: "reasoning", text: "Need file contents." });
expect(parsed.content[1]).toMatchObject({
type: "tool-call",
toolName: "read_file",
input: JSON.stringify({ path: "package.json" }),
});
});
test("language model forwards prompt and schema to the runner", async () => {
const calls: Array<{ modelId: string; prompt: string; schema: unknown }> = [];
const model = createCodexLanguageModel("gpt-5.2-codex", {
async run(call) {
calls.push({ modelId: call.modelId, prompt: call.prompt, schema: call.schema });
return {
text: JSON.stringify({
finishReason: "stop",
reasoning: "",
text: "PONG",
toolCalls: [],
}),
usage: { inputTokens: 12, outputTokens: 4, totalTokens: 16 },
};
},
});
const result = await model.doGenerate(BASE_OPTIONS);
expect(calls).toHaveLength(1);
expect(calls[0]?.modelId).toBe("gpt-5.2-codex");
expect(calls[0]?.prompt).toContain("Read package.json");
expect(result.content).toEqual([{ type: "text", text: "PONG" }]);
expect(result.usage.totalTokens).toBe(16);
});
test("streaming emits final text and finish events", async () => {
const model = createCodexLanguageModel("gpt-5.2-codex", {
async run() {
return {
text: JSON.stringify({
finishReason: "stop",
reasoning: "",
text: "Hello from Codex",
toolCalls: [],
}),
usage: { inputTokens: 10, outputTokens: 3, totalTokens: 13 },
};
},
});
const { stream } = await model.doStream(BASE_OPTIONS);
const reader = stream.getReader();
const parts: string[] = [];
while (true) {
const next = await reader.read();
if (next.done) break;
parts.push(next.value.type);
}
expect(parts).toEqual(["stream-start", "text-start", "text-delta", "text-end", "finish"]);
});
});
+2 -2
View File
@@ -14,9 +14,9 @@ describe("local providers registration", () => {
expect(getProvider("lmstudio")!.custom).toBeUndefined();
});
test("total builtin count is 18", () => {
test("total builtin count is 19", () => {
const builtins = getAllProviders().filter((p) => !p.custom);
expect(builtins.length).toBe(18);
expect(builtins.length).toBe(19);
});
test("ollama has required fields", () => {
+2 -2
View File
@@ -37,8 +37,8 @@ describe("new providers registration", () => {
test("total builtin count increased by 5", () => {
const builtins = getAllProviders().filter((p) => !p.custom);
// Was 13, now 18
expect(builtins.length).toBe(18);
// Was 13, then 18 after the previous provider batch, now 19 with Codex.
expect(builtins.length).toBe(19);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test";
import {
checkProviders,
getCachedProviderStatuses,
subscribeProviderStatuses,
} from "../src/core/llm/provider.js";
describe("provider status subscriptions", () => {
test("notifies subscribers when provider availability is refreshed", async () => {
const seen: Array<ReturnType<typeof getCachedProviderStatuses>> = [];
const unsubscribe = subscribeProviderStatuses((statuses) => {
seen.push(statuses);
});
const result = await checkProviders();
unsubscribe();
expect(seen).toHaveLength(1);
expect(seen[0]).toEqual(result);
expect(getCachedProviderStatuses()).toEqual(result);
});
test("stops notifying after unsubscribe", async () => {
let calls = 0;
const unsubscribe = subscribeProviderStatuses(() => {
calls += 1;
});
unsubscribe();
await checkProviders();
expect(calls).toBe(0);
});
});