Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,7 @@ NEXT_PUBLIC_CLOUD_URL=https://9router.com

# Currently unused by application runtime (kept as reference)
# INSTANCE_NAME=9router

# Optional, explicit Codex image aliases (no remapping when unset).
# Choose a target your connected accounts can access. Restart after changing.
# CODEX_IMAGE_MODEL_ALIASES={"gpt-5.5-image":"gpt-5.6-luna-image"}
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,6 @@ graphify-out/*
.next-analyze/*

# Kiro local workspace state
.kiro/
.kiro/
# Public Codex compatibility and verification guide
!docs/CODEX_COMPATIBILITY.md
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# Unreleased

## Fixes
- **Codex**: align discovery, chat, images and credential probes on one client identity; preserve image SSE errors and handle incremental/data-only frames.
- **Codex**: keep model-access errors scoped to the affected account/model and stop account rotation for client-version failures.
- **Codex**: make quota auto-ping model selectable (Luna by default), require a completed response before recording success, and support explicit opt-in image aliases. See [compatibility notes](docs/CODEX_COMPATIBILITY.md).

# v0.5.69 (2026-09-05)

## Features
Expand Down
73 changes: 73 additions & 0 deletions docs/CODEX_COMPATIBILITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Codex compatibility and model health

9Router sends its own Codex client identity. Updating a caller's CLI does not update
these outbound headers. Discovery, chat, images and credential probes share
`CODEX_CLIENT_VERSION` and `CODEX_USER_AGENT` in `open-sse/config/codexConstants.js`.

A request rejected with “requires a newer version of Codex” needs a gateway update.
Rotating through accounts cannot repair it, so this error does not lock accounts.
A model-access 400/404 is different: availability can vary by account. The affected
account/model pair is temporarily locked using the existing fallback cooldown, and
another account can be tried. The dashboard shows a model warning while preserving
the account's connection status. Other models remain eligible on that account.
Authentication, quota and server errors retain their existing handling.

## Quota auto-ping

On the Codex provider page, select **Auto-ping model** to choose an accessible model.
The default is `gpt-5.6-luna` with low reasoning. The selection is stored in
`settings.codexAutoPing.model`; changing it preserves the existing per-account
opt-ins. Only opted-in accounts are pinged after a quota reset. A ping is recorded
as successful only after a completed Responses event, not merely HTTP 200.

## Optional image aliases

Prefer updating clients to request an accessible model directly. For clients that
cannot be updated immediately, operators can explicitly configure exact aliases:

```dotenv
CODEX_IMAGE_MODEL_ALIASES={"gpt-5.5-image":"gpt-5.6-luna-image"}
```

Restart 9Router after changing the environment. Aliases apply only to Codex image
requests, before account selection and cooldown tracking. Both sides are unprefixed
image IDs; aliases are single-hop, and unrelated models/providers are unchanged.
Invalid JSON or non-image IDs produce a configuration error before an upstream call.
The configured substitution is logged. The target must be accessible to the accounts
being used; an alias cannot grant model access. With the variable unset, requests
retain their original model. A 404 does not establish global model retirement.

Image streams preserve upstream errors, including failed/incomplete events inside
HTTP 200. JSON/binary clients receive the mapped failure status; SSE clients receive
an `error` event with message, status and code when supplied, without a successful
`done` event. A response without an image reports a neutral no-result error.

## Reproducible checks

```sh
npm install
npm install --prefix tests
npm test --prefix tests -- unit/codex-client-identity.test.js unit/codex-image-stream-errors.test.js unit/codex-image-framing.test.js unit/codex-model-health.test.js unit/codex-image-alias.test.js unit/quota-auto-ping.test.js unit/image-generation.test.js
npm run build
```

These tests use synthetic upstream responses. They cover framing, error propagation,
account/model isolation, opt-in aliasing, API-key rejection and quota-ping completion;
they do not claim availability of any model on every live account.

For an isolated HTTP and dashboard check (Node 22.13+), run the fixture server after
building, then the check script in another terminal:

```sh
node tests/fixtures/codex-http-server.cjs
node tests/fixtures/codex-http-check.mjs
```

Open `http://127.0.0.1:20139/dashboard/providers/codex`. Confirm the synthetic
account stays active with a separate model warning; select another auto-ping model
and reload to verify persistence. To check failed saves, create an empty file named
`fail-next-settings-save` under the printed `FIXTURE_DATA_DIR`, then change the model:
the selection should revert and an error should appear. The fixture binds only to
loopback, creates a fresh temporary database, and replaces all upstream fetches with
synthetic responses. It uses `fixture-key` for API requests; no real account is needed.
Stop with Ctrl-C and remove its printed temporary data directory when done.
21 changes: 21 additions & 0 deletions open-sse/config/codexConstants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Shared outbound identity for discovery, chat, images and connection probes.
export const CODEX_CLIENT_VERSION = "0.153.4";
export const CODEX_USER_AGENT = `codex_cli_rs/${CODEX_CLIENT_VERSION}`;

export const CODEX_IMAGE_NO_RESULT_ERROR = "Codex completed without returning an image.";
export const CODEX_IMAGE_ERROR_TEXT_LIMIT = 1000;

export const CODEX_AUTO_PING_MODEL = "gpt-5.6-luna";

// Explicit, image-only compatibility policy. No model is rewritten by default.
export function resolveCodexImageModel(model, raw = process.env.CODEX_IMAGE_MODEL_ALIASES) {
if (!raw) return model;
let aliases;
try { aliases = JSON.parse(raw); } catch { throw new Error("CODEX_IMAGE_MODEL_ALIASES must be a JSON object"); }
const validId = /^[a-zA-Z0-9][a-zA-Z0-9._-]*-image$/;
if (!aliases || Array.isArray(aliases) || typeof aliases !== "object" ||
Object.entries(aliases).some(([from, to]) => !validId.test(from) || typeof to !== "string" || !validId.test(to))) {
throw new Error("CODEX_IMAGE_MODEL_ALIASES must map image model IDs to image model IDs");
}
return Object.hasOwn(aliases, model) ? aliases[model] : model;
}
4 changes: 3 additions & 1 deletion open-sse/handlers/imageGenerationCore.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,9 @@ export async function handleImageGenerationCore({
parsed = await providerResponse.json();
}
} catch (parseError) {
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, parseError.message || `Invalid response from ${provider}`);
const status = Number.isInteger(parseError.statusCode) && parseError.statusCode >= 400 && parseError.statusCode <= 599
? parseError.statusCode : HTTP_STATUS.BAD_GATEWAY;
return createErrorResult(status, parseError.message || `Invalid response from ${provider}`);
}

if (onRequestSuccess) await onRequestSuccess();
Expand Down
107 changes: 46 additions & 61 deletions open-sse/handlers/imageProviders/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@
import { randomUUID } from "node:crypto";
import { nowSec } from "./_base.js";
import { PROVIDERS } from "../../config/providers.js";
import {
CODEX_CLIENT_VERSION,
CODEX_USER_AGENT,
CODEX_IMAGE_ERROR_TEXT_LIMIT,
CODEX_IMAGE_NO_RESULT_ERROR,
} from "../../config/codexConstants.js";

import { readCodexEvents, codexEventError } from "../../utils/codexSse.js";

const CODEX_RESPONSES_URL = PROVIDERS["codex"].baseUrl;
const CODEX_USER_AGENT = "codex_cli_rs/0.136.0";
const CODEX_VERSION = "0.136.0";
const CODEX_ORIGINATOR = "codex_cli_rs";
const CODEX_MODEL_SUFFIX = "-image";
const CODEX_REF_DETAIL = "high";
Expand Down Expand Up @@ -45,93 +51,72 @@ function buildContent(prompt, refs, detail = CODEX_REF_DETAIL) {
}

// Parse Codex SSE stream → final base64 image. Optional callbacks for client streaming.
async function parseStream(response, log, callbacks = {}) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
async function parseStream(response, log, callbacks = {}, signal) {
let imageB64 = null;
let outputText = "";
let lastEvent = null;
let bytesReceived = 0;
let lastProgressLogMs = 0;

while (true) {
const { done, value } = await reader.read();
if (done) break;
bytesReceived += value?.byteLength || 0;
buffer += decoder.decode(value, { stream: true });

let sepIdx;
while ((sepIdx = buffer.indexOf("\n\n")) !== -1) {
const block = buffer.slice(0, sepIdx);
buffer = buffer.slice(sepIdx + 2);

const lines = block.split("\n");
let eventName = null;
let dataStr = "";
for (const line of lines) {
if (line.startsWith("event:")) eventName = line.slice(6).trim();
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
}
if (!eventName) continue;
if (eventName !== lastEvent) {
log?.info?.("IMAGE", `codex progress: ${eventName}`);
lastEvent = eventName;
}

const now = Date.now();
if (callbacks.onProgress && now - lastProgressLogMs > 200) {
lastProgressLogMs = now;
callbacks.onProgress({ stage: eventName, bytesReceived });
}

if (eventName === "response.image_generation_call.partial_image" && dataStr) {
try {
const data = JSON.parse(dataStr);
if (callbacks.onPartialImage && data?.partial_image_b64) {
callbacks.onPartialImage({ b64_json: data.partial_image_b64, index: data.partial_image_index });
}
} catch {}
}

if (eventName === "response.output_item.done" && dataStr) {
try {
const data = JSON.parse(dataStr);
const item = data?.item;
if (item?.type === "image_generation_call" && item.result) {
imageB64 = item.result;
}
} catch {}
for await (const { event, data, bytesReceived } of readCodexEvents(response, signal)) {
const error = codexEventError(event, data);
if (error) throw error;
if (event !== lastEvent) {
log?.info?.("IMAGE", `codex progress: ${event}`);
lastEvent = event;
}
const now = Date.now();
if (callbacks.onProgress && now - lastProgressLogMs > 200) {
lastProgressLogMs = now;
callbacks.onProgress({ stage: event, bytesReceived });
}
if (event === "response.image_generation_call.partial_image" && data?.partial_image_b64) {
callbacks.onPartialImage?.({ b64_json: data.partial_image_b64, index: data.partial_image_index });
}
const items = event === "response.output_item.done" ? [data?.item] :
event === "response.completed" ? data?.response?.output || [] : [];
for (const item of items) {
if (item?.type === "image_generation_call" && item.result) imageB64 = item.result;
if (item?.type === "message" && Array.isArray(item.content)) {
for (const part of item.content) {
const text = part.refusal || part.text;
if (typeof text === "string") outputText = (outputText + " " + text).slice(0, CODEX_IMAGE_ERROR_TEXT_LIMIT);
}
}
}
}
if (!imageB64 && outputText) throw new Error(`${CODEX_IMAGE_NO_RESULT_ERROR} ${outputText.trim()}`);
return imageB64;
}

// SSE Response that pipes codex progress + partial + done events to client
function buildSseResponse(providerResponse, log, onSuccess) {
const abort = new AbortController();
let cancelled = false;
const stream = new ReadableStream({
async start(controller) {
const enc = new TextEncoder();
const send = (event, data) => {
if (cancelled) return;
controller.enqueue(enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
};
try {
const b64 = await parseStream(providerResponse, log, {
onProgress: (info) => send("progress", info),
onPartialImage: (info) => send("partial_image", info),
});
}, abort.signal);
if (cancelled) return;
if (!b64) {
send("error", { message: "Codex did not return an image. Account may not be entitled (Plus/Pro required)." });
send("error", { message: CODEX_IMAGE_NO_RESULT_ERROR, status: 502 });
} else {
if (onSuccess) await onSuccess();
send("done", { created: nowSec(), data: [{ b64_json: b64 }] });
}
} catch (err) {
send("error", { message: err?.message || "Stream failed" });
send("error", { message: err?.message || "Stream failed", status: err?.statusCode || 502, code: err?.code });
} finally {
controller.close();
if (!cancelled) controller.close();
}
},
cancel() { cancelled = true; abort.abort(); },
});
return new Response(stream, {
headers: {
Expand All @@ -157,7 +142,7 @@ export default {
"originator": CODEX_ORIGINATOR,
"session_id": randomUUID(),
"user-agent": CODEX_USER_AGENT,
"version": CODEX_VERSION,
"version": CODEX_CLIENT_VERSION,
"x-client-request-id": randomUUID(),
};
},
Expand Down Expand Up @@ -191,7 +176,7 @@ export default {
}
const b64 = await parseStream(response, log);
if (!b64) {
throw new Error("Codex did not return an image. Account may not be entitled (Plus/Pro required).");
throw new Error(CODEX_IMAGE_NO_RESULT_ERROR);
}
return { created: nowSec(), data: [{ b64_json: b64 }] };
},
Expand Down
4 changes: 3 additions & 1 deletion open-sse/providers/registry/codex.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CODEX_CLIENT_VERSION, CODEX_USER_AGENT } from "../../config/codexConstants.js";
import { withCodexReviewModels } from "../models/helpers.js";

export default {
Expand Down Expand Up @@ -36,7 +37,8 @@ export default {
forceStream: true,
headers: {
originator: "codex_cli_rs",
"User-Agent": "codex_cli_rs/0.136.0",
"User-Agent": CODEX_USER_AGENT,
version: CODEX_CLIENT_VERSION,
},
usage: {
url: "https://chatgpt.com/backend-api/wham/usage",
Expand Down
68 changes: 68 additions & 0 deletions open-sse/utils/codexSse.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { CODEX_IMAGE_ERROR_TEXT_LIMIT } from "../config/codexConstants.js";

export function isCodexClientVersionError(message) {
return /requires a newer version of Codex/i.test(String(message || ""));
}

export function isCodexModelAccessError(status, message) {
return [400, 404].includes(Number(status)) &&
/model_not_found|model.{0,200}(?:does not exist|not found|not supported|do not have access)/i.test(String(message || ""));
}

// HTTP 200 can still contain a failed Responses API event.
export function codexEventError(event, data) {
if (event !== "error" && event !== "response.failed" && event !== "response.incomplete" &&
!["failed", "incomplete"].includes(data?.response?.status)) return null;
const detail = data?.response?.error || data?.error;
const message = detail?.message || (typeof detail === "string" ? detail : null) ||
data?.message || data?.response?.incomplete_details?.reason || "Codex response failed.";
const error = new Error(String(message).slice(0, CODEX_IMAGE_ERROR_TEXT_LIMIT));
error.code = typeof detail?.code === "string" ? detail.code : undefined;
const explicitStatus = Number(detail?.status_code || data?.status_code);
error.statusCode = Number.isInteger(explicitStatus) && explicitStatus >= 400 && explicitStatus <= 599 ? explicitStatus :
error.code === "model_not_found" ? 404 :
["rate_limit_exceeded", "usage_limit_reached"].includes(error.code) ? 429 :
error.code === "invalid_api_key" ? 401 :
isCodexClientVersionError(error.message) ? 400 : 502;
return error;
}

// Incremental SSE framing shared by images and quota pings. Accept data-only events,
// CRLF and an EOF without a blank separator, including split UTF-8 sequences.
export async function* readCodexEvents(response, signal) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let finished = false;
let bytesReceived = 0;
const cancel = () => { reader.cancel().catch(() => {}); };
signal?.addEventListener("abort", cancel, { once: true });
try {
while (!finished) {
if (signal?.aborted) return;
const { done, value } = await reader.read();
finished = done;
bytesReceived += value?.byteLength || 0;
buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });
let separator;
while ((separator = /\r\n\r\n|\n\n|\r\r/.exec(buffer)) || (done && buffer)) {
const block = separator ? buffer.slice(0, separator.index) : buffer;
buffer = separator ? buffer.slice(separator.index + separator[0].length) : "";
let event = null;
const lines = [];
for (const line of block.split(/\r\n|\n|\r/)) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) lines.push(line.slice(5).trimStart());
}
let data;
try { data = JSON.parse(lines.join("\n")); } catch { /* Ignore keepalives and malformed frames. */ }
event ||= data?.type;
if (event) yield { event, data, bytesReceived };
}
}
} finally {
signal?.removeEventListener("abort", cancel);
if (!finished) await reader.cancel().catch(() => {});
reader.releaseLock();
}
}
Loading