mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 20:32:20 +03:00
The native Codex passthrough path returned early before injecting default instructions and enforcing store=false. Clients sending Responses API requests without instructions (e.g. opencode) got 400 "Instructions are required", and requests missing store=false got 400 "Store must be set to false" from the Codex upstream. Move both assignments before the passthrough return so they apply to all code paths.
324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
import { BaseExecutor } from "./base.ts";
|
|
import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.ts";
|
|
import { PROVIDERS } from "../config/constants.ts";
|
|
import { refreshCodexToken } from "../services/tokenRefresh.ts";
|
|
|
|
// ─── T09: Codex vs Spark Scope-Aware Rate Limiting ────────────────────────
|
|
// Codex has two independent quota pools: "codex" (standard) and "spark" (premium).
|
|
// Exhausting one should NOT block requests to the other.
|
|
// Ref: sub2api PR #1129 (feat(openai): split codex spark rate limiting from codex)
|
|
|
|
/**
|
|
* Maps model name substrings to their rate-limit scope.
|
|
* Checked in order — first match wins.
|
|
*/
|
|
const CODEX_SCOPE_PATTERNS: Array<{ pattern: string; scope: "codex" | "spark" }> = [
|
|
{ pattern: "codex-spark", scope: "spark" },
|
|
{ pattern: "spark", scope: "spark" },
|
|
{ pattern: "codex", scope: "codex" },
|
|
{ pattern: "gpt-5", scope: "codex" }, // gpt-5.2-codex, gpt-5.3-codex, etc.
|
|
];
|
|
|
|
/**
|
|
* T09: Determine the rate-limit scope for a Codex model.
|
|
* Use this key as the suffix for per-scope rate limit state:
|
|
* `${accountId}:${getModelScope(model)}`
|
|
*
|
|
* @param model - The Codex model ID (e.g. "gpt-5.3-codex", "codex-spark-mini")
|
|
* @returns "codex" | "spark"
|
|
*/
|
|
export function getCodexModelScope(model: string): "codex" | "spark" {
|
|
const lower = model.toLowerCase();
|
|
for (const { pattern, scope } of CODEX_SCOPE_PATTERNS) {
|
|
if (lower.includes(pattern)) return scope;
|
|
}
|
|
return "codex"; // default scope
|
|
}
|
|
|
|
/**
|
|
* T09: Get the scope-keyed rate limit identifier for an account+model combination.
|
|
* Use this as the key for rateLimitState maps to ensure scope isolation.
|
|
*/
|
|
export function getCodexRateLimitKey(accountId: string, model: string): string {
|
|
return `${accountId}:${getCodexModelScope(model)}`;
|
|
}
|
|
|
|
/**
|
|
* T03: Parsed quota snapshot from Codex response headers.
|
|
* Codex includes per-account usage windows that allow precise reset scheduling.
|
|
* Ref: sub2api PR #357 (feat(oauth): persist usage snapshots and window cooldown)
|
|
*/
|
|
export interface CodexQuotaSnapshot {
|
|
usage5h: number; // tokens used in 5h window
|
|
limit5h: number; // token limit for 5h window
|
|
resetAt5h: string | null; // ISO timestamp when 5h window resets
|
|
usage7d: number; // tokens used in 7d window
|
|
limit7d: number; // token limit for 7d window
|
|
resetAt7d: string | null; // ISO timestamp when 7d window resets
|
|
}
|
|
|
|
/**
|
|
* T03: Parse Codex-specific quota headers from a provider response.
|
|
* Returns null if none of the relevant headers are present.
|
|
*
|
|
* Extracts:
|
|
* x-codex-5h-usage / x-codex-5h-limit / x-codex-5h-reset-at
|
|
* x-codex-7d-usage / x-codex-7d-limit / x-codex-7d-reset-at
|
|
*/
|
|
export function parseCodexQuotaHeaders(headers: Headers): CodexQuotaSnapshot | null {
|
|
const usage5h = headers.get("x-codex-5h-usage");
|
|
const limit5h = headers.get("x-codex-5h-limit");
|
|
const resetAt5h = headers.get("x-codex-5h-reset-at");
|
|
const usage7d = headers.get("x-codex-7d-usage");
|
|
const limit7d = headers.get("x-codex-7d-limit");
|
|
const resetAt7d = headers.get("x-codex-7d-reset-at");
|
|
|
|
// Return null if none of the quota headers are present (not a quota-aware response)
|
|
if (!usage5h && !limit5h && !resetAt5h && !usage7d && !limit7d && !resetAt7d) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
usage5h: usage5h ? parseFloat(usage5h) : 0,
|
|
limit5h: limit5h ? parseFloat(limit5h) : Infinity,
|
|
resetAt5h: resetAt5h ?? null,
|
|
usage7d: usage7d ? parseFloat(usage7d) : 0,
|
|
limit7d: limit7d ? parseFloat(limit7d) : Infinity,
|
|
resetAt7d: resetAt7d ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* T03: Get the soonest quota reset time from a CodexQuotaSnapshot.
|
|
* 7d window takes priority (wider window, harder limit) but we use whichever
|
|
* is further in the future to avoid releasing the block too early.
|
|
*
|
|
* @returns Unix timestamp (ms) of the soonest effective reset, or null
|
|
*/
|
|
export function getCodexResetTime(quota: CodexQuotaSnapshot): number | null {
|
|
const times: number[] = [];
|
|
if (quota.resetAt7d) {
|
|
const t = new Date(quota.resetAt7d).getTime();
|
|
if (!isNaN(t) && t > Date.now()) times.push(t);
|
|
}
|
|
if (quota.resetAt5h) {
|
|
const t = new Date(quota.resetAt5h).getTime();
|
|
if (!isNaN(t) && t > Date.now()) times.push(t);
|
|
}
|
|
if (times.length === 0) return null;
|
|
return Math.max(...times); // Use furthest-out reset to avoid premature unblock
|
|
}
|
|
|
|
// Ordered list of effort levels from lowest to highest
|
|
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const;
|
|
type EffortLevel = (typeof EFFORT_ORDER)[number];
|
|
const CODEX_FAST_WIRE_VALUE = "priority";
|
|
let defaultFastServiceTierEnabled = false;
|
|
|
|
function getResponsesSubpath(endpointPath: unknown): string | null {
|
|
const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, "");
|
|
const match = normalizedEndpoint.match(/(?:^|\/)responses(?:(\/.*))?$/i);
|
|
if (!match) return null;
|
|
return match[1] || "";
|
|
}
|
|
|
|
function isCompactResponsesEndpoint(endpointPath: unknown): boolean {
|
|
return getResponsesSubpath(endpointPath)?.toLowerCase() === "/compact";
|
|
}
|
|
|
|
function normalizeServiceTierValue(value: unknown): string | undefined {
|
|
if (typeof value !== "string") return undefined;
|
|
const normalized = value.trim().toLowerCase();
|
|
if (!normalized) return undefined;
|
|
if (normalized === "fast") return CODEX_FAST_WIRE_VALUE;
|
|
return normalized;
|
|
}
|
|
|
|
export function setDefaultFastServiceTierEnabled(enabled: boolean): void {
|
|
defaultFastServiceTierEnabled = enabled;
|
|
}
|
|
|
|
/**
|
|
* Maximum reasoning effort allowed per Codex model.
|
|
* Models not listed here default to "xhigh" (unrestricted).
|
|
* Update this table when Codex releases new models with different caps.
|
|
*/
|
|
const MAX_EFFORT_BY_MODEL: Record<string, EffortLevel> = {
|
|
"gpt-5.3-codex": "xhigh",
|
|
"gpt-5.2-codex": "xhigh",
|
|
"gpt-5.1-codex-max": "xhigh",
|
|
"gpt-5-mini": "high",
|
|
"gpt-5.1-mini": "high",
|
|
"gpt-4.1-mini": "high",
|
|
};
|
|
|
|
/**
|
|
* Clamp reasoning effort to the model's maximum allowed level.
|
|
* Returns the original value if within limits, or the cap if it exceeds it.
|
|
*/
|
|
function clampEffort(model: string, requested: string): string {
|
|
const max: EffortLevel = MAX_EFFORT_BY_MODEL[model] ?? "xhigh";
|
|
const reqIdx = EFFORT_ORDER.indexOf(requested as EffortLevel);
|
|
const maxIdx = EFFORT_ORDER.indexOf(max);
|
|
if (reqIdx > maxIdx) {
|
|
console.debug(`[Codex] clampEffort: "${requested}" → "${max}" (model: ${model})`);
|
|
return max;
|
|
}
|
|
return requested;
|
|
}
|
|
|
|
/**
|
|
* Codex Executor - handles OpenAI Codex API (Responses API format)
|
|
* Automatically injects default instructions if missing.
|
|
* IMPORTANT: Includes chatgpt-account-id header for workspace binding.
|
|
*/
|
|
export class CodexExecutor extends BaseExecutor {
|
|
constructor() {
|
|
super("codex", PROVIDERS.codex);
|
|
}
|
|
|
|
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
|
void model;
|
|
void stream;
|
|
void urlIndex;
|
|
|
|
const responsesSubpath = getResponsesSubpath(credentials?.requestEndpointPath);
|
|
if (responsesSubpath !== null) {
|
|
const baseUrl = String(this.config.baseUrl || "").replace(/\/$/, "");
|
|
if (baseUrl.endsWith("/responses")) {
|
|
return `${baseUrl}${responsesSubpath}`;
|
|
}
|
|
return `${baseUrl}/responses${responsesSubpath}`;
|
|
}
|
|
|
|
return super.buildUrl(model, stream, urlIndex, credentials);
|
|
}
|
|
|
|
/**
|
|
* Codex Responses endpoint is SSE-first.
|
|
* Always request event-stream from upstream, even when client requested stream=false.
|
|
* Includes chatgpt-account-id header for strict workspace binding.
|
|
*/
|
|
buildHeaders(credentials, stream = true) {
|
|
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
|
|
const headers = super.buildHeaders(credentials, isCompactRequest ? false : true);
|
|
|
|
// Add workspace binding header if workspaceId is persisted
|
|
const workspaceId = credentials?.providerSpecificData?.workspaceId;
|
|
if (workspaceId) {
|
|
headers["chatgpt-account-id"] = workspaceId;
|
|
}
|
|
|
|
return headers;
|
|
}
|
|
|
|
/**
|
|
* Refresh Codex OAuth credentials when a 401 is received.
|
|
* OpenAI uses rotating (one-time-use) refresh tokens — if the token was already
|
|
* consumed by a concurrent refresh, this returns null to signal re-auth is needed.
|
|
*
|
|
* Fixes #251: After a server restart/upgrade, previously cached access tokens may
|
|
* have expired or become invalid. chatCore.ts calls this on 401; previously the
|
|
* base class returned null causing the request to fail instead of refreshing.
|
|
*/
|
|
async refreshCredentials(credentials, log) {
|
|
if (!credentials?.refreshToken) {
|
|
log?.warn?.("TOKEN_REFRESH", "Codex: no refresh token available, re-authentication required");
|
|
return null;
|
|
}
|
|
const result = await refreshCodexToken(credentials.refreshToken, log);
|
|
if (!result || result.error) {
|
|
log?.warn?.(
|
|
"TOKEN_REFRESH",
|
|
`Codex: token refresh failed${result?.error ? ` (${result.error})` : ""} — re-authentication required`
|
|
);
|
|
return null;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Transform request before sending - inject default instructions if missing
|
|
*/
|
|
transformRequest(model, body, stream, credentials) {
|
|
const nativeCodexPassthrough = body?._nativeCodexPassthrough === true;
|
|
const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath);
|
|
|
|
// Codex /responses rejects stream=false, but /responses/compact rejects the stream field entirely.
|
|
if (isCompactRequest) {
|
|
delete body.stream;
|
|
delete body.stream_options;
|
|
} else {
|
|
body.stream = true;
|
|
}
|
|
delete body._nativeCodexPassthrough;
|
|
|
|
const requestServiceTier = normalizeServiceTierValue(body.service_tier);
|
|
if (requestServiceTier) {
|
|
body.service_tier = requestServiceTier;
|
|
} else if (defaultFastServiceTierEnabled) {
|
|
body.service_tier = CODEX_FAST_WIRE_VALUE;
|
|
}
|
|
|
|
// If no instructions provided, inject default Codex instructions
|
|
// NOTE: must run before the passthrough return — Codex upstream rejects
|
|
// requests without instructions even when the body is forwarded as-is.
|
|
if (!body.instructions || body.instructions.trim() === "") {
|
|
body.instructions = CODEX_DEFAULT_INSTRUCTIONS;
|
|
}
|
|
|
|
// Ensure store is false (Codex requirement)
|
|
body.store = false;
|
|
|
|
if (nativeCodexPassthrough) {
|
|
return body;
|
|
}
|
|
|
|
// Extract thinking level from model name suffix
|
|
// e.g., gpt-5.3-codex-high → high, gpt-5.3-codex → medium (default)
|
|
const effortLevels = ["none", "low", "medium", "high", "xhigh"];
|
|
let modelEffort: string | null = null;
|
|
// Track the clean model name (suffix stripped) for clamp lookup
|
|
let cleanModel = model;
|
|
for (const level of effortLevels) {
|
|
if (model.endsWith(`-${level}`)) {
|
|
modelEffort = level;
|
|
// Strip suffix from model name for actual API call
|
|
body.model = body.model.replace(`-${level}`, "");
|
|
cleanModel = body.model;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium)
|
|
if (!body.reasoning) {
|
|
const rawEffort = body.reasoning_effort || modelEffort || "medium";
|
|
// Clamp effort to the model's maximum allowed level (feature-07)
|
|
const effort = clampEffort(cleanModel, rawEffort);
|
|
body.reasoning = { effort };
|
|
} else if (body.reasoning.effort) {
|
|
// Also clamp if reasoning object was provided directly
|
|
body.reasoning.effort = clampEffort(cleanModel, body.reasoning.effort);
|
|
}
|
|
delete body.reasoning_effort;
|
|
|
|
// Remove unsupported parameters for Codex API
|
|
delete body.temperature;
|
|
delete body.top_p;
|
|
delete body.frequency_penalty;
|
|
delete body.presence_penalty;
|
|
delete body.logprobs;
|
|
delete body.top_logprobs;
|
|
delete body.n;
|
|
delete body.seed;
|
|
delete body.max_tokens;
|
|
delete body.user; // Cursor sends this but Codex doesn't support it
|
|
delete body.prompt_cache_retention; // Cursor sends this but Codex doesn't support it
|
|
delete body.metadata; // Cursor sends this but Codex doesn't support it
|
|
delete body.stream_options; // Cursor sends this but Codex doesn't support it
|
|
delete body.safety_identifier; // Droid CLI sends this but Codex doesn't support it
|
|
|
|
return body;
|
|
}
|
|
}
|