mirror of
https://github.com/proxysoul/Empryo.git
synced 2026-11-07 06:25:57 -10:00
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:
1 parent
e4f70d11bb
commit
ac16c22a12
18 files changed
+1404
-11
No files matched your search
+15
-2
@@ -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}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./codex/index.js";
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -97,6 +97,7 @@ export const copilot: ProviderDefinition = {
|
||||
keyUrl: "github.com/features/copilot",
|
||||
asciiIcon: "CP",
|
||||
description: "Free with Copilot sub",
|
||||
badge: "unofficial",
|
||||
|
||||
createModel: createCopilotModel,
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user