From 1e2b7e728de7b1c1db13017f185af5adcd5fc9fc Mon Sep 17 00:00:00 2001 From: Randi <55005611+rdself@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:40:32 -0400 Subject: [PATCH] Fix Codex combo fallback and move Codex defaults to connections (#1176) Integrated into release/v3.6.5. Added CHANGELOG breaking change entry for the removed /api/settings/codex-service-tier endpoint and deduplicated getCodexRequestDefaults in page.tsx (now imports from requestDefaults.ts). --- CHANGELOG.md | 16 +- open-sse/executors/codex.ts | 65 ++++---- open-sse/utils/streamPayloadCollector.ts | 24 +-- .../dashboard/providers/[id]/page.tsx | 52 +++++++ .../components/CodexServiceTierTab.tsx | 103 ------------- .../(dashboard)/dashboard/settings/page.tsx | 2 - src/app/api/providers/[id]/route.ts | 4 +- src/app/api/providers/route.ts | 3 + .../api/settings/codex-service-tier/route.ts | 55 ------- src/instrumentation-node.ts | 29 ++-- src/lib/db/providers.ts | 25 +++- src/lib/providers/codexConnectionDefaults.ts | 84 +++++++++++ src/lib/providers/requestDefaults.ts | 101 +++++++++++++ src/shared/validation/schemas.ts | 123 +++++++++------ tests/unit/codex-connection-defaults.test.mjs | 132 ++++++++++++++++ tests/unit/executor-codex.test.mjs | 141 +++++++++++++++--- tests/unit/plan3-p0.test.mjs | 32 ++-- tests/unit/stream-utils.test.mjs | 43 ++++++ 18 files changed, 735 insertions(+), 299 deletions(-) delete mode 100644 src/app/(dashboard)/dashboard/settings/components/CodexServiceTierTab.tsx delete mode 100644 src/app/api/settings/codex-service-tier/route.ts create mode 100644 src/lib/providers/codexConnectionDefaults.ts create mode 100644 src/lib/providers/requestDefaults.ts create mode 100644 tests/unit/codex-connection-defaults.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3afd902107..b50e508e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,21 @@ ## [Unreleased] ---- +### ✨ New Features + +- **Antigravity AI Credits Fallback:** Automatically retries with `GOOGLE_ONE_AI` credit injection when free-tier quota is exhausted. Per-account credit balance (5-hour TTL) is cached from SSE `remainingCredits` and exposed as a numeric badge in the Provider Usage dashboard (#1190 — thanks @sFaxsy) +- **Claude Code Native Parity:** Full header/body signing parity with the Claude Code 2.1.87 OAuth client — CCH xxHash64 body signing, dynamic per-request fingerprint, bidirectional TitleCase ↔ lowercase tool name remapping (14 tools), API constraint enforcement (`temperature=1` for thinking, max 4 `cache_control` blocks, auto-inject ephemeral on last user message), and optional ZWJ obfuscation (#1188 — thanks @RaviTharuma) +- **Per-Connection Codex Defaults:** Codex Fast Service Tier and Reasoning Effort settings are now per-connection instead of a single global toggle. Existing connections are migrated automatically on startup via an idempotent backfill migration (#1176 — thanks @rdself) + +### 🐛 Bug Fixes + +- **Search Cache Coalescing with TTL=0:** Fixed a bug where providers configured with `cacheTTLMs: 0` (caching explicitly disabled) still had concurrent requests coalesced and returned `{ cached: true }`. Now each call gets its own independent upstream fetch (#1178 — thanks @sjhddh) +- **Codex Combo Smoke Test False Positives:** Fixed combo tests incorrectly reporting `ERROR` for valid Codex streaming responses when `response.output` is empty but text deltas were emitted. The summary now falls back to accumulated delta text (#1176 — thanks @rdself) +- **Electron NODE_PATH Resolution (Windows):** Fixed Electron desktop startup failures on Windows packaged builds caused by native modules (`better-sqlite3`) being under `app.asar.unpacked` while helpers were in `app/node_modules`. `resolveServerNodePath()` now merges both locations with deduplication and existence checks (#1172 — thanks @backryun) + +### ⚠️ Breaking Changes + +- **`DELETE /api/settings/codex-service-tier` removed:** This endpoint no longer exists. Codex Service Tier configuration has moved to per-connection `providerSpecificData.requestDefaults`. Existing connections are migrated automatically on first startup after upgrade. Any external scripts or integrations that call this endpoint should be updated — use `PUT /api/providers/:id` with `providerSpecificData.requestDefaults.serviceTier` instead (#1176). ## [3.6.4] — 2026-04-12 diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 5e08855074..db67c0932a 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -2,6 +2,8 @@ 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"; +import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; +import { getCodexRequestDefaults } from "@/lib/providers/requestDefaults"; // ─── T09: Codex vs Spark Scope-Aware Rate Limiting ──────────────────────── // Codex has two independent quota pools: "codex" (standard) and "spark" (premium). @@ -160,7 +162,6 @@ export function getCodexDualWindowCooldownMs( 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 stringifyCodexInstructionContent(content: unknown): string { if (typeof content === "string") { @@ -285,10 +286,6 @@ function normalizeServiceTierValue(value: unknown): string | undefined { 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). @@ -318,6 +315,12 @@ function clampEffort(model: string, requested: string): string { return requested; } +function normalizeEffortValue(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const normalized = value.trim().toLowerCase(); + return normalized || undefined; +} + /** * Codex Executor - handles OpenAI Codex API (Responses API format) * Automatically injects default instructions if missing. @@ -394,6 +397,9 @@ export class CodexExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { const nativeCodexPassthrough = body?._nativeCodexPassthrough === true; const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath); + const requestDefaults = getCodexRequestDefaults(credentials?.providerSpecificData); + const thinkingBudgetConfig = getThinkingBudgetConfig(); + const allowConnectionReasoningDefaults = thinkingBudgetConfig.mode === ThinkingMode.PASSTHROUGH; // Codex /responses rejects stream=false, but /responses/compact rejects the stream field entirely. if (isCompactRequest) { @@ -407,8 +413,8 @@ export class CodexExecutor extends BaseExecutor { const requestServiceTier = normalizeServiceTierValue(body.service_tier); if (requestServiceTier) { body.service_tier = requestServiceTier; - } else if (defaultFastServiceTierEnabled) { - body.service_tier = CODEX_FAST_WIRE_VALUE; + } else if (requestDefaults.serviceTier) { + body.service_tier = requestDefaults.serviceTier; } // If no instructions provided, inject default Codex instructions @@ -435,38 +441,43 @@ export class CodexExecutor extends BaseExecutor { delete body.messages; delete body.prompt; - 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; + let cleanModel = typeof body.model === "string" ? body.model : model; for (const level of effortLevels) { - if (model.endsWith(`-${level}`)) { + if (typeof cleanModel === "string" && cleanModel.endsWith(`-${level}`)) { modelEffort = level; - // Strip suffix from model name for actual API call - body.model = body.model.replace(`-${level}`, ""); + body.model = cleanModel.slice(0, -`-${level}`.length); 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); + const explicitReasoning = normalizeEffortValue(body?.reasoning?.effort); + const requestReasoningEffort = normalizeEffortValue(body.reasoning_effort); + const fallbackReasoningEffort = allowConnectionReasoningDefaults + ? requestDefaults.reasoningEffort || "medium" + : undefined; + const rawEffort = + explicitReasoning || requestReasoningEffort || modelEffort || fallbackReasoningEffort; + + if (explicitReasoning) { + body.reasoning = { + ...(body.reasoning && typeof body.reasoning === "object" ? body.reasoning : {}), + effort: clampEffort(cleanModel, explicitReasoning), + }; + } else if (rawEffort) { + body.reasoning = { + ...(body.reasoning && typeof body.reasoning === "object" ? body.reasoning : {}), + effort: clampEffort(cleanModel, rawEffort), + }; } delete body.reasoning_effort; + if (nativeCodexPassthrough) { + return body; + } + // Remove unsupported parameters for Codex API delete body.temperature; delete body.top_p; diff --git a/open-sse/utils/streamPayloadCollector.ts b/open-sse/utils/streamPayloadCollector.ts index a9c9e8c9c5..96395bee99 100644 --- a/open-sse/utils/streamPayloadCollector.ts +++ b/open-sse/utils/streamPayloadCollector.ts @@ -261,6 +261,16 @@ function buildResponsesSummary( let latestResponse: JsonRecord | null = null; let usage: JsonRecord | null = null; const textParts: string[] = []; + const buildOutputFromText = () => + textParts.length > 0 + ? [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: textParts.join("") }], + }, + ] + : []; for (const payload of payloads) { const eventType = toString(payload.type); @@ -292,11 +302,12 @@ function buildResponsesSummary( const picked = completed || latestResponse; if (picked && Object.keys(picked).length > 0) { + const pickedOutput = Array.isArray(picked.output) ? picked.output : []; return { id: toString(picked.id, `resp_${Date.now()}`), object: "response", model: toString(picked.model, fallbackModel || "unknown"), - output: Array.isArray(picked.output) ? picked.output : [], + output: pickedOutput.length > 0 ? pickedOutput : buildOutputFromText(), usage: picked.usage ?? usage ?? null, status: toString(picked.status, completed ? "completed" : "in_progress"), created_at: toNumber(picked.created_at, Math.floor(Date.now() / 1000)), @@ -308,16 +319,7 @@ function buildResponsesSummary( id: `resp_${Date.now()}`, object: "response", model: fallbackModel || "unknown", - output: - textParts.length > 0 - ? [ - { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: textParts.join("") }], - }, - ] - : [], + output: buildOutputFromText(), usage: usage ?? null, status: "completed", created_at: Math.floor(Date.now() / 1000), diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index d1f600a35d..383fd89f7a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -46,6 +46,7 @@ import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases"; import { maskEmail, pickMaskedDisplayValue, pickDisplayValue } from "@/shared/utils/maskEmail"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle"; +import { getCodexRequestDefaults as _getCodexRequestDefaults } from "@/lib/providers/requestDefaults"; type CompatByProtocolMap = Partial< Record< @@ -535,6 +536,13 @@ interface EditCompatibleNodeModalProps { const CC_COMPATIBLE_LABEL = "CC Compatible"; const CC_COMPATIBLE_DETAILS_TITLE = "CC Compatible Details"; const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; +const CODEX_REASONING_STRENGTH_OPTIONS = [ + { value: "none", label: "None" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "XHigh" }, +]; function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly: boolean } { const record = @@ -547,6 +555,21 @@ function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; useWeekly }; } +/** + * UI adapter around the canonical getCodexRequestDefaults from requestDefaults.ts. + * Adds the "medium" fallback for reasoningEffort required by the connection form. + */ +function getCodexRequestDefaults(providerSpecificData: unknown): { + reasoningEffort: string; + serviceTier?: "priority"; +} { + const defaults = _getCodexRequestDefaults(providerSpecificData); + return { + reasoningEffort: defaults.reasoningEffort ?? "medium", + ...(defaults.serviceTier ? { serviceTier: defaults.serviceTier } : {}), + }; +} + function compatProtocolLabelKey(protocol: string): string { if (protocol === "openai") return "compatProtocolOpenAI"; if (protocol === "openai-responses") return "compatProtocolOpenAIResponses"; @@ -5340,6 +5363,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec tag: "", customUserAgent: "", accountId: "", + codexReasoningEffort: "medium", + codexFastServiceTier: false, }); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); @@ -5358,6 +5383,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec const isVertex = connection?.provider === "vertex"; const isGlm = connection?.provider === "glm"; const isCloudflare = connection?.provider === "cloudflare-ai"; + const isCodex = connection?.provider === "codex"; const defaultRegion = "us-central1"; useEffect(() => { @@ -5371,6 +5397,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec typeof rawCustomUserAgent === "string" ? rawCustomUserAgent : ""; const rawAccountId = connection.providerSpecificData?.accountId; const existingAccountId = typeof rawAccountId === "string" ? rawAccountId : ""; + const codexRequestDefaults = getCodexRequestDefaults(connection.providerSpecificData); setFormData({ name: connection.name || "", priority: connection.priority || 1, @@ -5383,6 +5410,8 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec tag: (connection.providerSpecificData?.tag as string) || "", customUserAgent: existingCustomUserAgent, accountId: existingAccountId, + codexReasoningEffort: codexRequestDefaults.reasoningEffort, + codexFastServiceTier: codexRequestDefaults.serviceTier === "priority", }); // Load existing extra keys from providerSpecificData const existing = connection.providerSpecificData?.extraApiKeys; @@ -5533,6 +5562,12 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec ...(connection.providerSpecificData || {}), tag: formData.tag.trim() || undefined, }; + if (isCodex) { + updates.providerSpecificData.requestDefaults = { + reasoningEffort: formData.codexReasoningEffort, + ...(formData.codexFastServiceTier ? { serviceTier: "priority" } : {}), + }; + } } const error = (await onSave(updates)) as void | unknown; if (error) { @@ -5570,6 +5605,23 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec placeholder="e.g. personal, work, team-a" hint="Used to group accounts in the provider view" /> + {isCodex && ( +
+