mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
T01 (P1): requested_model column in call_logs - Migration 009_requested_model.sql: ALTER TABLE call_logs ADD COLUMN requested_model - callLogs.ts: INSERT + SELECT updated to include requestedModel field T02 (P1): Strip empty text blocks from nested tool_result.content - New stripEmptyTextBlocks() recursive helper in openai-to-claude.ts - Applied on tool_result content before forwarding to Anthropic - Prevents 400 'text content blocks must be non-empty' errors T03 (P1): Parse x-codex-5h-*/x-codex-7d-* headers for precise quota reset - parseCodexQuotaHeaders() in codex.ts extracts usage/limit/resetAt - getCodexResetTime() returns furthest-out reset timestamp for safe unblocking T04 (P1): X-Session-Id header for external sticky routing - extractExternalSessionId() in sessionManager.ts reads x-session-id, x-omniroute-session, session-id headers with 'ext:' prefix to avoid collisions T06 (P2): account_deactivated permanent expired status on 401 - ACCOUNT_DEACTIVATED_SIGNALS constant + isAccountDeactivated() in accountFallback.ts - Returns 1-year cooldown (effectively permanent) to prevent retrying dead accounts T07 (P2): X-Forwarded-For IP validation - New src/lib/ipUtils.ts with extractClientIp() and getClientIpFromRequest() - Skips 'unknown'/non-IP entries in X-Forwarded-For chain T10 (P2): credits_exhausted distinct account status - CREDITS_EXHAUSTED_SIGNALS + isCreditsExhausted() in accountFallback.ts - Returns 1h cooldown with creditsExhausted flag, distinct from rate_limit 429 T11 (P1): max reasoning_effort -> budget_tokens: 131072 - EFFORT_BUDGETS and THINKING_LEVEL_MAP updated with max: 131072, xhigh: 131072 - Reverse mapping now returns 'max' for full-budget responses - Unit test updated to expect 'max' (was 'high') T12 (P3): Model pricing updates - MiniMax M2.7 / MiniMax-M2.7 / minimax-m2.7-highspeed pricing added T15 (P1): Array content normalization for system/tool messages - normalizeContentToString() helper exported from openai-to-claude.ts - System messages with array content now correctly collapsed to string
282 lines
10 KiB
TypeScript
282 lines
10 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";
|
|
|
|
/**
|
|
* 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 (nativeCodexPassthrough) {
|
|
return body;
|
|
}
|
|
|
|
// If no instructions provided, inject default Codex instructions
|
|
if (!body.instructions || body.instructions.trim() === "") {
|
|
body.instructions = CODEX_DEFAULT_INSTRUCTIONS;
|
|
}
|
|
|
|
// Ensure store is false (Codex requirement)
|
|
body.store = false;
|
|
|
|
// 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;
|
|
}
|
|
}
|