codex-proxy / src /proxy /codex-api.ts
icebear0828
feat: reactive API config, live code examples, quota display & curl fix
0fedcca
raw
history blame
7.83 kB
/**
* CodexApi — client for the Codex Responses API.
*
* Endpoint: POST /backend-api/codex/responses
* This is the API the Codex CLI actually uses.
* It requires: instructions, store: false, stream: true.
*/
import { execFile } from "child_process";
import { getConfig } from "../config.js";
import {
buildHeaders,
buildHeadersWithContentType,
} from "../fingerprint/manager.js";
import type { CookieJar } from "./cookie-jar.js";
export interface CodexResponsesRequest {
model: string;
instructions: string;
input: CodexInputItem[];
stream: true;
store: false;
/** Optional: reasoning effort level */
reasoning?: { effort: string };
/** Optional: tools available to the model */
tools?: unknown[];
/** Optional: previous response ID for multi-turn */
previous_response_id?: string | null;
}
export type CodexInputItem =
| { role: "user"; content: string }
| { role: "assistant"; content: string }
| { role: "system"; content: string };
/** Parsed SSE event from the Codex Responses stream */
export interface CodexSSEEvent {
event: string;
data: unknown;
}
export class CodexApi {
private token: string;
private accountId: string | null;
private cookieJar: CookieJar | null;
private entryId: string | null;
constructor(
token: string,
accountId: string | null,
cookieJar?: CookieJar | null,
entryId?: string | null,
) {
this.token = token;
this.accountId = accountId;
this.cookieJar = cookieJar ?? null;
this.entryId = entryId ?? null;
}
setToken(token: string): void {
this.token = token;
}
/** Build headers with cookies injected. */
private applyHeaders(headers: Record<string, string>): Record<string, string> {
if (this.cookieJar && this.entryId) {
const cookie = this.cookieJar.getCookieHeader(this.entryId);
if (cookie) headers["Cookie"] = cookie;
}
return headers;
}
/** Capture Set-Cookie headers from a response into the jar. */
private captureCookies(response: Response): void {
if (this.cookieJar && this.entryId) {
this.cookieJar.capture(this.entryId, response);
}
}
/**
* Query official Codex usage/quota.
* GET /backend-api/codex/usage
*
* Uses curl subprocess instead of Node.js fetch because Cloudflare
* fingerprints the TLS handshake and blocks Node.js/undici requests
* with a JS challenge (403). System curl uses native TLS (WinSSL/SecureTransport)
* which Cloudflare accepts.
*/
async getUsage(): Promise<CodexUsageResponse> {
const config = getConfig();
const url = `${config.api.base_url}/codex/usage`;
const headers = this.applyHeaders(
buildHeaders(this.token, this.accountId),
);
headers["Accept"] = "application/json";
// Remove Accept-Encoding — let curl negotiate its own supported encodings
// via --compressed. Passing unsupported encodings (br, zstd) causes curl
// to fail when it can't decompress the response.
delete headers["Accept-Encoding"];
// Build curl args
const args = ["-s", "--compressed", "--max-time", "15"];
for (const [key, value] of Object.entries(headers)) {
args.push("-H", `${key}: ${value}`);
}
args.push(url);
const body = await new Promise<string>((resolve, reject) => {
execFile("curl", args, { maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
if (err) {
reject(new CodexApiError(0, `curl failed: ${err.message} ${stderr}`));
} else {
resolve(stdout);
}
});
});
try {
const parsed = JSON.parse(body) as CodexUsageResponse;
// Validate we got actual usage data (not an error page)
if (!parsed.rate_limit) {
throw new CodexApiError(502, `Unexpected response: ${body.slice(0, 200)}`);
}
return parsed;
} catch (e) {
if (e instanceof CodexApiError) throw e;
throw new CodexApiError(502, `Invalid JSON from /codex/usage: ${body.slice(0, 200)}`);
}
}
/**
* Create a response (streaming).
* Returns the raw Response so the caller can process the SSE stream.
*/
async createResponse(
request: CodexResponsesRequest,
signal?: AbortSignal,
): Promise<Response> {
const config = getConfig();
const baseUrl = config.api.base_url; // https://chatgpt.com/backend-api
const url = `${baseUrl}/codex/responses`;
const headers = this.applyHeaders(
buildHeadersWithContentType(this.token, this.accountId),
);
headers["Accept"] = "text/event-stream";
const timeout = config.api.timeout_seconds * 1000;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const mergedSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
const res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(request),
signal: mergedSignal,
}).finally(() => clearTimeout(timer));
this.captureCookies(res);
if (!res.ok) {
let errorBody: string;
try {
errorBody = await res.text();
} catch {
errorBody = `HTTP ${res.status}`;
}
throw new CodexApiError(res.status, errorBody);
}
return res;
}
/**
* Parse SSE stream from a Codex Responses API response.
* Yields individual events.
*/
async *parseStream(
response: Response,
): AsyncGenerator<CodexSSEEvent> {
if (!response.body) {
throw new Error("Response body is null — cannot stream");
}
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += value;
const parts = buffer.split("\n\n");
buffer = parts.pop()!;
for (const part of parts) {
if (!part.trim()) continue;
const evt = this.parseSSEBlock(part);
if (evt) yield evt;
}
}
// Process remaining buffer
if (buffer.trim()) {
const evt = this.parseSSEBlock(buffer);
if (evt) yield evt;
}
} finally {
reader.releaseLock();
}
}
private parseSSEBlock(block: string): CodexSSEEvent | null {
let event = "";
const dataLines: string[] = [];
for (const line of block.split("\n")) {
if (line.startsWith("event:")) {
event = line.slice(6).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice(5).trimStart());
}
}
if (!event && dataLines.length === 0) return null;
const raw = dataLines.join("\n");
if (raw === "[DONE]") return null;
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
data = raw;
}
return { event, data };
}
}
/** Response from GET /backend-api/codex/usage */
export interface CodexUsageRateWindow {
used_percent: number;
limit_window_seconds: number;
reset_after_seconds: number;
reset_at: number;
}
export interface CodexUsageRateLimit {
allowed: boolean;
limit_reached: boolean;
primary_window: CodexUsageRateWindow | null;
secondary_window: CodexUsageRateWindow | null;
}
export interface CodexUsageResponse {
plan_type: string;
rate_limit: CodexUsageRateLimit;
code_review_rate_limit: CodexUsageRateLimit | null;
credits: unknown;
promo: unknown;
}
export class CodexApiError extends Error {
constructor(
public readonly status: number,
public readonly body: string,
) {
let detail: string;
try {
const parsed = JSON.parse(body);
detail = parsed.detail ?? parsed.error?.message ?? body;
} catch {
detail = body;
}
super(`Codex API error (${status}): ${detail}`);
}
}