mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 13:52:09 +03:00
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).
This commit is contained in:
16
CHANGELOG.md
16
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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 && (
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
|
||||
<Select
|
||||
label="Default thinking strength"
|
||||
value={formData.codexReasoningEffort}
|
||||
options={CODEX_REASONING_STRENGTH_OPTIONS}
|
||||
onChange={(e) => setFormData({ ...formData, codexReasoningEffort: e.target.value })}
|
||||
hint="Used when the client does not send a reasoning effort and the global Thinking Budget mode is passthrough."
|
||||
/>
|
||||
<Toggle
|
||||
checked={formData.codexFastServiceTier}
|
||||
onChange={(checked) => setFormData({ ...formData, codexFastServiceTier: checked })}
|
||||
label="Codex Fast Service Tier"
|
||||
description="When enabled, injects `service_tier=priority` for this connection if the client leaves the tier unset."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isOAuth && connection.email && (
|
||||
<div className="bg-sidebar/50 p-3 rounded-lg">
|
||||
<p className="text-sm text-text-muted mb-1">{t("email")}</p>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
export default function CodexServiceTierTab() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<"" | "saved" | "error">("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/codex-service-tier")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setEnabled(Boolean(data.enabled));
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const save = async (nextEnabled: boolean) => {
|
||||
setEnabled(nextEnabled);
|
||||
setSaving(true);
|
||||
setStatus("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings/codex-service-tier", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: nextEnabled }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setStatus("saved");
|
||||
setTimeout(() => setStatus(""), 2000);
|
||||
} else {
|
||||
setStatus("error");
|
||||
setEnabled(!nextEnabled);
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setEnabled(!nextEnabled);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="p-2 rounded-lg bg-sky-500/10 text-sky-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
bolt
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">Codex Fast Service Tier</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Inject `service_tier=priority` into Codex requests when the client leaves it unset.
|
||||
</p>
|
||||
</div>
|
||||
{status === "saved" && (
|
||||
<span className="text-xs font-medium text-emerald-500 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">check_circle</span>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<span className="text-xs font-medium text-rose-500 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[14px]">error</span>
|
||||
Failed to save
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-lg bg-surface/30 border border-border/30">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Force fast tier for Codex</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Off by default. Applies only to Codex requests and does not override an explicit tier.
|
||||
Codex fast mode is sent upstream as `service_tier=priority`.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => save(!enabled)}
|
||||
disabled={loading || saving}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full border transition-colors ${
|
||||
enabled
|
||||
? "bg-sky-500 border-sky-500"
|
||||
: "bg-black/10 border-black/10 dark:bg-white/10 dark:border-white/10"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
enabled ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import ComboDefaultsTab from "./components/ComboDefaultsTab";
|
||||
import ProxyTab from "./components/ProxyTab";
|
||||
import AppearanceTab from "./components/AppearanceTab";
|
||||
import ThinkingBudgetTab from "./components/ThinkingBudgetTab";
|
||||
import CodexServiceTierTab from "./components/CodexServiceTierTab";
|
||||
import SystemPromptTab from "./components/SystemPromptTab";
|
||||
import ModelAliasesUnified from "./components/ModelAliasesUnified";
|
||||
import BackgroundDegradationTab from "./components/BackgroundDegradationTab";
|
||||
@@ -93,7 +92,6 @@ export default function SettingsPage() {
|
||||
{activeTab === "ai" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ThinkingBudgetTab />
|
||||
<CodexServiceTierTab />
|
||||
<SystemPromptTab />
|
||||
<CacheSettingsTab />
|
||||
<MemorySkillsTab />
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { updateProviderConnectionSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
|
||||
|
||||
function normalizeCodexLimitPolicy(
|
||||
incoming: unknown,
|
||||
@@ -142,7 +143,8 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
}
|
||||
}
|
||||
|
||||
updateData.providerSpecificData = mergedPsd;
|
||||
updateData.providerSpecificData =
|
||||
normalizeProviderSpecificData(existing.provider, mergedPsd) || {};
|
||||
}
|
||||
|
||||
const updated = await updateProviderConnection(id, updateData);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { createProviderSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { normalizeQoderPatProviderData } from "@omniroute/open-sse/services/qoderCli";
|
||||
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
|
||||
|
||||
// GET /api/providers - List all connections
|
||||
export async function GET() {
|
||||
@@ -132,6 +133,8 @@ export async function POST(request: Request) {
|
||||
};
|
||||
}
|
||||
|
||||
providerSpecificData = normalizeProviderSpecificData(provider, providerSpecificData) || null;
|
||||
|
||||
const newConnection = await createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
import { setDefaultFastServiceTierEnabled } from "@omniroute/open-sse/executors/codex.ts";
|
||||
import { updateCodexServiceTierSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
const persisted =
|
||||
typeof settings.codexServiceTier === "string"
|
||||
? JSON.parse(settings.codexServiceTier)
|
||||
: settings.codexServiceTier;
|
||||
|
||||
return NextResponse.json({
|
||||
enabled: typeof persisted?.enabled === "boolean" ? persisted.enabled : false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/settings/codex-service-tier GET:", error);
|
||||
return NextResponse.json({ error: "Failed to get config" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "body", message: "Invalid JSON body" }],
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = validateBody(updateCodexServiceTierSchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
|
||||
const config = validation.data;
|
||||
await updateSettings({ codexServiceTier: config });
|
||||
setDefaultFastServiceTierEnabled(config.enabled);
|
||||
|
||||
return NextResponse.json(config);
|
||||
} catch (error) {
|
||||
console.error("[API ERROR] /api/settings/codex-service-tier PUT:", error);
|
||||
return NextResponse.json({ error: "Failed to update config" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -105,10 +105,11 @@ export async function registerNodejs(): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
const [{ setCustomAliases }, { setDefaultFastServiceTierEnabled }] = await Promise.all([
|
||||
import("@omniroute/open-sse/services/modelDeprecation.ts"),
|
||||
import("@omniroute/open-sse/executors/codex.ts"),
|
||||
]);
|
||||
const [{ setCustomAliases }, { migrateCodexConnectionDefaultsFromLegacySettings }] =
|
||||
await Promise.all([
|
||||
import("@omniroute/open-sse/services/modelDeprecation.ts"),
|
||||
import("@/lib/providers/codexConnectionDefaults"),
|
||||
]);
|
||||
const settings = await getSettings();
|
||||
|
||||
if (settings.modelAliases) {
|
||||
@@ -124,16 +125,20 @@ export async function registerNodejs(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const persisted =
|
||||
typeof settings.codexServiceTier === "string"
|
||||
? JSON.parse(settings.codexServiceTier)
|
||||
: settings.codexServiceTier;
|
||||
|
||||
if (typeof persisted?.enabled === "boolean") {
|
||||
setDefaultFastServiceTierEnabled(persisted.enabled);
|
||||
const migration = await migrateCodexConnectionDefaultsFromLegacySettings();
|
||||
if (migration.migrated) {
|
||||
console.log(
|
||||
`[STARTUP] Restored Codex fast service tier: ${persisted.enabled ? "on" : "off"}`
|
||||
`[STARTUP] Migrated Codex connection defaults for ${migration.updatedConnectionIds.length} connection(s)`
|
||||
);
|
||||
if (settings.cloudEnabled === true) {
|
||||
const [{ syncToCloud }, { getConsistentMachineId }] = await Promise.all([
|
||||
import("@/lib/cloudSync"),
|
||||
import("@/shared/utils/machineId"),
|
||||
]);
|
||||
const machineId = await getConsistentMachineId();
|
||||
await syncToCloud(machineId);
|
||||
console.log("[STARTUP] Synced migrated Codex connection defaults to cloud");
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getDbInstance, rowToCamel, cleanNulls } from "./core";
|
||||
import { backupDbFile } from "./backup";
|
||||
import { encryptConnectionFields, decryptConnectionFields } from "./encryption";
|
||||
import { invalidateDbCache } from "./readCache";
|
||||
import { normalizeProviderSpecificData } from "@/lib/providers/requestDefaults";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -67,6 +68,10 @@ export async function getProviderConnectionById(id: string) {
|
||||
export async function createProviderConnection(data: JsonRecord) {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const now = new Date().toISOString();
|
||||
const normalizedProviderSpecificData = normalizeProviderSpecificData(
|
||||
toStringOrNull(data.provider),
|
||||
data.providerSpecificData
|
||||
);
|
||||
|
||||
// Upsert check
|
||||
// For Codex/OpenAI, a single email can have multiple workspaces (Team + Personal)
|
||||
@@ -121,7 +126,11 @@ export async function createProviderConnection(data: JsonRecord) {
|
||||
if (existing) {
|
||||
const existingId = toStringOrNull(existing.id);
|
||||
if (!existingId) return null;
|
||||
const merged = { ...toRecord(rowToCamel(existing)), ...data, updatedAt: now };
|
||||
const merged: JsonRecord = { ...toRecord(rowToCamel(existing)), ...data, updatedAt: now };
|
||||
merged.providerSpecificData = normalizeProviderSpecificData(
|
||||
toStringOrNull(merged.provider),
|
||||
merged.providerSpecificData
|
||||
);
|
||||
_updateConnectionRow(db, existingId, merged);
|
||||
backupDbFile("pre-write");
|
||||
return cleanNulls(merged);
|
||||
@@ -192,8 +201,8 @@ export async function createProviderConnection(data: JsonRecord) {
|
||||
connection[field] = data[field];
|
||||
}
|
||||
}
|
||||
if (data.providerSpecificData && Object.keys(data.providerSpecificData).length > 0) {
|
||||
connection.providerSpecificData = data.providerSpecificData;
|
||||
if (normalizedProviderSpecificData && Object.keys(normalizedProviderSpecificData).length > 0) {
|
||||
connection.providerSpecificData = normalizedProviderSpecificData;
|
||||
}
|
||||
|
||||
_insertConnectionRow(db, encryptConnectionFields({ ...connection }));
|
||||
@@ -347,7 +356,15 @@ export async function updateProviderConnection(id: string, data: JsonRecord) {
|
||||
const existing = db.prepare("SELECT * FROM provider_connections WHERE id = ?").get(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const merged = { ...rowToCamel(existing), ...data, updatedAt: new Date().toISOString() };
|
||||
const merged: JsonRecord = {
|
||||
...toRecord(rowToCamel(existing)),
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
merged.providerSpecificData = normalizeProviderSpecificData(
|
||||
toStringOrNull(merged.provider),
|
||||
merged.providerSpecificData
|
||||
);
|
||||
_updateConnectionRow(db, id, encryptConnectionFields({ ...merged }));
|
||||
backupDbFile("pre-write");
|
||||
invalidateDbCache("connections"); // Bust connections read cache
|
||||
|
||||
84
src/lib/providers/codexConnectionDefaults.ts
Normal file
84
src/lib/providers/codexConnectionDefaults.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { getProviderConnections, updateProviderConnection } from "@/lib/db/providers";
|
||||
import { getSettings, updateSettings } from "@/lib/db/settings";
|
||||
import { getCodexRequestDefaults } from "./requestDefaults";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const MIGRATION_SETTING_KEY = "codexConnectionDefaultsMigrationV1";
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function parseLegacyCodexServiceTier(value: unknown): { enabled: boolean } {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return parseLegacyCodexServiceTier(JSON.parse(value));
|
||||
} catch {
|
||||
return { enabled: false };
|
||||
}
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
return { enabled: record.enabled === true };
|
||||
}
|
||||
|
||||
export async function migrateCodexConnectionDefaultsFromLegacySettings(): Promise<{
|
||||
migrated: boolean;
|
||||
updatedConnectionIds: string[];
|
||||
legacyFastEnabled: boolean;
|
||||
}> {
|
||||
const settings = await getSettings();
|
||||
if (settings[MIGRATION_SETTING_KEY]) {
|
||||
return {
|
||||
migrated: false,
|
||||
updatedConnectionIds: [],
|
||||
legacyFastEnabled: parseLegacyCodexServiceTier(settings.codexServiceTier).enabled,
|
||||
};
|
||||
}
|
||||
|
||||
const legacyFastEnabled = parseLegacyCodexServiceTier(settings.codexServiceTier).enabled;
|
||||
const codexConnections = await getProviderConnections({ provider: "codex" });
|
||||
const updatedConnectionIds: string[] = [];
|
||||
|
||||
for (const connection of codexConnections) {
|
||||
const providerSpecificData = asRecord(connection.providerSpecificData);
|
||||
const existingDefaults = getCodexRequestDefaults(providerSpecificData);
|
||||
const nextDefaults: JsonRecord = { ...existingDefaults };
|
||||
|
||||
if (!existingDefaults.reasoningEffort) {
|
||||
nextDefaults.reasoningEffort = "medium";
|
||||
}
|
||||
if (legacyFastEnabled && !existingDefaults.serviceTier) {
|
||||
nextDefaults.serviceTier = "priority";
|
||||
}
|
||||
|
||||
const defaultsChanged =
|
||||
nextDefaults.reasoningEffort !== existingDefaults.reasoningEffort ||
|
||||
nextDefaults.serviceTier !== existingDefaults.serviceTier;
|
||||
|
||||
if (!defaultsChanged) continue;
|
||||
|
||||
await updateProviderConnection(connection.id, {
|
||||
providerSpecificData: {
|
||||
...providerSpecificData,
|
||||
requestDefaults: nextDefaults,
|
||||
},
|
||||
});
|
||||
updatedConnectionIds.push(connection.id);
|
||||
}
|
||||
|
||||
await updateSettings({
|
||||
[MIGRATION_SETTING_KEY]: {
|
||||
completedAt: new Date().toISOString(),
|
||||
updatedConnectionIds,
|
||||
legacyFastEnabled,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
migrated: true,
|
||||
updatedConnectionIds,
|
||||
legacyFastEnabled,
|
||||
};
|
||||
}
|
||||
101
src/lib/providers/requestDefaults.ts
Normal file
101
src/lib/providers/requestDefaults.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export const CODEX_REASONING_EFFORT_VALUES = ["none", "low", "medium", "high", "xhigh"] as const;
|
||||
|
||||
export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_VALUES)[number];
|
||||
|
||||
const CODEX_REASONING_EFFORT_SET = new Set<string>(CODEX_REASONING_EFFORT_VALUES);
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
export function normalizeCodexReasoningEffort(value: unknown): CodexReasoningEffort | undefined {
|
||||
const normalized = normalizeString(value);
|
||||
if (!normalized || !CODEX_REASONING_EFFORT_SET.has(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized as CodexReasoningEffort;
|
||||
}
|
||||
|
||||
export function normalizeCodexServiceTier(value: unknown): "priority" | undefined {
|
||||
const normalized = normalizeString(value);
|
||||
if (!normalized) return undefined;
|
||||
if (normalized === "fast" || normalized === "priority") return "priority";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeRequestDefaults(
|
||||
provider: string | null | undefined,
|
||||
value: unknown
|
||||
): JsonRecord | undefined {
|
||||
const record = asRecord(value);
|
||||
if (Object.keys(record).length === 0) return undefined;
|
||||
|
||||
const normalized: JsonRecord = { ...record };
|
||||
|
||||
if (provider === "codex") {
|
||||
const reasoningEffort = normalizeCodexReasoningEffort(record.reasoningEffort);
|
||||
if (reasoningEffort) {
|
||||
normalized.reasoningEffort = reasoningEffort;
|
||||
} else {
|
||||
delete normalized.reasoningEffort;
|
||||
}
|
||||
|
||||
const serviceTier = normalizeCodexServiceTier(record.serviceTier);
|
||||
if (serviceTier) {
|
||||
normalized.serviceTier = serviceTier;
|
||||
} else {
|
||||
delete normalized.serviceTier;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function normalizeProviderSpecificData(
|
||||
provider: string | null | undefined,
|
||||
value: unknown
|
||||
): JsonRecord | undefined {
|
||||
const record = asRecord(value);
|
||||
if (Object.keys(record).length === 0) return undefined;
|
||||
|
||||
const normalized: JsonRecord = { ...record };
|
||||
|
||||
if ("requestDefaults" in normalized) {
|
||||
const requestDefaults = normalizeRequestDefaults(provider, normalized.requestDefaults);
|
||||
if (requestDefaults) {
|
||||
normalized.requestDefaults = requestDefaults;
|
||||
} else {
|
||||
delete normalized.requestDefaults;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
export function getProviderRequestDefaults(
|
||||
provider: string | null | undefined,
|
||||
providerSpecificData: unknown
|
||||
): JsonRecord {
|
||||
return normalizeRequestDefaults(provider, asRecord(providerSpecificData).requestDefaults) || {};
|
||||
}
|
||||
|
||||
export function getCodexRequestDefaults(providerSpecificData: unknown): {
|
||||
reasoningEffort?: CodexReasoningEffort;
|
||||
serviceTier?: "priority";
|
||||
} {
|
||||
const defaults = getProviderRequestDefaults("codex", providerSpecificData);
|
||||
const reasoningEffort = normalizeCodexReasoningEffort(defaults.reasoningEffort);
|
||||
const serviceTier = normalizeCodexServiceTier(defaults.serviceTier);
|
||||
return {
|
||||
...(reasoningEffort ? { reasoningEffort } : {}),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,79 @@ function isHttpUrl(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
const CODEX_REASONING_EFFORT_VALUES = new Set(["none", "low", "medium", "high", "xhigh"]);
|
||||
const REQUEST_DEFAULT_SERVICE_TIER_VALUES = new Set(["priority", "fast"]);
|
||||
|
||||
function validateProviderSpecificData(
|
||||
data: Record<string, unknown> | undefined,
|
||||
ctx: z.RefinementCtx
|
||||
): void {
|
||||
if (!data) return;
|
||||
|
||||
const baseUrl = data.baseUrl;
|
||||
if (baseUrl !== undefined && (typeof baseUrl !== "string" || !isHttpUrl(baseUrl))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.baseUrl must be a valid http(s) URL",
|
||||
path: ["baseUrl"],
|
||||
});
|
||||
}
|
||||
|
||||
const customUserAgent = data.customUserAgent;
|
||||
if (
|
||||
customUserAgent !== undefined &&
|
||||
customUserAgent !== null &&
|
||||
(typeof customUserAgent !== "string" || customUserAgent.length > 500)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.customUserAgent must be a string up to 500 chars",
|
||||
path: ["customUserAgent"],
|
||||
});
|
||||
}
|
||||
|
||||
const requestDefaults = data.requestDefaults;
|
||||
if (requestDefaults === undefined) return;
|
||||
if (!requestDefaults || typeof requestDefaults !== "object" || Array.isArray(requestDefaults)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.requestDefaults must be an object",
|
||||
path: ["requestDefaults"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestDefaultsRecord = requestDefaults as Record<string, unknown>;
|
||||
const reasoningEffort = requestDefaultsRecord.reasoningEffort;
|
||||
if (
|
||||
reasoningEffort !== undefined &&
|
||||
reasoningEffort !== null &&
|
||||
(typeof reasoningEffort !== "string" ||
|
||||
!CODEX_REASONING_EFFORT_VALUES.has(reasoningEffort.trim().toLowerCase()))
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"providerSpecificData.requestDefaults.reasoningEffort must be one of none, low, medium, high, xhigh",
|
||||
path: ["requestDefaults", "reasoningEffort"],
|
||||
});
|
||||
}
|
||||
|
||||
const serviceTier = requestDefaultsRecord.serviceTier;
|
||||
if (
|
||||
serviceTier !== undefined &&
|
||||
serviceTier !== null &&
|
||||
(typeof serviceTier !== "string" ||
|
||||
!REQUEST_DEFAULT_SERVICE_TIER_VALUES.has(serviceTier.trim().toLowerCase()))
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.requestDefaults.serviceTier must be priority when provided",
|
||||
path: ["requestDefaults", "serviceTier"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export validation helpers from dedicated module to avoid webpack barrel-file
|
||||
// optimization bug that truncates exports from large files.
|
||||
export { validateBody, isValidationFailure } from "./helpers";
|
||||
@@ -30,27 +103,7 @@ export const createProviderSchema = z.object({
|
||||
.record(z.string(), z.unknown())
|
||||
.optional()
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data) return;
|
||||
const baseUrl = data.baseUrl;
|
||||
if (baseUrl !== undefined && (typeof baseUrl !== "string" || !isHttpUrl(baseUrl))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.baseUrl must be a valid http(s) URL",
|
||||
path: ["baseUrl"],
|
||||
});
|
||||
}
|
||||
const customUserAgent = data.customUserAgent;
|
||||
if (
|
||||
customUserAgent !== undefined &&
|
||||
customUserAgent !== null &&
|
||||
(typeof customUserAgent !== "string" || customUserAgent.length > 500)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.customUserAgent must be a string up to 500 chars",
|
||||
path: ["customUserAgent"],
|
||||
});
|
||||
}
|
||||
validateProviderSpecificData(data, ctx);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -643,12 +696,6 @@ export const updateThinkingBudgetSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
export const updateCodexServiceTierSchema = z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ipFilterModeSchema = z.enum(["blacklist", "whitelist"]);
|
||||
const tempBanSchema = z.object({
|
||||
ip: z.string().trim().min(1),
|
||||
@@ -1101,27 +1148,7 @@ export const updateProviderConnectionSchema = z
|
||||
.record(z.string(), z.unknown())
|
||||
.optional()
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data) return;
|
||||
const baseUrl = data.baseUrl;
|
||||
if (baseUrl !== undefined && (typeof baseUrl !== "string" || !isHttpUrl(baseUrl))) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.baseUrl must be a valid http(s) URL",
|
||||
path: ["baseUrl"],
|
||||
});
|
||||
}
|
||||
const customUserAgent = data.customUserAgent;
|
||||
if (
|
||||
customUserAgent !== undefined &&
|
||||
customUserAgent !== null &&
|
||||
(typeof customUserAgent !== "string" || customUserAgent.length > 500)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.customUserAgent must be a string up to 500 chars",
|
||||
path: ["customUserAgent"],
|
||||
});
|
||||
}
|
||||
validateProviderSpecificData(data, ctx);
|
||||
}),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
|
||||
132
tests/unit/codex-connection-defaults.test.mjs
Normal file
132
tests/unit/codex-connection-defaults.test.mjs
Normal file
@@ -0,0 +1,132 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-codex-defaults-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const { migrateCodexConnectionDefaultsFromLegacySettings } =
|
||||
await import("../../src/lib/providers/codexConnectionDefaults.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("migration backfills Codex request defaults, preserves existing providerSpecificData, and is idempotent", async () => {
|
||||
const first = await providersDb.createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
email: "first@example.com",
|
||||
providerSpecificData: {
|
||||
workspaceId: "ws-first",
|
||||
tag: "team-a",
|
||||
codexLimitPolicy: { use5h: false, useWeekly: true },
|
||||
},
|
||||
});
|
||||
const second = await providersDb.createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
email: "second@example.com",
|
||||
providerSpecificData: {
|
||||
workspaceId: "ws-second",
|
||||
tag: "team-b",
|
||||
requestDefaults: { reasoningEffort: "high" },
|
||||
},
|
||||
});
|
||||
const untouched = await providersDb.createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
email: "third@example.com",
|
||||
providerSpecificData: {
|
||||
workspaceId: "ws-third",
|
||||
tag: "team-c",
|
||||
requestDefaults: { reasoningEffort: "low", serviceTier: "priority" },
|
||||
},
|
||||
});
|
||||
|
||||
await settingsDb.updateSettings({ codexServiceTier: { enabled: true } });
|
||||
|
||||
const firstRun = await migrateCodexConnectionDefaultsFromLegacySettings();
|
||||
const rows = await providersDb.getProviderConnections({ provider: "codex" });
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
const settings = await settingsDb.getSettings();
|
||||
|
||||
assert.equal(firstRun.migrated, true);
|
||||
assert.deepEqual(firstRun.updatedConnectionIds.sort(), [first.id, second.id].sort());
|
||||
assert.deepEqual(byId.get(first.id).providerSpecificData.requestDefaults, {
|
||||
reasoningEffort: "medium",
|
||||
serviceTier: "priority",
|
||||
});
|
||||
assert.deepEqual(byId.get(second.id).providerSpecificData.requestDefaults, {
|
||||
reasoningEffort: "high",
|
||||
serviceTier: "priority",
|
||||
});
|
||||
assert.deepEqual(byId.get(untouched.id).providerSpecificData.requestDefaults, {
|
||||
reasoningEffort: "low",
|
||||
serviceTier: "priority",
|
||||
});
|
||||
assert.equal(byId.get(first.id).providerSpecificData.tag, "team-a");
|
||||
assert.deepEqual(byId.get(first.id).providerSpecificData.codexLimitPolicy, {
|
||||
use5h: false,
|
||||
useWeekly: true,
|
||||
});
|
||||
assert.ok(settings.codexConnectionDefaultsMigrationV1);
|
||||
|
||||
const secondRun = await migrateCodexConnectionDefaultsFromLegacySettings();
|
||||
assert.equal(secondRun.migrated, false);
|
||||
assert.deepEqual(secondRun.updatedConnectionIds, []);
|
||||
});
|
||||
|
||||
test("provider connection persistence normalizes request defaults without dropping unrelated keys", async () => {
|
||||
const created = await providersDb.createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
email: "normalize@example.com",
|
||||
providerSpecificData: {
|
||||
workspaceId: "ws-normalize",
|
||||
tag: "team-z",
|
||||
requestDefaults: {
|
||||
reasoningEffort: "HIGH",
|
||||
serviceTier: "fast",
|
||||
customFlag: "keep-me",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(created.providerSpecificData.requestDefaults, {
|
||||
reasoningEffort: "high",
|
||||
serviceTier: "priority",
|
||||
customFlag: "keep-me",
|
||||
});
|
||||
assert.equal(created.providerSpecificData.workspaceId, "ws-normalize");
|
||||
assert.equal(created.providerSpecificData.tag, "team-z");
|
||||
|
||||
const updated = await providersDb.updateProviderConnection(created.id, {
|
||||
providerSpecificData: {
|
||||
...created.providerSpecificData,
|
||||
requestDefaults: { reasoningEffort: "medium" },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(updated.providerSpecificData.requestDefaults, {
|
||||
reasoningEffort: "medium",
|
||||
});
|
||||
assert.equal(updated.providerSpecificData.workspaceId, "ws-normalize");
|
||||
assert.equal(updated.providerSpecificData.tag, "team-z");
|
||||
});
|
||||
@@ -7,8 +7,16 @@ import {
|
||||
getCodexRateLimitKey,
|
||||
getCodexResetTime,
|
||||
parseCodexQuotaHeaders,
|
||||
setDefaultFastServiceTierEnabled,
|
||||
} from "../../open-sse/executors/codex.ts";
|
||||
import {
|
||||
DEFAULT_THINKING_CONFIG,
|
||||
setThinkingBudgetConfig,
|
||||
ThinkingMode,
|
||||
} from "../../open-sse/services/thinkingBudget.ts";
|
||||
|
||||
test.afterEach(() => {
|
||||
setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG);
|
||||
});
|
||||
|
||||
test("Codex helper functions isolate rate-limit scopes and parse quota headers", () => {
|
||||
const quota = parseCodexQuotaHeaders(
|
||||
@@ -102,26 +110,121 @@ test("CodexExecutor.transformRequest injects default instructions, clamps reason
|
||||
|
||||
test("CodexExecutor.transformRequest preserves compact requests and native passthrough semantics", () => {
|
||||
const executor = new CodexExecutor();
|
||||
setDefaultFastServiceTierEnabled(true);
|
||||
const body = {
|
||||
_nativeCodexPassthrough: true,
|
||||
instructions: "keep this",
|
||||
stream: false,
|
||||
};
|
||||
const result = executor.transformRequest("gpt-5.3-codex", body, false, {
|
||||
requestEndpointPath: "/responses/compact",
|
||||
providerSpecificData: {
|
||||
requestDefaults: { serviceTier: "priority" },
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const body = {
|
||||
_nativeCodexPassthrough: true,
|
||||
instructions: "keep this",
|
||||
stream: false,
|
||||
};
|
||||
const result = executor.transformRequest("gpt-5.3-codex", body, false, {
|
||||
requestEndpointPath: "/responses/compact",
|
||||
});
|
||||
assert.equal(result._nativeCodexPassthrough, undefined);
|
||||
assert.equal(result.stream, undefined);
|
||||
assert.equal(result.service_tier, "priority");
|
||||
assert.equal(result.reasoning.effort, "medium");
|
||||
assert.equal(result.store, false);
|
||||
assert.equal(result.instructions, "keep this");
|
||||
});
|
||||
|
||||
assert.equal(result._nativeCodexPassthrough, undefined);
|
||||
assert.equal(result.stream, undefined);
|
||||
assert.equal(result.service_tier, "priority");
|
||||
assert.equal(result.store, false);
|
||||
assert.equal(result.instructions, "keep this");
|
||||
} finally {
|
||||
setDefaultFastServiceTierEnabled(false);
|
||||
}
|
||||
test("CodexExecutor.transformRequest applies per-connection reasoning and service tier defaults", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const result = executor.transformRequest(
|
||||
"gpt-5.3-codex",
|
||||
{ model: "gpt-5.3-codex", input: [] },
|
||||
false,
|
||||
{
|
||||
providerSpecificData: {
|
||||
requestDefaults: {
|
||||
reasoningEffort: "high",
|
||||
serviceTier: "priority",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.reasoning.effort, "high");
|
||||
assert.equal(result.service_tier, "priority");
|
||||
});
|
||||
|
||||
test("CodexExecutor.transformRequest keeps explicit request values ahead of connection defaults", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const result = executor.transformRequest(
|
||||
"gpt-5.3-codex",
|
||||
{
|
||||
model: "gpt-5.3-codex",
|
||||
input: [],
|
||||
reasoning_effort: "none",
|
||||
service_tier: "standard",
|
||||
},
|
||||
false,
|
||||
{
|
||||
providerSpecificData: {
|
||||
requestDefaults: {
|
||||
reasoningEffort: "high",
|
||||
serviceTier: "priority",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.reasoning.effort, "none");
|
||||
assert.equal(result.service_tier, "standard");
|
||||
});
|
||||
|
||||
test("CodexExecutor.transformRequest lets model suffix beat connection reasoning defaults", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const result = executor.transformRequest(
|
||||
"gpt-5.3-codex-high",
|
||||
{ model: "gpt-5.3-codex-high", input: [] },
|
||||
false,
|
||||
{
|
||||
providerSpecificData: {
|
||||
requestDefaults: {
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.model, "gpt-5.3-codex");
|
||||
assert.equal(result.reasoning.effort, "high");
|
||||
});
|
||||
|
||||
test("CodexExecutor.transformRequest does not apply connection reasoning defaults when Thinking Budget is not passthrough", () => {
|
||||
const executor = new CodexExecutor();
|
||||
setThinkingBudgetConfig({ mode: ThinkingMode.AUTO });
|
||||
|
||||
const noDefaults = executor.transformRequest(
|
||||
"gpt-5.3-codex",
|
||||
{ model: "gpt-5.3-codex", input: [] },
|
||||
false,
|
||||
{
|
||||
providerSpecificData: {
|
||||
requestDefaults: {
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
const explicit = executor.transformRequest(
|
||||
"gpt-5.3-codex",
|
||||
{ model: "gpt-5.3-codex", input: [], reasoning_effort: "high" },
|
||||
false,
|
||||
{
|
||||
providerSpecificData: {
|
||||
requestDefaults: {
|
||||
reasoningEffort: "low",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(noDefaults.reasoning, undefined);
|
||||
assert.equal(explicit.reasoning.effort, "high");
|
||||
});
|
||||
|
||||
test("CodexExecutor.refreshCredentials refreshes OAuth tokens and returns null without a refresh token", async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { shouldUseNativeCodexPassthrough } from "../../open-sse/handlers/chatCor
|
||||
import { translateRequest } from "../../open-sse/translator/index.ts";
|
||||
import { GithubExecutor } from "../../open-sse/executors/github.ts";
|
||||
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
|
||||
import { CodexExecutor, setDefaultFastServiceTierEnabled } from "../../open-sse/executors/codex.ts";
|
||||
import { CodexExecutor } from "../../open-sse/executors/codex.ts";
|
||||
import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts";
|
||||
import { extractUsageFromResponse } from "../../open-sse/handlers/usageExtractor.ts";
|
||||
import {
|
||||
@@ -218,20 +218,19 @@ test("shouldUseNativeCodexPassthrough only enables responses-native Codex reques
|
||||
);
|
||||
});
|
||||
|
||||
test("CodexExecutor can force fast service tier from settings", () => {
|
||||
setDefaultFastServiceTierEnabled(true);
|
||||
|
||||
try {
|
||||
const executor = new CodexExecutor();
|
||||
const transformed = executor.transformRequest(
|
||||
"gpt-5.1-codex",
|
||||
{ model: "gpt-5.1-codex", input: [] },
|
||||
true
|
||||
);
|
||||
assert.equal(transformed.service_tier, "priority");
|
||||
} finally {
|
||||
setDefaultFastServiceTierEnabled(false);
|
||||
}
|
||||
test("CodexExecutor can apply per-connection fast service tier defaults", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const transformed = executor.transformRequest(
|
||||
"gpt-5.1-codex",
|
||||
{ model: "gpt-5.1-codex", input: [] },
|
||||
true,
|
||||
{
|
||||
providerSpecificData: {
|
||||
requestDefaults: { serviceTier: "priority" },
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.equal(transformed.service_tier, "priority");
|
||||
});
|
||||
|
||||
test("CodexExecutor always requests SSE accept header", () => {
|
||||
@@ -275,7 +274,8 @@ test("CodexExecutor preserves native responses payloads for Codex passthrough",
|
||||
assert.equal(transformed.instructions, "custom system prompt");
|
||||
assert.equal(transformed.store, false);
|
||||
assert.deepEqual(transformed.metadata, { source: "codex-client" });
|
||||
assert.equal(transformed.reasoning_effort, "high");
|
||||
assert.equal(transformed.reasoning.effort, "high");
|
||||
assert.equal(transformed.reasoning_effort, undefined);
|
||||
assert.ok(!("_nativeCodexPassthrough" in transformed));
|
||||
});
|
||||
|
||||
|
||||
@@ -237,6 +237,49 @@ test("createSSEStream passthrough preserves Responses API events and completion
|
||||
assert.equal(onCompletePayload.providerPayload.summary.object, "response");
|
||||
});
|
||||
|
||||
test("buildStreamSummaryFromEvents falls back to response.output_text.delta when completed output is empty", () => {
|
||||
const summary = buildStreamSummaryFromEvents(
|
||||
[
|
||||
{
|
||||
index: 0,
|
||||
data: {
|
||||
type: "response.output_text.delta",
|
||||
delta: "Hello ",
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
data: {
|
||||
type: "response.output_text.delta",
|
||||
delta: "world",
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
data: {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_fallback",
|
||||
object: "response",
|
||||
model: "gpt-5.4",
|
||||
status: "completed",
|
||||
output: [],
|
||||
usage: { output_tokens: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
"gpt-5.4"
|
||||
);
|
||||
|
||||
assert.equal(summary.object, "response");
|
||||
assert.equal(summary.output[0].type, "message");
|
||||
assert.equal(summary.output[0].content[0].type, "output_text");
|
||||
assert.equal(summary.output[0].content[0].text, "Hello world");
|
||||
assert.equal(summary.usage.output_tokens, 2);
|
||||
});
|
||||
|
||||
test("createSSEStream translate mode aborts on Responses failure with rate limit error", async () => {
|
||||
let onCompletePayload = null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user