diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 04ba86bc79..55a6df207c 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -6,6 +6,20 @@ import { refreshCodexToken } from "../services/tokenRefresh.ts"; // 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 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. @@ -103,6 +117,13 @@ export class CodexExecutor extends BaseExecutor { // Ensure store is false (Codex requirement) body.store = false; + const requestServiceTier = normalizeServiceTierValue(body.service_tier); + if (requestServiceTier) { + body.service_tier = requestServiceTier; + } else if (defaultFastServiceTierEnabled) { + body.service_tier = CODEX_FAST_WIRE_VALUE; + } + // 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"]; diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index b4b3b4709f..7a7ed10adb 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -363,6 +363,7 @@ export function openaiToOpenAIResponsesRequest( } // Pass through relevant fields + if (root.service_tier !== undefined) result.service_tier = root.service_tier; if (root.temperature !== undefined) result.temperature = root.temperature; if (root.max_tokens !== undefined) result.max_tokens = root.max_tokens; if (root.top_p !== undefined) result.top_p = root.top_p; diff --git a/src/app/(dashboard)/dashboard/settings/components/CodexServiceTierTab.tsx b/src/app/(dashboard)/dashboard/settings/components/CodexServiceTierTab.tsx new file mode 100644 index 0000000000..21b36c0442 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/CodexServiceTierTab.tsx @@ -0,0 +1,100 @@ +"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 ( + +
+
+ +
+
+

Codex Fast Service Tier

+

+ Inject `service_tier=fast` into Codex requests when the client leaves it unset. +

+
+ {status === "saved" && ( + + check_circle + Saved + + )} + {status === "error" && ( + + error + Failed to save + + )} +
+ +
+
+

Force fast tier for Codex

+

+ Off by default. Applies only to Codex requests and does not override an explicit tier. +

+
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/page.tsx b/src/app/(dashboard)/dashboard/settings/page.tsx index d307eb3801..428384cc2d 100644 --- a/src/app/(dashboard)/dashboard/settings/page.tsx +++ b/src/app/(dashboard)/dashboard/settings/page.tsx @@ -12,6 +12,7 @@ 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 ModelAliasesTab from "./components/ModelAliasesTab"; import BackgroundDegradationTab from "./components/BackgroundDegradationTab"; @@ -85,6 +86,7 @@ export default function SettingsPage() { {activeTab === "ai" && (
+
diff --git a/src/app/api/settings/codex-service-tier/route.ts b/src/app/api/settings/codex-service-tier/route.ts new file mode 100644 index 0000000000..382e6c856a --- /dev/null +++ b/src/app/api/settings/codex-service-tier/route.ts @@ -0,0 +1,55 @@ +import { NextResponse, type Request } 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; + setDefaultFastServiceTierEnabled(config.enabled); + await updateSettings({ codexServiceTier: config }); + + 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 }); + } +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 8a6296fecf..fa302f1d18 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -52,7 +52,9 @@ export async function register() { try { const { getSettings } = await import("@/lib/db/settings"); const { setCustomAliases } = await import("@omniroute/open-sse/services/modelDeprecation.ts"); + const { setDefaultFastServiceTierEnabled } = await import("@omniroute/open-sse/executors/codex.ts"); const settings = await getSettings(); + if (settings.modelAliases) { const aliases = typeof settings.modelAliases === "string" @@ -65,9 +67,19 @@ export async function register() { ); } } + + const persisted = + typeof settings.codexServiceTier === "string" + ? JSON.parse(settings.codexServiceTier) + : settings.codexServiceTier; + + if (typeof persisted?.enabled === "boolean") { + setDefaultFastServiceTierEnabled(persisted.enabled); + console.log(`[STARTUP] Restored Codex fast service tier: ${persisted.enabled ? "on" : "off"}`); + } } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); - console.warn("[STARTUP] Could not restore model aliases:", msg); + console.warn("[STARTUP] Could not restore runtime settings:", msg); } // Compliance: Initialize audit_log table + cleanup expired logs diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index d0634eb28c..9ea77f3f6e 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -439,6 +439,12 @@ 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), diff --git a/tests/unit/plan3-p0.test.mjs b/tests/unit/plan3-p0.test.mjs index 4b77c4b124..c240db62ed 100644 --- a/tests/unit/plan3-p0.test.mjs +++ b/tests/unit/plan3-p0.test.mjs @@ -6,7 +6,10 @@ import { getModelInfoCore } from "../../open-sse/services/model.ts"; import { detectFormat } from "../../open-sse/services/provider.ts"; import { translateRequest } from "../../open-sse/translator/index.ts"; import { GithubExecutor } from "../../open-sse/executors/github.ts"; -import { CodexExecutor } from "../../open-sse/executors/codex.ts"; +import { + CodexExecutor, + setDefaultFastServiceTierEnabled, +} from "../../open-sse/executors/codex.ts"; import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts"; import { extractUsageFromResponse } from "../../open-sse/handlers/usageExtractor.ts"; import { parseSSEToResponsesOutput } from "../../open-sse/handlers/sseParser.ts"; @@ -60,6 +63,32 @@ test("CodexExecutor forces stream=true for upstream compatibility", () => { assert.equal(transformed.stream, true); }); +test("CodexExecutor maps fast service tier to priority", () => { + const executor = new CodexExecutor(); + const transformed = executor.transformRequest( + "gpt-5.1-codex", + { model: "gpt-5.1-codex", input: [], service_tier: "fast" }, + true + ); + assert.equal(transformed.service_tier, "priority"); +}); + +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 always requests SSE accept header", () => { const executor = new CodexExecutor(); const headers = executor.buildHeaders({ accessToken: "test-token" }, false); @@ -166,6 +195,24 @@ test("translateRequest normalizes openai-responses input string into list payloa assert.equal(translated.input[0].content[0].text, "hello from responses"); }); +test("translateRequest preserves service_tier when converting openai to openai-responses", () => { + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.OPENAI_RESPONSES, + "gpt-5.1-codex", + { + model: "gpt-5.1-codex", + messages: [{ role: "user", content: "hello from chat completions" }], + service_tier: "fast", + stream: false, + }, + false + ); + + assert.equal(translated.service_tier, "fast"); + assert.ok(Array.isArray(translated.input)); +}); + test("parseSSEToResponsesOutput parses completed response from SSE payload", () => { const rawSSE = [ "event: response.created",