chore: import upstream snapshot with attribution
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:43:05 +08:00
commit 426e9eeabd
41828 changed files with 9656266 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
/** Load, refresh, and atomically save codex CLI ChatGPT OAuth cache. */
import { randomBytes } from "node:crypto";
import { open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
const OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
const OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
const LOCK_STALE_MS = 30_000;
const LOCK_RETRY_DELAY_MS = 100;
const LOCK_RETRY_MAX = 30;
export interface CodexAuth {
OPENAI_API_KEY: string | null;
auth_mode: "chatgpt" | "apikey";
last_refresh: string;
tokens: {
id_token: string;
access_token: string;
refresh_token: string;
account_id: string;
};
}
export interface CodexAuthDeps {
fetch?: typeof fetch;
now?: () => number;
}
let injectedDeps: CodexAuthDeps = {};
export function __setCodexAuthDeps(deps: CodexAuthDeps): void {
injectedDeps = deps;
}
export function __resetCodexAuthDeps(): void {
injectedDeps = {};
}
function getFetch(): typeof fetch {
return injectedDeps.fetch ?? fetch;
}
function nowMs(): number {
return injectedDeps.now ? injectedDeps.now() : Date.now();
}
export function defaultAuthPath(): string {
return join(homedir(), ".codex", "auth.json");
}
export async function loadCodexAuth(path?: string): Promise<CodexAuth> {
const p = path ?? defaultAuthPath();
const raw = await readFile(p, "utf8");
const parsed = JSON.parse(raw) as Partial<CodexAuth>;
if (
!parsed ||
typeof parsed !== "object" ||
!parsed.tokens ||
typeof parsed.tokens.access_token !== "string" ||
typeof parsed.tokens.refresh_token !== "string"
) {
throw new Error(`codex auth.json malformed at ${p}: missing access/refresh token fields`);
}
return {
OPENAI_API_KEY: parsed.OPENAI_API_KEY ?? null,
auth_mode: parsed.auth_mode === "apikey" ? "apikey" : "chatgpt",
last_refresh: parsed.last_refresh ?? new Date(0).toISOString(),
tokens: {
id_token: typeof parsed.tokens.id_token === "string" ? parsed.tokens.id_token : "",
access_token: parsed.tokens.access_token,
refresh_token: parsed.tokens.refresh_token,
account_id: typeof parsed.tokens.account_id === "string" ? parsed.tokens.account_id : "",
},
};
}
export async function saveCodexAuth(auth: CodexAuth, path: string): Promise<void> {
const tmp = `${path}.tmp.${process.pid}.${randomBytes(4).toString("hex")}`;
await writeFile(tmp, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 });
try {
await rename(tmp, path);
} catch (err) {
try {
await unlink(tmp);
} catch {
// ignore cleanup failure
}
throw err;
}
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split(".");
if (parts.length < 2 || !parts[1]) return null;
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
try {
return JSON.parse(Buffer.from(b64 + pad, "base64").toString("utf8")) as Record<string, unknown>;
} catch {
return null;
}
}
export function isExpired(auth: CodexAuth, bufferSeconds = 60): boolean {
const payload = decodeJwtPayload(auth.tokens.access_token);
const exp = payload?.exp;
if (typeof exp !== "number") return true;
return nowMs() + bufferSeconds * 1000 >= exp * 1000;
}
interface AcquiredLock {
release: () => Promise<void>;
}
async function tryCreateLock(lockPath: string): Promise<AcquiredLock | null> {
try {
const fh = await open(lockPath, "wx", 0o600);
try {
await fh.writeFile(`${process.pid}\n`);
} finally {
await fh.close();
}
return {
release: async () => {
try {
await unlink(lockPath);
} catch {
// already gone
}
},
};
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EEXIST") return null;
throw err;
}
}
async function isLockStale(lockPath: string): Promise<boolean> {
try {
return nowMs() - (await stat(lockPath)).mtimeMs > LOCK_STALE_MS;
} catch {
return false;
}
}
async function acquireLock(authPath: string): Promise<AcquiredLock> {
const lockPath = `${authPath}.lock`;
for (let attempt = 0; attempt < LOCK_RETRY_MAX; attempt++) {
const lock = await tryCreateLock(lockPath);
if (lock) return lock;
if (await isLockStale(lockPath)) {
try {
await unlink(lockPath);
} catch {
// race with another process
}
continue;
}
await new Promise<void>((resolve) => setTimeout(resolve, LOCK_RETRY_DELAY_MS));
}
throw new Error(`codex-auth: could not acquire lock ${lockPath} after ${LOCK_RETRY_MAX} retries`);
}
interface OAuthTokenResponse {
access_token: string;
refresh_token?: string;
id_token?: string;
}
export async function refreshCodexAuth(currentAuth: CodexAuth, path: string): Promise<CodexAuth> {
const lock = await acquireLock(path);
try {
try {
const onDisk = await loadCodexAuth(path);
if (!isExpired(onDisk)) return onDisk;
currentAuth = onDisk;
} catch {
// fall back to current auth
}
const res = await getFetch()(OAUTH_TOKEN_URL, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: currentAuth.tokens.refresh_token,
client_id: OAUTH_CLIENT_ID,
}).toString(),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`codex-auth: refresh failed: ${res.status} ${res.statusText} ${text}`.trim());
}
const json = (await res.json()) as OAuthTokenResponse;
if (!json || typeof json.access_token !== "string") {
throw new Error("codex-auth: refresh response missing access_token");
}
const next: CodexAuth = {
...currentAuth,
last_refresh: new Date(nowMs()).toISOString(),
tokens: {
...currentAuth.tokens,
access_token: json.access_token,
refresh_token: json.refresh_token ?? currentAuth.tokens.refresh_token,
id_token: json.id_token ?? currentAuth.tokens.id_token,
},
};
await saveCodexAuth(next, path);
return next;
} finally {
await lock.release();
}
}
@@ -0,0 +1,577 @@
/**
* HTTP client for the ChatGPT Codex `/responses` SSE endpoint, plus the
* provider-neutral message/tool translation it needs. CodexBackend loads the
* codex CLI OAuth token, posts a Responses-API request, and consumes the event
* stream into text, native tool calls, finish reason, and token usage.
*
* Calls on one instance serialize through a FIFO tail promise with optional
* pre-request jitter. A 401 triggers exactly one OAuth refresh-and-retry. The
* base URL is restricted to chatgpt.com or localhost to prevent token
* exfiltration, and temperature/max_output_tokens are never sent because the
* gpt-5.x reasoning models reject them with a 400.
*/
import { logger, type ChatMessage, type JsonValue, type ToolCall, type ToolDefinition } from "@elizaos/core";
import { parseSSE } from "./sse-parser";
import { toOpenAITool, type OpenAITool } from "./tool-format-openai";
import {
defaultAuthPath,
loadCodexAuth as loadCodexAuthDefault,
refreshCodexAuth as refreshCodexAuthDefault,
type CodexAuth,
} from "./codex-auth";
export type { CodexAuth } from "./codex-auth";
export type { OpenAITool } from "./tool-format-openai";
export interface CodexBackendConfig {
authPath?: string;
model?: string;
baseUrl?: string;
userAgent?: string;
originator?: string;
jitterMaxMs?: number;
fetchImpl?: typeof fetch;
loadAuth?: (path: string) => Promise<CodexAuth>;
refreshAuth?: (currentAuth: CodexAuth, path: string) => Promise<CodexAuth>;
toolTranslator?: (tool: ToolDefinition) => OpenAITool;
}
type CodexToolChoice =
| "auto"
| "none"
| "required"
| { type: "tool"; name: string }
| { type: "function"; function: { name: string } }
| { name: string };
export interface CodexGenerateParams {
prompt: string;
system?: string;
messages?: ChatMessage[];
tools?: ToolDefinition[];
toolChoice?: CodexToolChoice;
model?: string;
temperature?: number;
maxTokens?: number;
abortSignal?: AbortSignal;
onTextDelta?: (delta: string) => void;
responseFormat?:
| { type: "json_object" | "text" }
| { type: "json_schema"; schema: Record<string, unknown> }
| string;
}
export interface CodexGenerateResult {
text: string;
toolCalls: ToolCall[];
finishReason?: string;
usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
}
type CodexInputItem =
| {
type: "message";
role: "user" | "assistant" | "system";
content: Array<{ type: "input_text" | "output_text"; text: string }>;
}
| { type: "function_call"; call_id: string; name: string; arguments: string }
| { type: "function_call_output"; call_id: string; output: string };
interface CodexResponseBody {
model: string;
instructions: string;
input: CodexInputItem[];
store: false;
stream: true;
tools?: OpenAITool[];
tool_choice?: "auto" | "none" | "required" | { type: "function"; name: string };
text?: { format: { type: "json_object" } };
// NB: the ChatGPT codex backend rejects `temperature` and `max_output_tokens`
// (gpt-5.x reasoning models) with 400 "Unsupported parameter" — never include
// them in the request body.
}
const DEFAULT_MODEL = "gpt-5.5";
const DEFAULT_BASE_URL = "https://chatgpt.com/backend-api/codex";
const DEFAULT_USER_AGENT = "codex_cli_rs/0.124.0";
const DEFAULT_ORIGINATOR = "codex_cli_rs";
const DEFAULT_JITTER_MAX_MS = 200;
const DEFAULT_JITTER_MIN_MS = 50;
export class CodexBackend {
readonly name = "codex-cli";
private readonly authPath: string;
private readonly model: string;
private readonly baseUrl: string;
private readonly userAgent: string;
private readonly originator: string;
private readonly jitterMaxMs: number;
private readonly fetchImpl: typeof fetch;
private readonly loadAuth: (path: string) => Promise<CodexAuth>;
private readonly refreshAuth: (currentAuth: CodexAuth, path: string) => Promise<CodexAuth>;
private readonly toolTranslator: (tool: ToolDefinition) => OpenAITool;
private tail: Promise<unknown> = Promise.resolve();
constructor(config: CodexBackendConfig = {}) {
this.authPath = config.authPath ?? process.env.CODEX_AUTH_PATH ?? defaultAuthPath();
this.model = config.model ?? process.env.CODEX_MODEL ?? DEFAULT_MODEL;
this.baseUrl = validateBaseUrl(stripTrailingSlash(config.baseUrl ?? process.env.CODEX_BASE_URL ?? DEFAULT_BASE_URL));
this.userAgent = config.userAgent ?? process.env.CODEX_USER_AGENT ?? DEFAULT_USER_AGENT;
this.originator = config.originator ?? process.env.CODEX_ORIGINATOR ?? DEFAULT_ORIGINATOR;
this.jitterMaxMs = config.jitterMaxMs ?? envInt("CODEX_JITTER_MS_MAX", DEFAULT_JITTER_MAX_MS);
this.fetchImpl = config.fetchImpl ?? fetch;
this.loadAuth = config.loadAuth ?? loadCodexAuthDefault;
this.refreshAuth = config.refreshAuth ?? refreshCodexAuthDefault;
this.toolTranslator = config.toolTranslator ?? toOpenAITool;
}
async generate(params: CodexGenerateParams): Promise<CodexGenerateResult> {
const prior = this.tail;
let release!: () => void;
this.tail = new Promise<void>((resolve) => {
release = resolve;
});
try {
await prior;
await this.jitter();
return await this.generateInner(params);
} finally {
release();
}
}
private async jitter(): Promise<void> {
if (this.jitterMaxMs <= 0) return;
const lo = Math.min(DEFAULT_JITTER_MIN_MS, this.jitterMaxMs);
const span = Math.max(0, this.jitterMaxMs - lo);
await new Promise((resolve) => setTimeout(resolve, lo + Math.floor(Math.random() * (span + 1))));
}
private async generateInner(params: CodexGenerateParams): Promise<CodexGenerateResult> {
const systemPrompt = params.system ?? extractSystemPrompt(params.messages) ?? "";
const body: CodexResponseBody = {
model: params.model ?? this.model,
instructions: systemPrompt,
input: translateMessagesToCodexInput(params.messages, params.prompt),
store: false,
stream: true,
};
const tools = (params.tools ?? []).map((tool) => this.toolTranslator(tool));
if (tools.length > 0) body.tools = tools;
const toolChoice = toCodexToolChoice(params.toolChoice);
if (toolChoice !== undefined) body.tool_choice = toolChoice;
// The ChatGPT codex backend (gpt-5.x reasoning models on the Responses API)
// rejects `temperature` and `max_output_tokens` with a 400 "Unsupported
// parameter" — reasoning models honor neither, and the official codex CLI
// never sends them. The runtime's planner/response-handler calls always pass
// maxTokens (and often temperature), so forwarding them 400'd every codex
// turn → empty result → no reply. Never send them to the codex backend.
if (isJsonResponse(params.responseFormat)) body.text = { format: { type: "json_object" } };
let auth = await this.loadAuth(this.authPath);
let res = await this.postResponses(auth, body, params.abortSignal);
if (res.status === 401) {
logger.warn("[codex-cli] 401 from /responses, refreshing OAuth and retrying once");
auth = await this.refreshAuth(auth, this.authPath);
res = await this.postResponses(auth, body, params.abortSignal);
}
if (!res.ok) {
const errText = await safeReadText(res);
throw new Error(`codex /responses returned ${res.status} ${res.statusText} :: ${errText.slice(0, 512)}`);
}
if (!res.body) throw new Error("codex /responses returned no body");
return consumeResponseStream(res.body, params.abortSignal, params.onTextDelta);
}
private async postResponses(auth: CodexAuth, body: CodexResponseBody, signal?: AbortSignal): Promise<Response> {
const headers: Record<string, string> = {
Authorization: `Bearer ${auth.tokens.access_token}`,
"Content-Type": "application/json",
originator: this.originator,
"User-Agent": this.userAgent,
"OpenAI-Beta": "responses=v1",
Accept: "text/event-stream",
};
if (auth.tokens.account_id) headers["chatgpt-account-id"] = auth.tokens.account_id;
return this.fetchImpl(`${this.baseUrl}/responses`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal,
});
}
}
export function translateMessagesToCodexInput(messages: ChatMessage[] | undefined, prompt: string): CodexInputItem[] {
const out: CodexInputItem[] = [];
if (!messages || messages.length === 0) {
if (prompt.trim().length > 0) {
out.push({ type: "message", role: "user", content: [{ type: "input_text", text: prompt }] });
}
return out;
}
let lastText = "";
// function_call ids emitted but not yet paired with their output, in order.
// The Responses API rejects a function_call_output whose call_id has no
// matching function_call ("No tool call found for function call output with
// call_id ..."), so every output must reference a real preceding call.
const pendingCallIds: string[] = [];
for (const message of messages) {
if (message.role === "system" || message.role === "developer") continue;
if (message.role === "tool") {
// A tool turn carries one or more results. The planner renders them as
// `tool-result` CONTENT PARTS (toolCallId + output on the part); older
// callers put a single result in plain-text content keyed by the
// message-level toolCallId. Handle both so the fetched data actually
// reaches the model — dropping it makes the model believe its own tool
// call never ran ("I don't have the fetched contents visible here").
const resultParts = toolResultPartsFromContent(message.content);
const results =
resultParts.length > 0
? resultParts
: [{ toolCallId: message.toolCallId, text: contentToText(message.content) }];
for (const result of results) {
const wantId = result.toolCallId ?? message.toolCallId;
let callId: string | undefined;
const idx = wantId ? pendingCallIds.indexOf(wantId) : -1;
if (idx >= 0) {
callId = pendingCallIds[idx];
pendingCallIds.splice(idx, 1);
} else if (pendingCallIds.length > 0) {
callId = pendingCallIds.shift();
}
if (callId) {
out.push({
type: "function_call_output",
call_id: callId,
output: result.text,
});
} else if (result.text.trim().length > 0) {
// Orphaned tool result (no preceding function_call in this
// transcript): render its content as plain context instead of an
// unmatched function_call_output that the backend would 400 on.
out.push({
type: "message",
role: "user",
content: [{ type: "input_text", text: result.text }],
});
}
}
continue;
}
if (message.role === "assistant") {
const text = contentToText(message.content);
if (text.length > 0) {
lastText = text;
out.push({ type: "message", role: "assistant", content: [{ type: "output_text", text }] });
}
// Tool calls arrive either as a structured `toolCalls` field or as
// `tool-call` content parts (what the planner renders). Emit both,
// deduped by id, so the assistant's prior calls are preserved and the
// following tool results have a real function_call to pair with.
const seen = new Set<string>();
const toolCalls = [
...(message.toolCalls ?? []).map((toolCall) => ({
id: toolCall.id,
name: toolCall.name,
arguments:
typeof toolCall.arguments === "string"
? toolCall.arguments
: JSON.stringify(toolCall.arguments ?? {}),
})),
...toolCallPartsFromContent(message.content),
];
for (const toolCall of toolCalls) {
if (!toolCall.id || seen.has(toolCall.id)) continue;
seen.add(toolCall.id);
out.push({
type: "function_call",
call_id: toolCall.id,
name: toolCall.name,
arguments: toolCall.arguments,
});
pendingCallIds.push(toolCall.id);
}
continue;
}
const text = contentToText(message.content);
lastText = text;
out.push({ type: "message", role: "user", content: [{ type: "input_text", text }] });
}
if (prompt.trim().length > 0 && prompt !== lastText) {
out.push({ type: "message", role: "user", content: [{ type: "input_text", text: prompt }] });
}
return out;
}
function extractSystemPrompt(messages?: ChatMessage[]): string | undefined {
const parts = messages
?.filter((message) => message.role === "system" || message.role === "developer")
.map((message) => contentToText(message.content))
.filter(Boolean);
return parts && parts.length > 0 ? parts.join("\n\n") : undefined;
}
function contentToText(content: ChatMessage["content"]): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((part) => (part.type === "text" && typeof part.text === "string" ? part.text : ""))
.filter(Boolean)
.join("\n");
}
/** Flatten a tool-result part's `output`/`result` into a single string. */
function toolOutputText(output: unknown): string {
if (output == null) return "";
if (typeof output === "string") return output;
if (Array.isArray(output)) return contentToText(output as ChatMessage["content"]);
if (isRecord(output)) {
if (typeof output.value === "string") return output.value;
if (typeof output.text === "string") return output.text;
return JSON.stringify(output);
}
return String(output);
}
/** Extract `tool-result` content parts (toolCallId + flattened output text). */
function toolResultPartsFromContent(
content: ChatMessage["content"],
): Array<{ toolCallId?: string; text: string }> {
if (!Array.isArray(content)) return [];
const results: Array<{ toolCallId?: string; text: string }> = [];
for (const part of content) {
if (!isRecord(part) || part.type !== "tool-result") continue;
results.push({
toolCallId: typeof part.toolCallId === "string" ? part.toolCallId : undefined,
text: toolOutputText(part.output ?? part.result),
});
}
return results;
}
/** Extract `tool-call` content parts as codex function-call descriptors. */
function toolCallPartsFromContent(
content: ChatMessage["content"],
): Array<{ id: string; name: string; arguments: string }> {
if (!Array.isArray(content)) return [];
const calls: Array<{ id: string; name: string; arguments: string }> = [];
for (const part of content) {
if (!isRecord(part) || part.type !== "tool-call") continue;
const argSource = part.input ?? part.args ?? {};
calls.push({
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
name: typeof part.toolName === "string" ? part.toolName : "",
arguments: typeof argSource === "string" ? argSource : JSON.stringify(argSource ?? {}),
});
}
return calls;
}
interface ActiveFunctionCall {
id: string;
name: string;
args: string;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function stringProperty(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;
}
function recordProperty(record: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
const value = record[key];
return isRecord(value) ? value : undefined;
}
function isJsonRecord(value: unknown): value is Record<string, JsonValue> {
if (!isRecord(value)) return false;
return Object.values(value).every(isJsonValue);
}
function isJsonValue(value: unknown): value is JsonValue {
if (value === null) return true;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return true;
if (Array.isArray(value)) return value.every(isJsonValue);
return isJsonRecord(value);
}
async function consumeResponseStream(
body: ReadableStream<Uint8Array>,
abortSignal?: AbortSignal,
onTextDelta?: (delta: string) => void
): Promise<CodexGenerateResult> {
let text = "";
const toolCalls: ToolCall[] = [];
const activeByItemId = new Map<string, ActiveFunctionCall>();
let finishReason: string | undefined;
let usage: CodexGenerateResult["usage"] | undefined;
let failed: { code?: string; message?: string } | null = null;
const iter = parseSSE(body);
let abortPromise: Promise<never> | null = null;
let onAbort: (() => void) | null = null;
if (abortSignal) {
abortPromise = new Promise<never>((_, reject) => {
onAbort = () => reject(new Error("codex stream aborted"));
abortSignal.addEventListener("abort", onAbort, { once: true });
});
}
try {
while (true) {
if (abortSignal?.aborted) throw new Error("codex stream aborted");
const next = abortPromise ? await Promise.race([iter.next(), abortPromise]) : await iter.next();
if (next.done) break;
if (!next.value.data) continue;
let payload: unknown;
try {
payload = JSON.parse(next.value.data);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const preview = next.value.data.slice(0, 200);
logger.debug(
`[codex-cli] dropped malformed SSE event: ${message} preview=${preview}`,
);
continue;
}
const payloadRecord = isRecord(payload) ? payload : {};
const evType = next.value.event ?? stringProperty(payloadRecord, "type") ?? "";
switch (evType) {
case "response.output_text.delta": {
const delta = payloadRecord.delta;
if (typeof delta === "string") {
text += delta;
onTextDelta?.(delta);
}
break;
}
case "response.output_item.added": {
const item = recordProperty(payloadRecord, "item");
if (item?.type === "function_call") {
const itemId = stringProperty(item, "id") ?? stringProperty(item, "call_id");
const callId = stringProperty(item, "call_id");
const name = stringProperty(item, "name");
if (itemId && callId && name) {
activeByItemId.set(itemId, { id: callId, name, args: "" });
}
}
break;
}
case "response.function_call_arguments.delta": {
const itemId = stringProperty(payloadRecord, "item_id");
const delta = payloadRecord.delta;
const call = itemId ? activeByItemId.get(itemId) : undefined;
if (call && typeof delta === "string") call.args += delta;
break;
}
case "response.output_item.done": {
const item = recordProperty(payloadRecord, "item");
if (item?.type === "function_call") {
const itemId = stringProperty(item, "id") ?? stringProperty(item, "call_id");
const call = itemId ? activeByItemId.get(itemId) : undefined;
if (call) {
const argStr = stringProperty(item, "arguments") ?? call.args;
let parsed: Record<string, JsonValue> | string = argStr;
try {
if (argStr) {
const parsedJson: unknown = JSON.parse(argStr);
parsed = isJsonRecord(parsedJson) ? parsedJson : argStr;
} else {
parsed = {};
}
} catch {
// keep raw string
}
toolCalls.push({ id: call.id, name: call.name, arguments: parsed, type: "function" });
if (itemId) activeByItemId.delete(itemId);
}
}
break;
}
case "response.completed": {
const resp = recordProperty(payloadRecord, "response");
const stopReason = resp ? resp.stop_reason : undefined;
if (stopReason) finishReason = String(stopReason);
const respUsage = resp ? recordProperty(resp, "usage") : undefined;
if (respUsage) {
const inputTokens = numOrZero(respUsage.input_tokens);
const outputTokens = numOrZero(respUsage.output_tokens);
usage = { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens };
}
return { text, toolCalls, finishReason, usage };
}
case "response.failed": {
const resp = recordProperty(payloadRecord, "response");
const error = resp ? recordProperty(resp, "error") : undefined;
failed = {
code: error ? stringProperty(error, "code") : undefined,
message: error ? stringProperty(error, "message") : undefined,
};
throw new Error(`codex response.failed: ${failed.code ?? "unknown"} ${failed.message ?? ""}`.trim());
}
default:
break;
}
}
} finally {
if (abortSignal && onAbort) abortSignal.removeEventListener("abort", onAbort);
void iter.return?.(undefined).catch(() => {});
if (!body.locked) void body.cancel().catch(() => {});
}
logger.warn(
`[codex-cli] SSE stream ended without response.completed; returning partial result (text=${text.length} toolCalls=${toolCalls.length} finishReason=${finishReason})`,
);
return { text, toolCalls, finishReason, usage };
}
function stripTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}
function validateBaseUrl(value: string): string {
const url = new URL(value);
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
if (url.hostname === "chatgpt.com" && url.protocol === "https:") return value;
if (localHosts.has(url.hostname) && (url.protocol === "http:" || url.protocol === "https:")) return value;
throw new Error("CODEX_BASE_URL may only target https://chatgpt.com or localhost to avoid OAuth token exfiltration");
}
function envInt(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined) return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
function numOrZero(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function isJsonResponse(responseFormat: CodexGenerateParams["responseFormat"]): boolean {
return responseFormat === "json_object" || (typeof responseFormat === "object" && responseFormat?.type === "json_object");
}
function toCodexToolChoice(
toolChoice: CodexGenerateParams["toolChoice"]
): CodexResponseBody["tool_choice"] | undefined {
if (!toolChoice) return undefined;
if (toolChoice === "auto" || toolChoice === "none" || toolChoice === "required") return toolChoice;
if ("name" in toolChoice) return { type: "function", name: toolChoice.name };
return { type: "function", name: toolChoice.function.name };
}
async function safeReadText(res: Response): Promise<string> {
try {
return await res.text();
} catch {
return "";
}
}
@@ -0,0 +1,88 @@
/** Spec-compliant enough SSE parser for ChatGPT Codex response streams. */
export interface SSEEvent {
event?: string;
data: string;
id?: string;
retry?: number;
}
export async function* parseSSE(stream: ReadableStream<Uint8Array>): AsyncGenerator<SSEEvent> {
const decoder = new TextDecoder();
const reader = stream.getReader();
let buffer = "";
let eventName: string | undefined;
let eventId: string | undefined;
let retry: number | undefined;
let dataLines: string[] = [];
const emit = (): SSEEvent | null => {
if (dataLines.length === 0 && eventName === undefined && eventId === undefined && retry === undefined) {
return null;
}
const event: SSEEvent = { data: dataLines.join("\n") };
if (eventName !== undefined) event.event = eventName;
if (eventId !== undefined) event.id = eventId;
if (retry !== undefined) event.retry = retry;
eventName = undefined;
retry = undefined;
dataLines = [];
return event;
};
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
while (true) {
const nl = buffer.search(/\r\n|\r|\n/);
if (nl === -1) break;
const line = buffer.slice(0, nl);
const nextChar = buffer[nl] === "\r" && buffer[nl + 1] === "\n" ? 2 : 1;
buffer = buffer.slice(nl + nextChar);
if (line === "") {
const event = emit();
if (event) yield event;
continue;
}
if (line.startsWith(":")) continue;
const colon = line.indexOf(":");
const field = colon === -1 ? line : line.slice(0, colon);
let valueText = colon === -1 ? "" : line.slice(colon + 1);
if (valueText.startsWith(" ")) valueText = valueText.slice(1);
if (field === "event") eventName = valueText;
else if (field === "data") dataLines.push(valueText);
else if (field === "id") eventId = valueText;
else if (field === "retry") {
const parsed = Number.parseInt(valueText, 10);
if (Number.isFinite(parsed)) retry = parsed;
}
}
}
buffer += decoder.decode();
if (buffer.length > 0) {
for (const line of buffer.split(/\r\n|\r|\n/)) {
if (line === "") {
const event = emit();
if (event) yield event;
} else if (!line.startsWith(":")) {
const colon = line.indexOf(":");
const field = colon === -1 ? line : line.slice(0, colon);
let valueText = colon === -1 ? "" : line.slice(colon + 1);
if (valueText.startsWith(" ")) valueText = valueText.slice(1);
if (field === "event") eventName = valueText;
else if (field === "data") dataLines.push(valueText);
else if (field === "id") eventId = valueText;
}
}
}
const event = emit();
if (event) yield event;
} finally {
reader.releaseLock();
}
}
@@ -0,0 +1,88 @@
/**
* Maps elizaOS ToolDefinition objects to the OpenAI Responses function-tool
* shape. Strict tools are normalized to satisfy the codex backend's schema
* rules (see makeStrictSchema below); loose tools pass through verbatim.
*/
import type { ToolDefinition } from "@elizaos/core";
export interface OpenAITool {
type: "function";
name: string;
description: string;
parameters: object;
strict?: boolean;
}
type JsonSchema = Record<string, unknown>;
/**
* The ChatGPT codex backend enforces OpenAI strict-function-schema rules whenever
* a tool is marked `strict: true`: every object schema MUST set
* `additionalProperties: false` and list ALL of its `properties` keys in
* `required`. elizaOS action tools (core's to-tool.ts) set `strict: true` but
* author schemas with partial `required` and no `additionalProperties`, so the
* backend rejects them with 400 "Invalid schema for function '<name>'". Other
* providers tolerate this; the codex backend does not.
*
* `makeStrictSchema` normalizes a schema to be strict-compliant: `required`
* becomes every property key, `additionalProperties` becomes false, and keys that
* were NOT originally required are made nullable (a `null` union member) so the
* now-required keys keep their optional semantics — the model may emit `null`
* instead of being forced to invent a value.
*/
function makeNullable(schema: unknown): unknown {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
const s = schema as JsonSchema;
if (Array.isArray(s.type)) {
return (s.type as unknown[]).includes("null")
? s
: { ...s, type: [...(s.type as unknown[]), "null"] };
}
if (typeof s.type === "string") return { ...s, type: [s.type, "null"] };
return s;
}
function makeStrictSchema(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) return schema.map(makeStrictSchema);
const out = { ...(schema as JsonSchema) };
if (out.type === "object" && out.properties && typeof out.properties === "object") {
const props = out.properties as JsonSchema;
const keys = Object.keys(props);
const origRequired = new Set(
Array.isArray(out.required) ? (out.required as string[]) : [],
);
const next: JsonSchema = {};
for (const key of keys) {
let prop = makeStrictSchema(props[key]);
if (!origRequired.has(key)) prop = makeNullable(prop);
next[key] = prop;
}
out.properties = next;
out.required = keys;
out.additionalProperties = false;
}
if (out.items) out.items = makeStrictSchema(out.items);
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
if (Array.isArray(out[key])) out[key] = (out[key] as unknown[]).map(makeStrictSchema);
}
return out;
}
export function toOpenAITool(tool: ToolDefinition): OpenAITool {
const strict = tool.strict ?? false;
const parameters = (tool.parameters ?? { type: "object", properties: {} }) as object;
return {
type: "function",
name: tool.name,
description: tool.description ?? "",
// Only strict tools need normalization; the backend accepts loose schemas
// verbatim when strict is false.
parameters: strict ? (makeStrictSchema(parameters) as object) : parameters,
strict,
};
}
export function toOpenAITools(tools: ToolDefinition[]): OpenAITool[] {
return tools.map(toOpenAITool);
}