diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 4ed9c52f..cf6f2c58 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -5,6 +5,27 @@ import { buildTtsProviderModels } from "./ttsModels.js"; // Key = alias (cc, cx, gc, qw, if, ag, gh for OAuth; id for API Key) // Field "provider" for special cases (e.g. AntiGravity models that call different backends) +const CODEX_REVIEW_SUFFIX = "-review"; + +function withCodexReviewModels(models) { + return models.flatMap((model) => { + if ((model.type || "llm") !== "llm" || model.id.endsWith(CODEX_REVIEW_SUFFIX)) { + return [model]; + } + + return [ + model, + { + ...model, + id: `${model.id}${CODEX_REVIEW_SUFFIX}`, + name: `${model.name} Review`, + upstreamModelId: model.upstreamModelId || model.id, + quotaFamily: "review", + }, + ]; + }); +} + export const PROVIDER_MODELS = { // OAuth Providers (using alias) cc: [ // Claude Code @@ -15,7 +36,7 @@ export const PROVIDER_MODELS = { { id: "claude-sonnet-4-5-20250929", name: "Claude 4.5 Sonnet" }, { id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku" }, ], - cx: [ // OpenAI Codex + cx: withCodexReviewModels([ // OpenAI Codex { id: "gpt-5.5", name: "GPT 5.5" }, { id: "gpt-5.4", name: "GPT 5.4" }, // GPT 5.3 Codex - all thinking levels @@ -40,7 +61,7 @@ export const PROVIDER_MODELS = { { id: "gpt-5.4-image", name: "GPT 5.4 Image", type: "image", capabilities: ["text2img", "edit"], params: ["size", "quality", "background", "image_detail", "output_format"] }, { id: "gpt-5.3-image", name: "GPT 5.3 Image", type: "image", capabilities: ["text2img", "edit"], params: ["size", "quality", "background", "image_detail", "output_format"] }, { id: "gpt-5.2-image", name: "GPT 5.2 Image", type: "image", capabilities: ["text2img", "edit"], params: ["size", "quality", "background", "image_detail", "output_format"] }, - ], + ]), gc: [ // Gemini CLI { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, { id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" }, @@ -568,6 +589,22 @@ export function getModelTargetFormat(aliasOrId, modelId) { return found?.targetFormat || null; } +export function getModelUpstreamId(aliasOrId, modelId) { + const models = PROVIDER_MODELS[aliasOrId]; + const found = models?.find(m => m.id === modelId); + if (found?.upstreamModelId) return found.upstreamModelId; + if (aliasOrId === "cx" && typeof modelId === "string" && modelId.endsWith(CODEX_REVIEW_SUFFIX)) { + return modelId.slice(0, -CODEX_REVIEW_SUFFIX.length); + } + return modelId; +} + +export function getModelQuotaFamily(aliasOrId, modelId) { + const models = PROVIDER_MODELS[aliasOrId]; + const found = models?.find(m => m.id === modelId); + return found?.quotaFamily || "normal"; +} + // OAuth providers that use short aliases (everything else: alias = id) const OAUTH_ALIASES = { claude: "cc", diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index f7a62120..80cbd6d3 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -4,6 +4,7 @@ import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js"; import { PROVIDERS } from "../config/providers.js"; import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.js"; import { fetchImageAsBase64 } from "../translator/helpers/imageHelper.js"; +import { getModelUpstreamId } from "../config/providerModels.js"; import { getConsistentMachineId } from "../../src/shared/utils/machineId.js"; // In-memory map: hash(machineId + first assistant content) → { sessionId, lastUsed } @@ -175,12 +176,15 @@ export class CodexExecutor extends BaseExecutor { // Ensure store is false (Codex requirement) body.store = false; + // Map virtual Codex review models to the upstream Codex model before suffix parsing. + body.model = getModelUpstreamId("cx", body.model || model); + // 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 = null; for (const level of effortLevels) { - if (model.endsWith(`-${level}`)) { + if (body.model.endsWith(`-${level}`)) { modelEffort = level; // Strip suffix from model name for actual API call body.model = body.model.replace(`-${level}`, ''); @@ -212,6 +216,8 @@ export class CodexExecutor extends BaseExecutor { delete body.n; delete body.seed; delete body.max_tokens; + delete body.max_completion_tokens; + delete body.max_output_tokens; // Responses API clients send this but Codex rejects it 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 diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 53181c1f..094aa86e 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -488,6 +488,70 @@ async function getClaudeUsageLegacy(accessToken, proxyOptions = null) { /** * Codex (OpenAI) Usage - Fetch from ChatGPT backend API */ +function toFiniteNumber(value, fallback = 0) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +function getCodexRateLimitBody(snapshot) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null; + return snapshot.rate_limit && typeof snapshot.rate_limit === "object" + ? snapshot.rate_limit + : snapshot; +} + +function formatCodexWindow(window) { + const used = Math.max(0, Math.min(100, toFiniteNumber(window?.used_percent ?? window?.percent_used, 0))); + return { + used, + total: 100, + remaining: Math.max(0, 100 - used), + resetAt: parseResetTime(window?.reset_at ?? window?.resets_at ?? window?.resetAt ?? null), + unlimited: false, + }; +} + +function appendCodexQuotaWindows(quotas, prefix, snapshot) { + const rateLimit = getCodexRateLimitBody(snapshot); + if (!rateLimit) return false; + + const primary = rateLimit.primary_window || rateLimit.primary || snapshot.primary_window || snapshot.primary; + const secondary = rateLimit.secondary_window || rateLimit.secondary || snapshot.secondary_window || snapshot.secondary; + let added = false; + + if (primary) { + quotas[prefix ? `${prefix}_session` : "session"] = formatCodexWindow(primary); + added = true; + } + if (secondary) { + quotas[prefix ? `${prefix}_weekly` : "weekly"] = formatCodexWindow(secondary); + added = true; + } + + return added; +} + +function getCodexReviewRateLimit(data) { + if (data.code_review_rate_limit || data.review_rate_limit) { + return data.code_review_rate_limit || data.review_rate_limit; + } + + const byLimitId = data.rate_limits_by_limit_id; + if (byLimitId && typeof byLimitId === "object" && !Array.isArray(byLimitId)) { + return byLimitId.code_review || byLimitId.codex_review || byLimitId.review || null; + } + + const additional = Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : []; + return additional.find((entry) => { + const id = String(entry?.limit_name || entry?.metered_feature || entry?.id || "").toLowerCase(); + return id === "code_review" || id === "codex_review" || id === "review" || id.includes("review"); + }) || null; +} + async function getCodexUsage(accessToken, proxyOptions = null) { try { const response = await proxyAwareFetch(CODEX_CONFIG.usageUrl, { @@ -503,35 +567,18 @@ async function getCodexUsage(accessToken, proxyOptions = null) { } const data = await response.json(); + const normalRateLimit = data.rate_limit || data.rate_limits || data.rate_limits_by_limit_id?.codex || {}; + const reviewRateLimit = getCodexReviewRateLimit(data); + const quotas = {}; - // Parse rate limit info - const rateLimit = data.rate_limit || {}; - const primaryWindow = rateLimit.primary_window || {}; - const secondaryWindow = rateLimit.secondary_window || {}; - - // Parse reset dates (reset_at is Unix timestamp in seconds, multiply by 1000 for ms) - const sessionResetAt = parseResetTime(primaryWindow.reset_at ? primaryWindow.reset_at * 1000 : null); - const weeklyResetAt = parseResetTime(secondaryWindow.reset_at ? secondaryWindow.reset_at * 1000 : null); + appendCodexQuotaWindows(quotas, "", normalRateLimit); + appendCodexQuotaWindows(quotas, "review", reviewRateLimit); return { - plan: data.plan_type || "unknown", - limitReached: rateLimit.limit_reached || false, - quotas: { - session: { - used: primaryWindow.used_percent || 0, - total: 100, - remaining: 100 - (primaryWindow.used_percent || 0), - resetAt: sessionResetAt, - unlimited: false, - }, - weekly: { - used: secondaryWindow.used_percent || 0, - total: 100, - remaining: 100 - (secondaryWindow.used_percent || 0), - resetAt: weeklyResetAt, - unlimited: false, - }, - }, + plan: data.plan_type || data.summary?.plan || "unknown", + limitReached: getCodexRateLimitBody(normalRateLimit)?.limit_reached || false, + reviewLimitReached: getCodexRateLimitBody(reviewRateLimit)?.limit_reached || false, + quotas, }; } catch (error) { throw new Error(`Failed to fetch Codex usage: ${error.message}`); diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 989845aa..59ff4ea3 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -36,6 +36,27 @@ const parseGeminiCliModels = (data) => { return []; }; +const appendCodexReviewModels = (models) => models.flatMap((model) => { + const id = model?.id || model?.slug || model?.model || model?.name; + if (!id) return []; + const name = model?.display_name || model?.displayName || model?.name || id; + const normalized = { ...model, id, name }; + const isChatModel = (model?.type || "llm") !== "image" && !id.toLowerCase().includes("embed"); + if (!isChatModel || id.endsWith("-review")) return [normalized]; + return [ + normalized, + { + ...normalized, + id: `${id}-review`, + name: `${name} Review`, + upstreamModelId: id, + quotaFamily: "review", + }, + ]; +}); + +const parseCodexModels = (data) => appendCodexReviewModels(parseOpenAIStyleModels(data)); + const createOpenAIModelsConfig = (url) => ({ url, method: "GET", @@ -84,6 +105,14 @@ const PROVIDER_MODELS_CONFIG = { authPrefix: "Bearer ", parseResponse: (data) => data.data || [] }, + codex: { + url: "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0", + method: "GET", + headers: { "Content-Type": "application/json", "Accept": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: parseCodexModels + }, antigravity: { url: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:models", method: "POST", diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 14907727..beeda107 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -415,7 +415,7 @@ export async function createProviderConnection(data) { const optionalFields = [ "displayName", "email", "globalPriority", "defaultModel", "accessToken", "refreshToken", "expiresAt", "tokenType", - "scope", "idToken", "projectId", "apiKey", "testStatus", + "scope", "projectId", "apiKey", "testStatus", "lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn", "errorCode", "consecutiveUseCount" ]; @@ -681,7 +681,7 @@ export async function cleanupProviderConnections() { const fieldsToCheck = [ "displayName", "email", "globalPriority", "defaultModel", "accessToken", "refreshToken", "expiresAt", "tokenType", - "scope", "idToken", "projectId", "apiKey", "testStatus", + "scope", "projectId", "apiKey", "testStatus", "lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn", "consecutiveUseCount" ]; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index 6055d70b..46154ecb 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -172,7 +172,6 @@ const PROVIDERS = { const mapped = { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, - idToken: tokens.id_token, expiresIn: tokens.expires_in, }; if (info.email) mapped.email = info.email; diff --git a/src/lib/oauth/services/codex.js b/src/lib/oauth/services/codex.js index 1edce836..fdc0310e 100644 --- a/src/lib/oauth/services/codex.js +++ b/src/lib/oauth/services/codex.js @@ -53,7 +53,6 @@ export class CodexService extends OAuthService { body: JSON.stringify({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token, - idToken: tokens.id_token, expiresIn: tokens.expires_in, }), }); diff --git a/src/shared/constants/models.js b/src/shared/constants/models.js index 1fb37c5c..3e842f6e 100644 --- a/src/shared/constants/models.js +++ b/src/shared/constants/models.js @@ -8,7 +8,9 @@ export { getModelTargetFormat, getModelStrip, PROVIDER_ID_TO_ALIAS, - getModelsByProviderId + getModelsByProviderId, + getModelUpstreamId, + getModelQuotaFamily } from "open-sse/config/providerModels.js"; import { AI_PROVIDERS, isOpenAICompatibleProvider } from "./providers.js";