;
onClose: () => void;
@@ -100,6 +101,7 @@ export default function AddApiKeyModal({
const [formData, setFormData] = useState({
name: "",
apiKey: "",
+ defaultModel: "",
priority: 1,
baseUrl: initialBaseUrl || defaultBaseUrl,
cx: "",
@@ -303,6 +305,7 @@ export default function AddApiKeyModal({
apiKey: credentialInput.trim() || undefined,
priority: formData.priority,
testStatus: "active",
+ defaultModel: isCompatible ? formData.defaultModel.trim() || undefined : undefined,
providerSpecificData,
};
@@ -696,6 +699,16 @@ export default function AddApiKeyModal({
onChange={(patch) => setFormData({ ...formData, ...patch })}
t={t}
/>
+ {isCompatible && (
+ setFormData({ ...formData, defaultModel: e.target.value })}
+ placeholder={isAnthropic ? "claude-3-5-sonnet-latest" : "gpt-4o-mini"}
+ hint={t("compatibleDefaultModelHint")}
+ data-testid="compat-default-model-input"
+ />
+ )}
{isCompatible && !isCcCompatible && (
{isAnthropic
@@ -832,6 +845,7 @@ export default function AddApiKeyModal({
disabled={
!formData.name ||
(!isCompatible && !apiKeyOptional && !formData.apiKey) ||
+ (isCompatible && !formData.defaultModel.trim()) ||
(isGooglePse && !formData.cx.trim()) ||
saving ||
(usesBaseUrl && !formData.baseUrl.trim() && !defaultBaseUrl)
diff --git a/src/app/api/cli-tools/droid-settings/route.ts b/src/app/api/cli-tools/droid-settings/route.ts
index 7313faef81..856edf2e49 100644
--- a/src/app/api/cli-tools/droid-settings/route.ts
+++ b/src/app/api/cli-tools/droid-settings/route.ts
@@ -11,10 +11,15 @@ import {
} from "@/shared/services/cliRuntime";
import { createBackup } from "@/shared/services/backupService";
import { saveCliToolLastConfigured, deleteCliToolLastConfigured } from "@/lib/db/cliToolState";
-import { cliModelConfigSchema } from "@/shared/validation/schemas";
+import { cliMultiModelConfigSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { resolveApiKey } from "@/shared/services/apiKeyResolver";
import { readJsoncConfig } from "../_lib/jsoncConfig";
+import {
+ buildDroidCustomModels,
+ isOmniRouteCustomModel,
+ normalizeDroidModelList,
+} from "@/shared/services/droidCustomModels";
const getDroidSettingsPath = () => getCliPrimaryConfigPath("droid");
const getDroidDir = () => path.dirname(getDroidSettingsPath());
@@ -25,10 +30,12 @@ const getDroidDir = () => path.dirname(getDroidSettingsPath());
// "installed but not configured" instead of a 500 misread as "not installed".
const readSettings = async () => readJsoncConfig(getDroidSettingsPath());
-// Check if settings has OmniRoute customModels
+// Check if settings has OmniRoute customModels.
+// Multi-model entries are stored as `custom:OmniRoute-0`, `custom:OmniRoute-1`, …
+// (Ported from upstream PR decolua/9router#618.)
const hasOmniRouteConfig = (settings: any) => {
if (!settings || !settings.customModels) return false;
- return settings.customModels.some((m) => m.id === "custom:OmniRoute-0");
+ return settings.customModels.some(isOmniRouteCustomModel);
};
// GET - Check droid CLI and read current settings
@@ -103,13 +110,30 @@ export async function POST(request: Request) {
// (#549) Extract keyId BEFORE validation — Zod strips unknown fields!
const keyId = typeof rawBody?.keyId === "string" ? rawBody.keyId.trim() : null;
- const validation = validateBody(cliModelConfigSchema, rawBody);
+ const validation = validateBody(cliMultiModelConfigSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
- const { baseUrl, model } = validation.data;
+ const { baseUrl, model, models, activeModel } = validation.data;
const apiKey = await resolveApiKey(keyId, validation.data.apiKey);
+ // Multi-model support (ported from decolua/9router#618): accept either a
+ // `models` array or the legacy single `model` string.
+ const modelList = normalizeDroidModelList({ model, models });
+ if (modelList.length === 0) {
+ return NextResponse.json(
+ {
+ error: {
+ message: "Invalid request",
+ details: [
+ { field: "models", message: "baseUrl and at least one model are required" },
+ ],
+ },
+ },
+ { status: 400 }
+ );
+ }
+
const droidDir = getDroidDir();
const settingsPath = getDroidSettingsPath();
@@ -133,26 +157,19 @@ export async function POST(request: Request) {
settings.customModels = [];
}
- // Remove existing OmniRoute config if any
- settings.customModels = settings.customModels.filter((m) => m.id !== "custom:OmniRoute-0");
+ // Remove every existing OmniRoute config (multi-model: index 0..N)
+ settings.customModels = settings.customModels.filter((m) => !isOmniRouteCustomModel(m));
// Normalize baseUrl to ensure /v1 suffix
const normalizedBaseUrl = baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`;
- // Add new OmniRoute config
- const customModel = {
- model: model,
- id: "custom:OmniRoute-0",
- index: 0,
+ // Build and prepend OmniRoute entries (one per requested model)
+ const newEntries = buildDroidCustomModels(modelList, {
baseUrl: normalizedBaseUrl,
apiKey: apiKey || "your_api_key",
- displayName: model,
- maxOutputTokens: 131072,
- noImageSupport: false,
- provider: "openai",
- };
-
- settings.customModels.unshift(customModel);
+ activeModel,
+ });
+ settings.customModels = [...newEntries, ...settings.customModels];
// Write settings
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
@@ -206,9 +223,9 @@ export async function DELETE(request: Request) {
throw error;
}
- // Remove OmniRoute customModels
+ // Remove OmniRoute customModels (every index, multi-model)
if (settings.customModels) {
- settings.customModels = settings.customModels.filter((m) => m.id !== "custom:OmniRoute-0");
+ settings.customModels = settings.customModels.filter((m) => !isOmniRouteCustomModel(m));
// Remove customModels array if empty
if (settings.customModels.length === 0) {
diff --git a/src/app/api/headroom/start/route.ts b/src/app/api/headroom/start/route.ts
new file mode 100644
index 0000000000..e2fe685db7
--- /dev/null
+++ b/src/app/api/headroom/start/route.ts
@@ -0,0 +1,49 @@
+import { getSettings } from "@/lib/db/settings";
+import {
+ DEFAULT_HEADROOM_URL,
+ isLoopbackHeadroomUrl,
+ parsePortFromHeadroomUrl,
+} from "@/lib/headroom/detect";
+import { startHeadroomProxy, HeadroomError } from "@/lib/headroom/process";
+import { createErrorResponse } from "@/lib/api/errorResponse";
+import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
+
+export const dynamic = "force-dynamic";
+
+export async function POST(): Promise {
+ try {
+ const settings = await getSettings();
+ const url =
+ typeof settings.headroomUrl === "string" && settings.headroomUrl
+ ? settings.headroomUrl
+ : DEFAULT_HEADROOM_URL;
+
+ // Pair commit 50ed79fe: refuse to spawn against a non-loopback URL.
+ // External Docker sidecars must be started outside OmniRoute.
+ if (!isLoopbackHeadroomUrl(url)) {
+ return createErrorResponse({
+ status: 400,
+ message: "External Headroom proxies must be started outside OmniRoute",
+ type: "invalid_request",
+ });
+ }
+
+ const port = parsePortFromHeadroomUrl(url) ?? 8787;
+ const result = await startHeadroomProxy({ port });
+ return Response.json({ success: true, ...result });
+ } catch (error) {
+ if (error instanceof HeadroomError && error.code === "NOT_INSTALLED") {
+ return createErrorResponse({
+ status: 400,
+ message: error.message,
+ type: "invalid_request",
+ details: { code: error.code },
+ });
+ }
+ return createErrorResponse({
+ status: 500,
+ message: sanitizeErrorMessage(error),
+ type: "server_error",
+ });
+ }
+}
diff --git a/src/app/api/headroom/status/route.ts b/src/app/api/headroom/status/route.ts
new file mode 100644
index 0000000000..03d08ee6c5
--- /dev/null
+++ b/src/app/api/headroom/status/route.ts
@@ -0,0 +1,26 @@
+import { getSettings } from "@/lib/db/settings";
+import { DEFAULT_HEADROOM_URL, getHeadroomStatus } from "@/lib/headroom/detect";
+import { getManagedPid } from "@/lib/headroom/process";
+import { createErrorResponse } from "@/lib/api/errorResponse";
+import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(): Promise {
+ try {
+ const settings = await getSettings();
+ const url =
+ typeof settings.headroomUrl === "string" && settings.headroomUrl
+ ? settings.headroomUrl
+ : DEFAULT_HEADROOM_URL;
+ const status = await getHeadroomStatus(url);
+ const managedPid = getManagedPid();
+ return Response.json({ ...status, url, managedPid });
+ } catch (error) {
+ return createErrorResponse({
+ status: 500,
+ message: sanitizeErrorMessage(error),
+ type: "server_error",
+ });
+ }
+}
diff --git a/src/app/api/headroom/stop/route.ts b/src/app/api/headroom/stop/route.ts
new file mode 100644
index 0000000000..8472accdb5
--- /dev/null
+++ b/src/app/api/headroom/stop/route.ts
@@ -0,0 +1,19 @@
+import { stopHeadroomProxy } from "@/lib/headroom/process";
+import { createErrorResponse } from "@/lib/api/errorResponse";
+import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
+
+export const dynamic = "force-dynamic";
+
+export async function POST(): Promise {
+ try {
+ const result = stopHeadroomProxy();
+ const status = result.stopped ? 200 : 409;
+ return Response.json(result, { status });
+ } catch (error) {
+ return createErrorResponse({
+ status: 500,
+ message: sanitizeErrorMessage(error),
+ type: "server_error",
+ });
+ }
+}
diff --git a/src/app/api/oauth/codex/import/route.ts b/src/app/api/oauth/codex/import/route.ts
new file mode 100644
index 0000000000..5e37803696
--- /dev/null
+++ b/src/app/api/oauth/codex/import/route.ts
@@ -0,0 +1,107 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { normalizeCodexImportRecord, flattenCodexImportPayload } from "@/lib/oauth/services/codexImport";
+import { createProviderConnection } from "@/models";
+import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
+
+/**
+ * POST /api/oauth/codex/import
+ *
+ * Bulk-import Codex (OpenAI) accounts from JSON payloads produced by the Codex
+ * CLI or common token-export tools. Each item may be a flat export
+ * (`access_token`, `refresh_token`, …) or the CLI's nested `auth.json` shape.
+ *
+ * Body: `{ accounts: object | object[] }`
+ *
+ * Returns a per-record summary so partial successes are surfaced to the UI.
+ *
+ * Ported from decolua/9router#1257 (beaaan).
+ */
+
+const bodySchema = z.object({
+ accounts: z.union([z.record(z.unknown()), z.array(z.unknown())], {
+ errorMap: () => ({ message: "accounts must be an object or an array of objects" }),
+ }),
+});
+
+async function requireAuth(request: Request): Promise {
+ if (!(await isAuthRequired(request))) return null;
+ if (await isAuthenticated(request)) return null;
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+}
+
+export async function POST(request: Request) {
+ const authResponse = await requireAuth(request);
+ if (authResponse) return authResponse;
+
+ let rawBody: unknown;
+ try {
+ rawBody = await request.json();
+ } catch {
+ return NextResponse.json(
+ { error: "Invalid or empty JSON body" },
+ { status: 400 },
+ );
+ }
+
+ const parsed = bodySchema.safeParse(rawBody);
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.errors[0]?.message ?? "Invalid request body" },
+ { status: 400 },
+ );
+ }
+
+ const flat = flattenCodexImportPayload(parsed.data.accounts);
+ if (!flat.ok) {
+ return NextResponse.json({ error: flat.error }, { status: 400 });
+ }
+ if (flat.records.length === 0) {
+ return NextResponse.json(
+ { error: "No accounts found in payload" },
+ { status: 400 },
+ );
+ }
+
+ const results: Array<
+ | { index: number; ok: true; connectionId: string; email: string }
+ | { index: number; ok: false; error: string }
+ > = [];
+ let imported = 0;
+ let failed = 0;
+
+ for (let i = 0; i < flat.records.length; i++) {
+ const norm = normalizeCodexImportRecord(flat.records[i]);
+ if (!norm.ok) {
+ failed += 1;
+ results.push({ index: i, ok: false, error: norm.error });
+ continue;
+ }
+ try {
+ const conn = await createProviderConnection(norm.payload as Record);
+ imported += 1;
+ results.push({
+ index: i,
+ ok: true,
+ connectionId: String(conn.id),
+ email: String(conn.email ?? norm.payload.email),
+ });
+ } catch (error) {
+ failed += 1;
+ results.push({
+ index: i,
+ ok: false,
+ error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)),
+ });
+ }
+ }
+
+ return NextResponse.json({
+ success: failed === 0,
+ imported,
+ failed,
+ total: flat.records.length,
+ results,
+ });
+}
diff --git a/src/app/api/oauth/cursor/auto-import/route.ts b/src/app/api/oauth/cursor/auto-import/route.ts
index b5a114d580..85f9c8baa8 100755
--- a/src/app/api/oauth/cursor/auto-import/route.ts
+++ b/src/app/api/oauth/cursor/auto-import/route.ts
@@ -2,8 +2,65 @@ import { NextResponse } from "next/server";
import { access, constants, readFile } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
+import { execFile } from "child_process";
+import { promisify } from "util";
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
+const execFileAsync = promisify(execFile);
+
+/**
+ * Probe dependencies for {@link verifyLinuxCursorInstalled}. Injectable so the
+ * guard can be unit-tested without spawning a real `which` process or touching
+ * the filesystem — mirrors the `__setExecFileImpl` pattern in
+ * `src/lib/cli-helper/tool-detector.ts`.
+ */
+export interface CursorInstallProbe {
+ /** Runs `which `; rejects when the binary is not on PATH. */
+ execFile?: (
+ file: string,
+ args: string[],
+ options: { timeout: number }
+ ) => Promise<{ stdout: string; stderr: string }>;
+ /** Resolves when the path is readable; rejects otherwise (e.g. `fs.access`). */
+ access?: (path: string, mode: number) => Promise;
+ /** Override the home directory used to locate the `.desktop` fallback. */
+ home?: string;
+}
+
+/**
+ * On Linux, verify that the Cursor IDE is actually installed before trusting
+ * leftover config files (state.vscdb). A removed Cursor install can leave its
+ * `~/.config/Cursor/...` directory behind, which would otherwise trigger a
+ * false-positive auto-import and create a phantom Cursor provider connection.
+ *
+ * The check prefers `which cursor` and falls back to a readable
+ * `~/.local/share/applications/cursor.desktop` entry (the desktop launcher a
+ * package install drops even when the CLI shim is not on PATH).
+ *
+ * Port of decolua/9router#313 — only the linux probe is added; macOS/Windows
+ * keep their existing behavior (no install probe).
+ */
+export async function verifyLinuxCursorInstalled(
+ probe: CursorInstallProbe = {}
+): Promise {
+ const exec = probe.execFile ?? execFileAsync;
+ const canAccess = probe.access ?? access;
+ const home = probe.home ?? homedir();
+
+ try {
+ await exec("which", ["cursor"], { timeout: 5000 });
+ return true;
+ } catch {
+ try {
+ const desktopFile = join(home, ".local/share/applications/cursor.desktop");
+ await canAccess(desktopFile, constants.R_OK);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+}
+
/**
* Known key names Cursor IDE has used over time to persist the auth token
* and machine id in the local `state.vscdb`. Order matters — the first
@@ -184,6 +241,17 @@ async function tryIdeAuth(): Promise<{
};
}
} else {
+ // On Linux, verify Cursor is actually installed before trusting leftover
+ // config files — a removed install can leave ~/.config/Cursor behind and
+ // would otherwise create a phantom Cursor connection (port: 9router#313).
+ if (platform === "linux" && !(await verifyLinuxCursorInstalled())) {
+ return {
+ found: false,
+ error:
+ "Cursor config files found but Cursor IDE does not appear to be " +
+ "installed. Skipping auto-import.",
+ };
+ }
dbPath = candidates[0];
}
diff --git a/src/app/api/provider-nodes/validate/route.ts b/src/app/api/provider-nodes/validate/route.ts
index 024818fcaa..c7bc7b7648 100644
--- a/src/app/api/provider-nodes/validate/route.ts
+++ b/src/app/api/provider-nodes/validate/route.ts
@@ -16,6 +16,48 @@ import { isCcCompatibleProviderEnabled } from "@/shared/utils/featureFlags";
import { providerNodeValidateSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
+// Matches a base URL whose host is localhost / 127.0.0.1 (with an optional port).
+const LOCALHOST_BASE_URL_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?(?:[/?#]|$)/i;
+
+// Extract the underlying network error code from a SafeOutboundFetchError chain.
+// safeOutboundFetch wraps fetch failures and preserves the original error as `cause`,
+// so a connection-refused surfaces as cause.code === "ECONNREFUSED".
+function getOutboundCauseCode(error: unknown): string | undefined {
+ const cause = (error as { cause?: unknown })?.cause;
+ if (cause && typeof cause === "object") {
+ const direct = (cause as { code?: unknown }).code;
+ if (typeof direct === "string") return direct;
+ const nested = (cause as { cause?: { code?: unknown } }).cause?.code;
+ if (typeof nested === "string") return nested;
+ }
+ return undefined;
+}
+
+// When a connection error happens against a localhost base URL, the most common
+// cause is running OmniRoute in Docker — `localhost` then points at the container,
+// not the host. Augment the surfaced message with an actionable hint in that case.
+// Ported from decolua/9router#642.
+export function augmentDockerLocalhostHint(
+ error: unknown,
+ baseUrl: string | undefined,
+ fallbackMessage: string
+): string {
+ if (!baseUrl || !LOCALHOST_BASE_URL_RE.test(baseUrl)) return fallbackMessage;
+
+ const code =
+ error instanceof SafeOutboundFetchError && error.code === "TIMEOUT"
+ ? "ETIMEDOUT"
+ : getOutboundCauseCode(error);
+
+ if (code === "ECONNREFUSED") {
+ return "Connection refused — are you running OmniRoute in Docker? localhost points to the container, not your host. Use your host IP (e.g. http://192.168.x.x:11434) or http://host.docker.internal:11434 on Linux/Mac.";
+ }
+ if (code === "ETIMEDOUT") {
+ return "Connection timeout — are you running OmniRoute in Docker? Use your host IP (e.g. http://192.168.x.x:11434) or http://host.docker.internal:11434 on Linux/Mac.";
+ }
+ return fallbackMessage;
+}
+
function sanitizeAnthropicBaseUrl(baseUrl: string) {
return (baseUrl || "")
.trim()
@@ -212,18 +254,19 @@ export async function POST(request) {
}
return NextResponse.json({ valid: false, error: getModelsErrorMessage(res.status) });
} catch (error) {
+ const attemptedBaseUrl =
+ rawBody && typeof rawBody === "object" && "baseUrl" in rawBody
+ ? String((rawBody as { baseUrl?: unknown }).baseUrl || "")
+ : "";
const status = getSafeOutboundFetchErrorStatus(error);
if (status) {
- const message = error instanceof Error ? error.message : "Validation failed";
+ const rawMessage = error instanceof Error ? error.message : "Validation failed";
+ const message = augmentDockerLocalhostHint(error, attemptedBaseUrl, rawMessage);
if (
error instanceof SafeOutboundFetchError &&
error.code === "URL_GUARD_BLOCKED" &&
message.includes(PROVIDER_URL_BLOCKED_MESSAGE)
) {
- const attemptedBaseUrl =
- rawBody && typeof rawBody === "object" && "baseUrl" in rawBody
- ? String((rawBody as { baseUrl?: unknown }).baseUrl || "")
- : "";
logAuditEvent({
action: "provider.validation.ssrf_blocked",
actor: "admin",
@@ -242,6 +285,7 @@ export async function POST(request) {
return NextResponse.json({ error: message }, { status });
}
console.log("Error validating provider node:", error);
- return NextResponse.json({ error: "Validation failed" }, { status: 500 });
+ const message = augmentDockerLocalhostHint(error, attemptedBaseUrl, "Validation failed");
+ return NextResponse.json({ error: message }, { status: 500 });
}
}
diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts
index 51053397c4..21b3894eea 100755
--- a/src/app/api/providers/[id]/models/route.ts
+++ b/src/app/api/providers/[id]/models/route.ts
@@ -191,6 +191,9 @@ const NAMED_OPENAI_STYLE_PROVIDERS = new Set([
// but was left out of the sweep, so it served a stale hardcoded seed (grok-3, grok-2-1212,
// claude-3.7-sonnet …). Live fetch keeps it fresh; seed stays as the offline fallback.
"api-airforce",
+ // DGrid is an OpenAI-compatible gateway whose default seed is the free auto-router;
+ // the full model catalog is discovered live from https://api.dgrid.ai/v1/models.
+ "dgrid",
]);
function isNamedOpenAIStyleProvider(provider: string): boolean {
diff --git a/src/app/globals.css b/src/app/globals.css
index 489a58688f..4df43dff07 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -496,3 +496,15 @@ select option {
.react-flow__controls-button svg {
fill: var(--color-text-main);
}
+
+/* Desktop sidebar visibility must not depend on Tailwind's hidden/lg:flex cascade
+ (ordering/specificity can collapse it). Explicit class + 1024px media query. */
+.dashboard-sidebar-desktop {
+ display: none;
+ min-height: 0;
+}
+@media (min-width: 1024px) {
+ .dashboard-sidebar-desktop {
+ display: flex;
+ }
+}
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index 2b8b5edf35..4296f4eba3 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -4173,6 +4173,8 @@
"optional": "Optional",
"anthropicCompatibleName": "Anthropic Compatible",
"openaiCompatibleName": "OpenAI Compatible",
+ "compatibleDefaultModelLabel": "Default Model",
+ "compatibleDefaultModelHint": "Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.",
"failedImportModels": "Failed to import models",
"noModelsReturnedFromEndpoint": "No models returned from /models endpoint.",
"importingModelsProgress": "Importing {current} of {total} models...",
diff --git a/src/lib/combos/autoPromote.ts b/src/lib/combos/autoPromote.ts
new file mode 100644
index 0000000000..e5928b3cc8
--- /dev/null
+++ b/src/lib/combos/autoPromote.ts
@@ -0,0 +1,92 @@
+/**
+ * combos/autoPromote.ts — pure reorder helper for the "auto-promote successful
+ * combo model" feature.
+ *
+ * When the `comboAutoPromoteEnabled` setting is on and a combo model responds
+ * successfully, the winning model is moved to position #1 of the persisted
+ * combo so future requests try it first.
+ *
+ * OmniRoute stores `combo.models` as an array of `ComboStep` objects
+ * (`{ kind: "model", model, ... }`), unlike the upstream project which stores
+ * plain model strings. This helper accepts both shapes and reorders in place
+ * without mutating the input, returning `null` when no change is required
+ * (winner already first, winner absent, or empty list).
+ */
+
+type StepLike = { kind?: unknown; model?: unknown } | string;
+
+/** Extract the model id from a step entry (object or bare string). */
+export function comboStepModelId(step: unknown): string | null {
+ if (typeof step === "string") return step.trim().length > 0 ? step : null;
+ if (step && typeof step === "object") {
+ const model = (step as { model?: unknown }).model;
+ if (typeof model === "string" && model.trim().length > 0) return model;
+ }
+ return null;
+}
+
+/**
+ * Return a reordered copy of `models` with the entry matching `winningModel`
+ * moved to the front, or `null` if no reordering is needed/possible.
+ *
+ * Pure: never mutates the input array or its entries.
+ */
+export function promoteModelToFront(
+ models: readonly T[] | null | undefined,
+ winningModel: string | null | undefined
+): T[] | null {
+ if (!Array.isArray(models) || models.length === 0) return null;
+ if (typeof winningModel !== "string" || winningModel.length === 0) return null;
+
+ const matchIndex = models.findIndex((step) => comboStepModelId(step) === winningModel);
+ // Winner not in the combo (e.g. global fallback) or already first → no-op.
+ if (matchIndex <= 0) return null;
+
+ const winner = models[matchIndex];
+ const rest = models.filter((_, index) => index !== matchIndex);
+ return [winner, ...rest];
+}
+
+interface PromoteComboDeps {
+ updateCombo: (id: string, data: { models: unknown[] }) => Promise;
+ info?: (tag: string, msg: string) => void;
+ warn?: (tag: string, msg: string) => void;
+}
+
+/**
+ * Persist the auto-promotion of a successful combo model to position #1.
+ *
+ * Opt-in via the `comboAutoPromoteEnabled` setting. Best-effort: a DB failure is
+ * logged and swallowed so it never affects the already-successful response.
+ * No-op when the flag is off, the combo has no id, or the model is already first
+ * / absent. `updateCombo` is injected so this stays unit-testable without a DB.
+ */
+export async function promoteSuccessfulComboModel(
+ combo: { id?: unknown; name?: unknown; models?: unknown } | null | undefined,
+ winningModel: string | null | undefined,
+ settings: Record | null | undefined,
+ deps: PromoteComboDeps
+): Promise {
+ if (!combo || !settings || !settings.comboAutoPromoteEnabled) return false;
+ const comboId = typeof combo.id === "string" ? combo.id : null;
+ if (!comboId) return false;
+ const reordered = promoteModelToFront(
+ Array.isArray(combo.models) ? (combo.models as unknown[]) : null,
+ winningModel
+ );
+ if (!reordered) return false;
+ const label = typeof combo.name === "string" ? combo.name : comboId;
+ try {
+ await deps.updateCombo(comboId, { models: reordered });
+ deps.info?.("COMBO", `Model "${winningModel}" succeeded — promoted to #1 in combo "${label}"`);
+ return true;
+ } catch (dbErr: any) {
+ deps.warn?.(
+ "COMBO",
+ `Failed to promote model "${winningModel}" in combo "${label}": ${
+ dbErr?.message || "unknown error"
+ }`
+ );
+ return false;
+ }
+}
diff --git a/src/lib/dataPaths.ts b/src/lib/dataPaths.ts
index 3152265828..5ad61ddbee 100644
--- a/src/lib/dataPaths.ts
+++ b/src/lib/dataPaths.ts
@@ -70,6 +70,45 @@ export function resolveDataDir({ isCloud = false }: { isCloud?: boolean } = {}):
return getDefaultDataDir();
}
+/**
+ * Resolve the data directory and guarantee it is writable.
+ *
+ * Unlike {@link resolveDataDir} (a pure, side-effect-free path resolver used by
+ * many callers), this variant probes the resolved directory by attempting to
+ * create it. When a configured `DATA_DIR` is not writable (`EACCES`/`EPERM`),
+ * it falls back to the default user directory so the app keeps working instead
+ * of crashing on an unwritable, operator-supplied path. Any other error (e.g.
+ * `ENOTDIR`, `ENOSPC`) still propagates.
+ *
+ * Use this only at the single startup site that owns directory creation
+ * (currently `db/core.ts`); everywhere else keep using the pure resolver.
+ */
+export function resolveWritableDataDir({ isCloud = false }: { isCloud?: boolean } = {}): string {
+ const resolved = resolveDataDir({ isCloud });
+
+ // Cloud/serverless never owns a writable home dir; leave its sentinel alone.
+ if (isCloud) return resolved;
+
+ // No explicit override → already the default user dir; nothing to fall back to.
+ const configured = normalizeConfiguredPath(process.env.DATA_DIR);
+ if (!configured) return resolved;
+
+ try {
+ fs.mkdirSync(resolved, { recursive: true });
+ return resolved;
+ } catch (err: unknown) {
+ const code = (err as NodeJS.ErrnoException | null)?.code;
+ if (code === "EACCES" || code === "EPERM") {
+ const fallback = getDefaultDataDir();
+ console.warn(
+ `[DATA_DIR] '${resolved}' is not writable (${code}) → falling back to '${fallback}'`
+ );
+ return fallback;
+ }
+ throw err;
+ }
+}
+
export function isSamePath(a: string | null | undefined, b: string | null | undefined): boolean {
if (!a || !b) return false;
const normalizedA = path.resolve(a);
diff --git a/src/lib/db/combos.ts b/src/lib/db/combos.ts
index 51cb0892b2..b24f6e7e9b 100644
--- a/src/lib/db/combos.ts
+++ b/src/lib/db/combos.ts
@@ -125,6 +125,24 @@ export async function getComboByName(name: string) {
return normalizeStoredCombo(combo, db, [name]);
}
+// #4446: case-insensitive name lookup. The opencode dispatch path forwards a
+// lowercased combo slug (e.g. "master-light") for a combo provisioned as
+// "MASTER-LIGHT"; the default BINARY collation of getComboByName misses it.
+// Used only as a fallback after the exact match fails, so it cannot change the
+// resolution of any combo that already resolves today.
+export async function getComboByNameInsensitive(name: string) {
+ const db = getDbInstance();
+ const row = db
+ .prepare(
+ "SELECT data, sort_order, context_cache_protection FROM combos WHERE name = ? COLLATE NOCASE"
+ )
+ .get(name);
+ const combo = parseComboRow(row);
+ if (!combo) return null;
+ const storedName = typeof combo.name === "string" ? combo.name : name;
+ return normalizeStoredCombo(combo, db, [storedName]);
+}
+
export async function createCombo(data: JsonRecord) {
const db = getDbInstance();
const now = new Date().toISOString();
diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts
index 99a04cda7f..261749a814 100644
--- a/src/lib/db/core.ts
+++ b/src/lib/db/core.ts
@@ -13,7 +13,7 @@ import {
} from "./adapters/driverFactory";
import path from "path";
import fs from "fs";
-import { resolveDataDir, getLegacyDotDataDir } from "../dataPaths";
+import { resolveWritableDataDir, getLegacyDotDataDir } from "../dataPaths";
import { runMigrations } from "./migrationRunner";
import { runDbHealthCheck } from "./healthCheck";
import { resetAllDbModuleState } from "./stateReset";
@@ -83,7 +83,7 @@ export const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
// ──────────────── Paths ────────────────
-export const DATA_DIR = resolveDataDir({ isCloud });
+export const DATA_DIR = resolveWritableDataDir({ isCloud });
const LEGACY_DATA_DIR = isCloud ? null : getLegacyDotDataDir();
export const SQLITE_FILE = isCloud ? null : path.join(DATA_DIR, "storage.sqlite");
const JSON_DB_FILE = isCloud ? null : path.join(DATA_DIR, "db.json");
diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts
index f6b207b775..10341b3860 100644
--- a/src/lib/db/settings.ts
+++ b/src/lib/db/settings.ts
@@ -124,6 +124,7 @@ export async function getSettings() {
autoRefreshProviderQuota: false,
autoRefreshProviderQuotaInterval: 180,
comboConfigMode: "guided",
+ comboAutoPromoteEnabled: false,
codexServiceTier: { enabled: false },
claudeFastMode: {
enabled: false,
@@ -136,6 +137,9 @@ export async function getSettings() {
wsAuth: false,
maxBodySizeMb: requestBodyLimitMbFromEnv(process.env.MAX_BODY_SIZE_BYTES),
debugMode: true,
+ // Opt-in diagnostic: when true, the chat handler emits a `log.debug("TOOLS", …)`
+ // line per request summarizing tool count + MCP/hosted/client source breakdown.
+ logToolSources: false,
// LOCAL_ONLY manage-scope bypass policy defaults (T-011 / spec §Data Model).
// Preserves PR #2473 behaviour on migration — the bypass starts ENABLED
// for `/api/mcp/` so existing manage-scope Bearer clients keep working.
diff --git a/src/lib/embeddings/service.ts b/src/lib/embeddings/service.ts
index 7c445fe2cb..3c467c066c 100644
--- a/src/lib/embeddings/service.ts
+++ b/src/lib/embeddings/service.ts
@@ -13,6 +13,7 @@ import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
import { getProviderCredentials, clearRecoveredProviderState } from "@/sse/services/auth";
import { getProviderNodes, getComboByName, getCombos, getDatabaseSettings } from "@/lib/localDb";
import { handleComboChat } from "@omniroute/open-sse/services/combo.ts";
+import { resolveBareModelToConnectionDefault } from "@omniroute/open-sse/services/model.ts";
import { findEmbeddingComboDimensionConflict } from "./familyGuard";
import { calculateCost } from "@/lib/usage/costCalculator";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
@@ -71,8 +72,22 @@ export async function createEmbeddingResponse(
settings = getDatabaseSettings();
} catch {}
+ // Inject the combo's configured dimensions into the request body so that
+ // every upstream embedding call within this combo receives the same
+ // dimensions override. The client's own dimensions value takes precedence
+ // if already set. Ported from decolua/9router#1530.
+ const comboRecord = combo as Record;
+ const comboDimensions =
+ comboRecord.dimensions !== undefined && comboRecord.dimensions !== null
+ ? String(comboRecord.dimensions)
+ : undefined;
+ const bodyWithDimensions =
+ comboDimensions !== undefined && body.dimensions === undefined
+ ? { ...body, dimensions: comboDimensions }
+ : body;
+
return handleComboChat({
- body,
+ body: bodyWithDimensions,
combo: combo as any,
handleSingleModel: async (reqBody: any, targetModelStr: string, target?: any) => {
const newBody = { ...reqBody, model: targetModelStr };
@@ -191,15 +206,29 @@ export async function createEmbeddingResponse(
}
}
+ // #474: when the request used a bare model name (no "/" — e.g. an alias that
+ // resolved to "auto") and the selected connection declares a defaultModel,
+ // resolve the bare name to that real model ID before the upstream call so the
+ // provider receives a concrete model. A "/"-qualified name is left untouched.
+ const connectionDefaultModel =
+ credentials && typeof (credentials as { defaultModel?: unknown }).defaultModel === "string"
+ ? ((credentials as { defaultModel?: string }).defaultModel as string)
+ : null;
+ const effectiveModel = resolveBareModelToConnectionDefault(
+ modelStr,
+ resolvedModel,
+ connectionDefaultModel
+ );
+
const result = await handleEmbedding({
- body,
+ body: effectiveModel !== resolvedModel ? { ...body, model: `${provider}/${effectiveModel}` } : body,
// getProviderCredentials returns a richer connection object; handleEmbedding
// only reads apiKey/accessToken, both present at runtime. Bridge the wider
// selection type to the handler's narrow credential shape.
credentials: credentials as { apiKey?: string; accessToken?: string } | null,
log,
resolvedProvider: providerConfig,
- resolvedModel,
+ resolvedModel: effectiveModel,
clientRawRequest: options.clientRawRequest || null,
apiKeyId: options.apiKeyId || null,
apiKeyName: options.apiKeyName || null,
@@ -212,10 +241,10 @@ export async function createEmbeddingResponse(
if (credentials) await clearRecoveredProviderState(credentials);
responseHeaders.set("Content-Type", "application/json");
const usage = (result.data as { usage?: Record })?.usage ?? null;
- const costUsd = usage ? await calculateCost(provider, resolvedModel ?? "", usage) : 0;
+ const costUsd = usage ? await calculateCost(provider, effectiveModel ?? "", usage) : 0;
attachOmniRouteMetaHeaders(responseHeaders, {
provider,
- model: resolvedModel,
+ model: effectiveModel,
usage,
costUsd,
latencyMs: Date.now() - startTime,
diff --git a/src/lib/headroom/detect.ts b/src/lib/headroom/detect.ts
new file mode 100644
index 0000000000..cbdd9dd240
--- /dev/null
+++ b/src/lib/headroom/detect.ts
@@ -0,0 +1,167 @@
+/**
+ * Headroom proxy detection helpers.
+ *
+ * Ported from upstream 9router (decolua/9router @ b55cf36d + 50ed79fe).
+ * Original authors: decolua, Carmelo Campos (@carmelogunsroses), Cursor.
+ *
+ * Headroom is the optional third-party token-saver proxy (headroom-ai
+ * Python CLI). OmniRoute can either:
+ * 1. Manage a local proxy lifecycle (loopback URL → start/stop from the
+ * dashboard via `process.ts`).
+ * 2. Use an external Docker sidecar proxy (non-loopback HEADROOM_URL).
+ * In this case we only probe /health; start/stop are NOT exposed.
+ *
+ * All functions here are pure / side-effect-free where possible so they can
+ * be unit-tested without spawning processes.
+ */
+
+import { execFileSync } from "node:child_process";
+
+const EXTRA_BINS = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"];
+const EXTENDED_PATH = [...EXTRA_BINS, process.env.PATH || ""].filter(Boolean).join(":");
+
+const PYTHON_CANDIDATES = [
+ "python3.13",
+ "python3.12",
+ "python3.11",
+ "python3.10",
+ "python3",
+ "python",
+];
+const MIN_VERSION: readonly [number, number] = [3, 10];
+const HEADROOM_HEALTH_TIMEOUT_MS = 1500;
+const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]);
+
+export const DEFAULT_HEADROOM_URL = process.env.HEADROOM_URL || "http://localhost:8787";
+
+export interface HeadroomStatus {
+ installed: boolean;
+ path: string | null;
+ running: boolean;
+ python: string | null;
+ localUrl: boolean;
+ canStart: boolean;
+}
+
+export interface BuildHeadroomStatusInput {
+ url: string;
+ binaryPath: string | null;
+ python: string | null;
+ proxyReachable: boolean;
+}
+
+// ──────────────── Pure helpers (unit-testable) ────────────────
+
+export function isLoopbackHeadroomUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ return LOOPBACK_HOSTS.has(parsed.hostname);
+ } catch {
+ return false;
+ }
+}
+
+export function parsePortFromHeadroomUrl(url: string): number | null {
+ try {
+ const u = new URL(url);
+ if (!u.port) return null;
+ const p = parseInt(u.port, 10);
+ if (Number.isFinite(p) && p > 0 && p < 65536) return p;
+ } catch {
+ // fall through
+ }
+ return null;
+}
+
+/**
+ * Assemble the headroom status payload from already-resolved inputs.
+ * Kept pure so unit tests can exercise every branch without spawning
+ * processes or hitting the network.
+ *
+ * - `running` reflects /health reachability regardless of local CLI presence
+ * (pair commit 50ed79fe — Docker sidecar support).
+ * - `canStart` is only true for a loopback URL with the CLI installed; we
+ * never spawn against a non-loopback URL.
+ */
+export function buildHeadroomStatus(input: BuildHeadroomStatusInput): HeadroomStatus {
+ const installed = Boolean(input.binaryPath);
+ const localUrl = isLoopbackHeadroomUrl(input.url);
+ return {
+ installed,
+ path: input.binaryPath,
+ running: input.proxyReachable,
+ python: input.python,
+ localUrl,
+ canStart: installed && localUrl,
+ };
+}
+
+// ──────────────── Side-effecting probes ────────────────
+
+export function findHeadroomBinary(): string | null {
+ try {
+ // execFileSync (no shell): "which" is invoked directly with "headroom" as an
+ // arg, so even if some upstream caller passes attacker-controlled input the
+ // shell metacharacters cannot reach a shell parser.
+ const out = execFileSync("which", ["headroom"], {
+ stdio: ["ignore", "pipe", "ignore"],
+ windowsHide: true,
+ env: { ...process.env, PATH: EXTENDED_PATH },
+ })
+ .toString()
+ .trim();
+ return out || null;
+ } catch {
+ return null;
+ }
+}
+
+export function findPython310(): string | null {
+ for (const candidate of PYTHON_CANDIDATES) {
+ try {
+ // candidate is from a fixed allowlist (PYTHON_CANDIDATES) above — no
+ // user input — but use execFileSync anyway to remove the shell entirely.
+ const ver = execFileSync(candidate, ["--version"], {
+ stdio: ["ignore", "pipe", "ignore"],
+ windowsHide: true,
+ env: { ...process.env, PATH: EXTENDED_PATH },
+ })
+ .toString()
+ .trim();
+ const match = ver.match(/(\d+)\.(\d+)/);
+ if (!match) continue;
+ const major = parseInt(match[1], 10);
+ const minor = parseInt(match[2], 10);
+ if (major > MIN_VERSION[0] || (major === MIN_VERSION[0] && minor >= MIN_VERSION[1])) {
+ return candidate;
+ }
+ } catch {
+ // try next candidate
+ }
+ }
+ return null;
+}
+
+export async function probeProxyRunning(url: string): Promise {
+ if (!url) return false;
+ const base = String(url).replace(/\/$/, "");
+ try {
+ const res = await fetch(`${base}/health`, {
+ signal: AbortSignal.timeout(HEADROOM_HEALTH_TIMEOUT_MS),
+ });
+ return res.ok;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Aggregated dashboard status. Composes the three probes above with the
+ * pure builder so the I/O happens in one place.
+ */
+export async function getHeadroomStatus(url: string): Promise {
+ const binaryPath = findHeadroomBinary();
+ const python = findPython310();
+ const proxyReachable = await probeProxyRunning(url);
+ return buildHeadroomStatus({ url, binaryPath, python, proxyReachable });
+}
diff --git a/src/lib/headroom/process.ts b/src/lib/headroom/process.ts
new file mode 100644
index 0000000000..b788bf6600
--- /dev/null
+++ b/src/lib/headroom/process.ts
@@ -0,0 +1,193 @@
+/**
+ * Local headroom-proxy lifecycle management.
+ *
+ * Ported from upstream 9router @ b55cf36d (Cursor / decolua).
+ *
+ * Spawns the local `headroom proxy --port ` as a detached process,
+ * tracks its PID in `/headroom/proxy.pid`, and exposes start/stop/
+ * status helpers. Only invoked behind the LOCAL_ONLY route-guard tier
+ * (Hard Rules #15 + #17): a tunneled JWT cannot reach the start/stop routes.
+ */
+
+import fs from "node:fs";
+import path from "node:path";
+import { spawn } from "node:child_process";
+import { DATA_DIR } from "@/lib/db/core";
+import { findHeadroomBinary } from "./detect";
+
+const HEADROOM_DIR = path.join(DATA_DIR ?? ".", "headroom");
+const PID_FILE = path.join(HEADROOM_DIR, "proxy.pid");
+const LOG_FILE = path.join(HEADROOM_DIR, "proxy.log");
+const DEFAULT_PORT = 8787;
+const STARTUP_TIMEOUT_MS = 8000;
+const STOP_GRACE_MS = 2000;
+
+export interface StartResult {
+ pid: number;
+ alreadyRunning: boolean;
+}
+
+export interface StopResult {
+ stopped: boolean;
+ pid?: number;
+ reason?: string;
+}
+
+export class HeadroomError extends Error {
+ code: string;
+ constructor(message: string, code: string) {
+ super(message);
+ this.code = code;
+ }
+}
+
+function ensureDir(): void {
+ if (!fs.existsSync(HEADROOM_DIR)) fs.mkdirSync(HEADROOM_DIR, { recursive: true });
+}
+
+function readPid(): number | null {
+ try {
+ if (!fs.existsSync(PID_FILE)) return null;
+ const raw = fs.readFileSync(PID_FILE, "utf8").trim();
+ const pid = parseInt(raw, 10);
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
+ } catch {
+ return null;
+ }
+}
+
+function writePid(pid: number): void {
+ ensureDir();
+ fs.writeFileSync(PID_FILE, String(pid));
+}
+
+function clearPid(): void {
+ try {
+ if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
+ } catch {
+ // ignore
+ }
+}
+
+export function isPidAlive(pid: number | null | undefined): boolean {
+ if (!pid || typeof pid !== "number") return false;
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export function getManagedPid(): number | null {
+ const pid = readPid();
+ return pid && isPidAlive(pid) ? pid : null;
+}
+
+function safePort(port: unknown): number {
+ const n = Number(port);
+ return Number.isFinite(n) && n > 0 && n < 65536 ? n : DEFAULT_PORT;
+}
+
+export async function startHeadroomProxy(
+ opts: { port?: number } = {}
+): Promise {
+ const binary = findHeadroomBinary();
+ if (!binary) {
+ throw new HeadroomError("Headroom CLI not installed", "NOT_INSTALLED");
+ }
+
+ const existing = getManagedPid();
+ if (existing) return { pid: existing, alreadyRunning: true };
+
+ ensureDir();
+ const outFd = fs.openSync(LOG_FILE, "a");
+
+ // spawn (no shell) with array args — argv is passed directly to the
+ // headroom binary, so no shell-metacharacter handling is needed.
+ const child = spawn(binary, ["proxy", "--port", String(safePort(opts.port))], {
+ stdio: ["ignore", outFd, outFd],
+ detached: true,
+ windowsHide: true,
+ env: { ...process.env },
+ });
+
+ if (!child.pid) {
+ fs.closeSync(outFd);
+ throw new HeadroomError("Failed to spawn headroom proxy", "SPAWN_FAILED");
+ }
+
+ child.unref();
+ writePid(child.pid);
+
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ if (isPidAlive(child.pid!)) resolve();
+ else
+ reject(
+ new HeadroomError(
+ "headroom proxy exited during startup — see proxy.log",
+ "EARLY_EXIT"
+ )
+ );
+ }, STARTUP_TIMEOUT_MS);
+
+ child.once("exit", (code) => {
+ clearTimeout(timer);
+ clearPid();
+ try {
+ fs.closeSync(outFd);
+ } catch {
+ // already closed
+ }
+ reject(
+ new HeadroomError(
+ `headroom proxy exited early (code=${code}) — see proxy.log`,
+ "EARLY_EXIT"
+ )
+ );
+ });
+ });
+
+ try {
+ fs.closeSync(outFd);
+ } catch {
+ // already closed
+ }
+
+ return { pid: child.pid, alreadyRunning: false };
+}
+
+export function stopHeadroomProxy(): StopResult {
+ const pid = getManagedPid();
+ if (!pid) return { stopped: false, reason: "not_running" };
+ try {
+ process.kill(pid, "SIGTERM");
+ setTimeout(() => {
+ if (isPidAlive(pid)) {
+ try {
+ process.kill(pid, "SIGKILL");
+ } catch {
+ // already gone
+ }
+ }
+ }, STOP_GRACE_MS);
+ clearPid();
+ return { stopped: true, pid };
+ } catch (e) {
+ clearPid();
+ const msg = e instanceof Error ? e.message : String(e);
+ throw new HeadroomError(`Failed to stop headroom proxy: ${msg}`, "STOP_FAILED");
+ }
+}
+
+export function getHeadroomLogTail(maxLines = 200): string {
+ try {
+ if (!fs.existsSync(LOG_FILE)) return "";
+ const content = fs.readFileSync(LOG_FILE, "utf8");
+ const lines = content.split(/\r?\n/).filter(Boolean);
+ return lines.slice(-maxLines).join("\n");
+ } catch {
+ return "";
+ }
+}
diff --git a/src/lib/ipUtils.ts b/src/lib/ipUtils.ts
index b1881b28d2..3bf92d3127 100644
--- a/src/lib/ipUtils.ts
+++ b/src/lib/ipUtils.ts
@@ -26,9 +26,41 @@ export function extractClientIp(
return remoteAddress?.trim() ?? "unknown";
}
+/**
+ * Strip an IPv4-mapped IPv6 prefix ("::ffff:127.0.0.1" -> "127.0.0.1") so the
+ * loopback check below catches both representations Node may report.
+ */
+function normalizePeer(addr: string | undefined): string {
+ const trimmed = (addr ?? "").trim();
+ if (!trimmed) return "";
+ return trimmed.startsWith("::ffff:") ? trimmed.slice("::ffff:".length) : trimmed;
+}
+
+/**
+ * Whether the TCP peer is a loopback address (i.e. the request reached us via
+ * a local reverse proxy such as nginx). Only then is it safe to trust the
+ * forwarding headers — from a direct public socket those headers are
+ * attacker-controlled and must be ignored, otherwise per-IP brute-force
+ * buckets (login lockout, etc.) become spoofable / shareable.
+ *
+ * Ported from decolua/9router#1893.
+ */
+function isLoopbackPeer(addr: string | undefined): boolean {
+ const ip = normalizePeer(addr);
+ if (!ip) return false;
+ if (ip === "::1") return true;
+ return ip.startsWith("127.");
+}
+
/**
* Extract client IP from a Request or NextRequest object.
- * Checks X-Forwarded-For, X-Real-IP, CF-Connecting-IP, then socket.
+ *
+ * Behind a local reverse proxy (TCP peer is loopback) we trust the standard
+ * forwarding headers in priority order: CF-Connecting-IP > X-Forwarded-For >
+ * X-Real-IP. Directly from a public socket those headers are spoofable, so we
+ * key by the unspoofable TCP peer address instead. When no peer is known
+ * (edge runtime / fetch path with no socket) we fall back to the headers so
+ * we don't regress to "unknown" for every request in that path.
*/
export function getClientIpFromRequest(req: {
headers?: Headers | { get?: (n: string) => string | null };
@@ -44,13 +76,19 @@ export function getClientIpFromRequest(req: {
return null;
};
- // Priority: CF-Connecting-IP (Cloudflare) > X-Forwarded-For > X-Real-IP > socket
- const cfIp = getHeader("cf-connecting-ip");
- if (cfIp && isIP(cfIp.trim()) !== 0) return cfIp.trim();
-
- const xff = getHeader("x-forwarded-for");
- const realIp = getHeader("x-real-ip");
const remoteAddress = req.ip ?? req.socket?.remoteAddress;
+ const hasPeer = Boolean(normalizePeer(remoteAddress));
+ const trustForwardingHeaders = !hasPeer || isLoopbackPeer(remoteAddress);
- return extractClientIp(xff ?? realIp, remoteAddress);
+ if (trustForwardingHeaders) {
+ const cfIp = getHeader("cf-connecting-ip");
+ if (cfIp && isIP(cfIp.trim()) !== 0) return cfIp.trim();
+
+ const xff = getHeader("x-forwarded-for");
+ const realIp = getHeader("x-real-ip");
+ return extractClientIp(xff ?? realIp, remoteAddress);
+ }
+
+ // Direct public peer — forwarding headers are attacker-controlled, ignore.
+ return normalizePeer(remoteAddress);
}
diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts
index 68b1245590..1fcadda8f7 100755
--- a/src/lib/localDb.ts
+++ b/src/lib/localDb.ts
@@ -83,6 +83,7 @@ export {
getCombos,
getComboById,
getComboByName,
+ getComboByNameInsensitive,
createCombo,
updateCombo,
reorderCombos,
diff --git a/src/lib/oauth/services/codexImport.ts b/src/lib/oauth/services/codexImport.ts
new file mode 100644
index 0000000000..5ef19be84f
--- /dev/null
+++ b/src/lib/oauth/services/codexImport.ts
@@ -0,0 +1,214 @@
+/**
+ * Codex (OpenAI) bulk-import normalization
+ *
+ * Accepts the JSON shape produced by Codex CLI / common token-export tools and
+ * returns a {@link createProviderConnection} payload (or a typed error).
+ *
+ * Pure: no I/O, no network. Safe to unit-test.
+ *
+ * Ported from decolua/9router#1257 (beaaan).
+ */
+
+const REQUIRED_FIELDS = ["access_token", "refresh_token"] as const;
+
+/** 10 days — the typical OpenAI access-token lifetime when no `expired` is supplied. */
+const DEFAULT_EXPIRY_MS = 10 * 24 * 60 * 60 * 1000;
+
+const BASE64_BLOCK_SIZE = 4;
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export type CodexImportPayload = {
+ provider: "codex";
+ authType: "oauth";
+ accessToken: string;
+ refreshToken: string;
+ idToken?: string;
+ email: string;
+ expiresAt: string;
+ testStatus: "active";
+ providerSpecificData?: {
+ chatgptAccountId?: string;
+ chatgptPlanType?: string;
+ };
+};
+
+export type NormalizeOk = { ok: true; payload: CodexImportPayload };
+export type NormalizeErr = { ok: false; error: string };
+export type NormalizeResult = NormalizeOk | NormalizeErr;
+
+export type FlattenOk = { ok: true; records: unknown[] };
+export type FlattenErr = { ok: false; error: string };
+export type FlattenResult = FlattenOk | FlattenErr;
+
+// ── Internal helpers ──────────────────────────────────────────────────────────
+
+/**
+ * Decode a JWT payload to a plain object (no signature verification).
+ *
+ * Mirrors the helper in `providers.ts` but is kept local so this module has no
+ * runtime dependency on the larger OAuth module graph.
+ */
+function decodeJwtPayload(jwt: unknown): Record | null {
+ try {
+ if (!jwt || typeof jwt !== "string") return null;
+ const parts = jwt.split(".");
+ if (parts.length !== 3) return null;
+ const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
+ const missingPadding =
+ (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE;
+ const padded = base64 + "=".repeat(missingPadding);
+ return JSON.parse(Buffer.from(padded, "base64").toString("utf8")) as Record<
+ string,
+ unknown
+ >;
+ } catch {
+ return null;
+ }
+}
+
+function extractCodexAccountInfo(idToken: string): {
+ email?: string;
+ chatgptAccountId?: string;
+ chatgptPlanType?: string;
+} {
+ const payload = decodeJwtPayload(idToken);
+ if (!payload) return {};
+ const chatgpt =
+ (payload["https://api.openai.com/auth"] as Record) || {};
+ return {
+ email: typeof payload.email === "string" ? payload.email : undefined,
+ chatgptAccountId:
+ typeof chatgpt.chatgpt_account_id === "string"
+ ? chatgpt.chatgpt_account_id
+ : undefined,
+ chatgptPlanType:
+ typeof chatgpt.chatgpt_plan_type === "string"
+ ? chatgpt.chatgpt_plan_type
+ : undefined,
+ };
+}
+
+function pickString(...candidates: (string | undefined)[]): string | undefined {
+ for (const c of candidates) {
+ if (typeof c === "string" && c.trim()) return c.trim();
+ }
+ return undefined;
+}
+
+function parseExpiry(value: unknown): string | undefined {
+ if (typeof value === "string" && value) {
+ const ms = Date.parse(value);
+ if (Number.isFinite(ms)) return new Date(ms).toISOString();
+ }
+ return undefined;
+}
+
+function parseAccessTokenExp(accessToken: string): string {
+ const payload = decodeJwtPayload(accessToken);
+ const exp = payload && typeof payload.exp === "number" ? payload.exp : null;
+ if (exp && Number.isFinite(exp)) {
+ return new Date(exp * 1000).toISOString();
+ }
+ return new Date(Date.now() + DEFAULT_EXPIRY_MS).toISOString();
+}
+
+/**
+ * Codex CLI persists tokens to `auth.json` with the OAuth fields nested under
+ * a `tokens` object: `{ auth_mode, OPENAI_API_KEY, tokens: { id_token, ... } }`.
+ * Flatten that into the same shape as a simple top-level export so the rest of
+ * the normalizer can stay unchanged.
+ */
+function unwrapCodexAuthJson(rec: Record): Record {
+ const tokens = rec.tokens as Record | undefined;
+ if (!tokens || typeof tokens !== "object" || Array.isArray(tokens)) {
+ return rec;
+ }
+ if (typeof tokens.access_token !== "string") return rec;
+ // Tokens take priority; carry through siblings (email / account_id / expired)
+ // only when the nested object doesn't already define them.
+ return { ...rec, ...tokens };
+}
+
+// ── Public API ────────────────────────────────────────────────────────────────
+
+/**
+ * Normalize a single raw record from an uploaded JSON file into a
+ * `createProviderConnection` payload.
+ *
+ * @param input — single record from the uploaded JSON
+ */
+export function normalizeCodexImportRecord(input: unknown): NormalizeResult {
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
+ return { ok: false, error: "Record is not an object" };
+ }
+
+ const rec = unwrapCodexAuthJson(input as Record);
+
+ // Allow type field to be missing or "codex"; reject anything else explicitly so
+ // users don't accidentally import claude/gemini exports through this path.
+ if (rec.type !== undefined && rec.type !== null && rec.type !== "codex") {
+ return { ok: false, error: `Unsupported type: ${String(rec.type)}` };
+ }
+
+ for (const f of REQUIRED_FIELDS) {
+ if (typeof rec[f] !== "string" || !rec[f]) {
+ return { ok: false, error: `Missing required field: ${f}` };
+ }
+ }
+
+ const accessToken = String(rec.access_token);
+ const refreshToken = String(rec.refresh_token);
+ const idToken = typeof rec.id_token === "string" ? rec.id_token : undefined;
+
+ // Prefer JWT-derived account info, fall back to top-level fields.
+ const fromJwt = idToken ? extractCodexAccountInfo(idToken) : {};
+ const email = pickString(fromJwt.email, rec.email as string | undefined);
+
+ if (!email) {
+ return { ok: false, error: "Missing email (and id_token does not contain one)" };
+ }
+
+ const chatgptAccountId = pickString(
+ fromJwt.chatgptAccountId,
+ rec.account_id as string | undefined,
+ );
+ const chatgptPlanType = pickString(fromJwt.chatgptPlanType);
+
+ const expiresAt =
+ parseExpiry(rec.expired) ?? parseAccessTokenExp(accessToken);
+
+ const providerSpecificData: CodexImportPayload["providerSpecificData"] = {};
+ if (chatgptAccountId) providerSpecificData.chatgptAccountId = chatgptAccountId;
+ if (chatgptPlanType) providerSpecificData.chatgptPlanType = chatgptPlanType;
+
+ const payload: CodexImportPayload = {
+ provider: "codex",
+ authType: "oauth",
+ accessToken,
+ refreshToken,
+ email,
+ expiresAt,
+ testStatus: "active",
+ };
+ if (idToken) payload.idToken = idToken;
+ if (Object.keys(providerSpecificData).length > 0) {
+ payload.providerSpecificData = providerSpecificData;
+ }
+
+ return { ok: true, payload };
+}
+
+/**
+ * Flatten the user-uploaded JSON into an array of candidate records.
+ * Accepts a single record or an array; rejects anything else.
+ */
+export function flattenCodexImportPayload(parsed: unknown): FlattenResult {
+ if (Array.isArray(parsed)) {
+ return { ok: true, records: parsed };
+ }
+ if (parsed && typeof parsed === "object") {
+ return { ok: true, records: [parsed] };
+ }
+ return { ok: false, error: "JSON must be an object or an array of objects" };
+}
diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts
index eaf6062478..091cc51aa6 100644
--- a/src/lib/providers/validation.ts
+++ b/src/lib/providers/validation.ts
@@ -95,6 +95,7 @@ import {
validateOpenAILikeProvider,
validateCommandCodeProvider,
validateGeminiLikeProvider,
+ validateHuggingFaceProvider,
validateOpenAICompatibleProvider,
} from "./validation/openaiFormat";
import {
@@ -280,6 +281,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
}
},
"command-code": validateCommandCodeProvider,
+ huggingface: validateHuggingFaceProvider,
deepgram: validateDeepgramProvider,
assemblyai: validateAssemblyAIProvider,
"fal-ai": ({ apiKey, providerSpecificData }: any) =>
diff --git a/src/lib/providers/validation/openaiFormat.ts b/src/lib/providers/validation/openaiFormat.ts
index b7e63cf317..a2813cf6b3 100644
--- a/src/lib/providers/validation/openaiFormat.ts
+++ b/src/lib/providers/validation/openaiFormat.ts
@@ -227,6 +227,35 @@ export async function validateCommandCodeProvider({ apiKey, providerSpecificData
}
+// HuggingFace fine-grained Inference-Provider tokens are valid even when
+// model/task endpoints reject them, so the generic OpenAI-like probe against
+// router.huggingface.co/v1/models falsely marks them invalid. Validate the
+// token strictly as an auth check via the whoami-v2 endpoint instead: only
+// 401/403 means the token is invalid; any other non-OK status is a transient
+// upstream failure, NOT an invalid key.
+export async function validateHuggingFaceProvider({ apiKey }: any) {
+ try {
+ const response = await validationRead("https://huggingface.co/api/whoami-v2", {
+ method: "GET",
+ headers: { Authorization: `Bearer ${apiKey}` },
+ });
+
+ if (response.ok) {
+ return { valid: true, error: null, method: "huggingface_whoami" };
+ }
+
+ if (response.status === 401 || response.status === 403) {
+ return { valid: false, error: "Invalid API key" };
+ }
+
+ // Non-auth, non-OK status — surface as a transient upstream failure rather
+ // than declaring the (potentially valid) fine-grained token invalid.
+ return { valid: false, error: `HuggingFace token check returned ${response.status}` };
+ } catch (error: unknown) {
+ return toValidationErrorResult(error);
+ }
+}
+
export async function validateGeminiLikeProvider({
apiKey,
baseUrl,
diff --git a/src/lib/providers/xai/thinking.ts b/src/lib/providers/xai/thinking.ts
new file mode 100644
index 0000000000..8348d08864
--- /dev/null
+++ b/src/lib/providers/xai/thinking.ts
@@ -0,0 +1,115 @@
+/**
+ * xAI Reasoning / Thinking Patcher
+ *
+ * Source of truth: router-for-me/CLIProxyAPI internal/thinking/provider/xai/apply.go
+ *
+ * Maps the various inbound reasoning/thinking spec shapes (OpenAI Chat,
+ * OpenAI Responses, Anthropic Messages, Gemini) onto the xAI Responses
+ * `reasoning` field. Single source of truth for budget mapping.
+ *
+ * Defaults policy mirrors CLIProxyAPI:
+ * - never proactively enable reasoning when the caller omits it
+ * - honor explicit caller intent verbatim
+ */
+
+const VALID_EFFORTS = new Set(["minimal", "low", "medium", "high"]);
+
+export type ReasoningEffort = "minimal" | "low" | "medium" | "high";
+
+/**
+ * Map a numeric token budget to a discrete effort tier.
+ * <=0 → undefined (disabled)
+ * 1..3999 → "low"
+ * 4000..15999 → "medium"
+ * >=16000 → "high"
+ */
+export function budgetToEffort(budget: number): ReasoningEffort | undefined {
+ if (typeof budget !== "number" || !Number.isFinite(budget) || budget <= 0) return undefined;
+ if (budget >= 16000) return "high";
+ if (budget >= 4000) return "medium";
+ return "low";
+}
+
+interface AnthropicThinking {
+ type?: string;
+ budget_tokens?: number;
+}
+
+interface GeminiThinkingConfig {
+ thinkingBudget?: number;
+ includeThoughts?: boolean;
+}
+
+interface XaiReasoning {
+ effort: ReasoningEffort;
+}
+
+interface ThinkingRequest {
+ reasoning?: XaiReasoning | Record;
+ reasoning_effort?: string;
+ thinking?: AnthropicThinking;
+ thinkingConfig?: GeminiThinkingConfig;
+ [key: string]: unknown;
+}
+
+interface ApplyThinkingOptions {
+ defaultEffort?: ReasoningEffort;
+}
+
+/**
+ * Apply reasoning/thinking patch to an xAI request body.
+ *
+ * Returns a new object — caller's request is not mutated.
+ *
+ * Recognized inbound shapes:
+ * - request.reasoning_effort: "minimal"|"low"|"medium"|"high" (OpenAI Chat)
+ * - request.reasoning: { effort: ... } (OpenAI Responses)
+ * - request.thinking: { type: "enabled", budget_tokens: N } (Anthropic)
+ * - request.thinkingConfig: { thinkingBudget: N, includeThoughts } (Gemini)
+ */
+export function applyThinking(
+ request: ThinkingRequest,
+ options: ApplyThinkingOptions = {},
+): ThinkingRequest {
+ if (!request || typeof request !== "object") return request;
+ const out: ThinkingRequest = { ...request };
+
+ // 1) Already xAI-native? Honor and stop.
+ if (out.reasoning && typeof out.reasoning === "object") {
+ const reasoning = out.reasoning as Record;
+ if (typeof reasoning.effort === "string" && VALID_EFFORTS.has(reasoning.effort)) {
+ return out;
+ }
+ }
+
+ // 2) OpenAI Chat reasoning_effort
+ if (typeof out.reasoning_effort === "string" && VALID_EFFORTS.has(out.reasoning_effort)) {
+ out.reasoning = { effort: out.reasoning_effort as ReasoningEffort };
+ delete out.reasoning_effort;
+ return out;
+ }
+
+ // 3) Anthropic-style thinking
+ if (out.thinking && typeof out.thinking === "object") {
+ if (out.thinking.type === "enabled") {
+ const eff = budgetToEffort(out.thinking.budget_tokens ?? 0) ?? "medium";
+ out.reasoning = { effort: eff };
+ }
+ delete out.thinking;
+ return out;
+ }
+
+ // 4) Gemini-style thinkingConfig
+ if (out.thinkingConfig && typeof out.thinkingConfig === "object") {
+ const eff = budgetToEffort(out.thinkingConfig.thinkingBudget ?? 0);
+ if (eff) out.reasoning = { effort: eff };
+ delete out.thinkingConfig;
+ return out;
+ }
+
+ // 5) Default — leave untouched, optionally apply defaultEffort
+ if (options.defaultEffort && VALID_EFFORTS.has(options.defaultEffort)) {
+ out.reasoning = { effort: options.defaultEffort };
+ }
+ return out;
+}
diff --git a/src/lib/providers/xai/translators/claude.ts b/src/lib/providers/xai/translators/claude.ts
new file mode 100644
index 0000000000..7f9850ca1d
--- /dev/null
+++ b/src/lib/providers/xai/translators/claude.ts
@@ -0,0 +1,368 @@
+/**
+ * Claude (Anthropic Messages) ↔ xAI Responses translator
+ *
+ * Source of truth: router-for-me/CLIProxyAPI internal/translator/claude/xai/*
+ *
+ * Inbound: Anthropic /v1/messages { model, system, messages, tools, ... }
+ * Outbound (to xAI): xAI Responses { model, input, instructions, tools, ... }
+ *
+ * Reverse direction:
+ * - xAI completed → Anthropic Messages JSON (full message)
+ * - per-event xAI SSE → Anthropic SSE frames:
+ * message_start, content_block_start, content_block_delta,
+ * content_block_stop, message_delta, message_stop
+ */
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+interface AnthropicImageSource {
+ type: "base64" | "url";
+ media_type?: string;
+ data?: string;
+ url?: string;
+}
+
+interface AnthropicContentBlock {
+ type: string;
+ text?: string;
+ source?: AnthropicImageSource;
+ id?: string;
+ name?: string;
+ input?: unknown;
+ tool_use_id?: string;
+ content?: unknown;
+ thinking?: string;
+ [key: string]: unknown;
+}
+
+interface AnthropicMessage {
+ role: "user" | "assistant";
+ content: string | AnthropicContentBlock[];
+}
+
+interface AnthropicTool {
+ name?: string;
+ description?: string;
+ input_schema?: unknown;
+ parameters?: unknown;
+ type?: string;
+ function?: unknown;
+ [key: string]: unknown;
+}
+
+interface AnthropicThinking {
+ type?: string;
+ budget_tokens?: number;
+}
+
+interface AnthropicRequest {
+ model?: string;
+ messages?: AnthropicMessage[];
+ system?: string | Array<{ type: string; text: string }>;
+ tools?: AnthropicTool[];
+ tool_choice?: unknown;
+ temperature?: number;
+ top_p?: number;
+ max_tokens?: number;
+ stop_sequences?: string[];
+ metadata?: unknown;
+ thinking?: AnthropicThinking;
+ [key: string]: unknown;
+}
+
+interface XaiInputBlock {
+ type: string;
+ text?: string;
+ image_url?: string;
+ [key: string]: unknown;
+}
+
+interface XaiInputItem {
+ role?: string;
+ content?: XaiInputBlock[];
+ type?: string;
+ call_id?: string;
+ name?: string;
+ arguments?: string;
+ output?: string;
+ [key: string]: unknown;
+}
+
+interface XaiTool {
+ type: "function";
+ function: {
+ name?: string;
+ description?: string;
+ parameters?: unknown;
+ };
+}
+
+interface XaiReasoning {
+ effort: "low" | "medium" | "high";
+}
+
+interface XaiResponsesRequest {
+ model?: string;
+ input: XaiInputItem[];
+ instructions?: string;
+ tools?: XaiTool[];
+ tool_choice?: unknown;
+ temperature?: number;
+ top_p?: number;
+ max_output_tokens?: number;
+ stop?: string[];
+ metadata?: unknown;
+ reasoning?: XaiReasoning;
+ [key: string]: unknown;
+}
+
+interface XaiOutputContent {
+ type: string;
+ text?: string;
+ refusal?: string;
+}
+
+interface XaiOutputItem {
+ type: string;
+ content?: XaiOutputContent[];
+ call_id?: string;
+ id?: string;
+ name?: string;
+ arguments?: string;
+ summary?: Array<{ text?: string }>;
+ [key: string]: unknown;
+}
+
+interface XaiUsage {
+ input_tokens?: number;
+ output_tokens?: number;
+ prompt_tokens?: number;
+ completion_tokens?: number;
+}
+
+interface XaiCompleted {
+ id?: string;
+ model?: string;
+ output?: XaiOutputItem[];
+ usage?: XaiUsage;
+ [key: string]: unknown;
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function genId(prefix: string): string {
+ return `${prefix}_${Math.random().toString(36).slice(2, 14)}`;
+}
+
+/**
+ * Translate Anthropic content blocks into xAI input content blocks.
+ * Anthropic block types:
+ * "text", "image", "tool_use", "tool_result", "thinking"
+ */
+function blocksToXai(
+ blocks: string | AnthropicContentBlock[],
+): XaiInputBlock[] {
+ if (typeof blocks === "string") return [{ type: "input_text", text: blocks }];
+ if (!Array.isArray(blocks)) return [];
+ const out: XaiInputBlock[] = [];
+ for (const b of blocks) {
+ if (!b || typeof b !== "object") continue;
+ if (b.type === "text") out.push({ type: "input_text", text: b.text ?? "" });
+ else if (b.type === "image" && b.source) {
+ // Anthropic source { type: "base64"|"url", media_type, data | url }
+ if (b.source.type === "url") {
+ out.push({ type: "input_image", image_url: b.source.url });
+ } else {
+ const dataUrl = `data:${b.source.media_type ?? "image/png"};base64,${b.source.data ?? ""}`;
+ out.push({ type: "input_image", image_url: dataUrl });
+ }
+ } else if (b.type === "thinking") {
+ // dropped on the input side — xAI does not accept caller thinking blocks
+ } else {
+ out.push(b as XaiInputBlock);
+ }
+ }
+ return out;
+}
+
+/**
+ * Translate Anthropic tools[] into xAI tools[].
+ * Anthropic uses { name, description, input_schema } — xAI uses
+ * function-tool shape { type: "function", function: { name, description, parameters } }.
+ */
+function toolsAnthropicToXai(tools: AnthropicTool[]): XaiTool[] | undefined {
+ if (!Array.isArray(tools)) return undefined;
+ return tools.map((t) => {
+ if (!t || typeof t !== "object") return t as unknown as XaiTool;
+ if (t.type === "function" && t.function) return t as unknown as XaiTool;
+ return {
+ type: "function" as const,
+ function: {
+ name: t.name,
+ description: t.description,
+ parameters:
+ (t.input_schema ?? t.parameters ?? { type: "object" }),
+ },
+ };
+ });
+}
+
+function mapClaudeThinking(
+ thinking: AnthropicThinking,
+): XaiReasoning | undefined {
+ if (!thinking || typeof thinking !== "object") return undefined;
+ if (thinking.type === "enabled") {
+ if (typeof thinking.budget_tokens === "number") {
+ const b = thinking.budget_tokens;
+ if (b >= 16000) return { effort: "high" };
+ if (b >= 4000) return { effort: "medium" };
+ if (b > 0) return { effort: "low" };
+ }
+ return { effort: "medium" };
+ }
+ return undefined;
+}
+
+// ─── Public API ──────────────────────────────────────────────────────────────
+
+/**
+ * Translate an Anthropic Messages request body into an xAI Responses body.
+ */
+export function claudeRequestToXaiResponses(req: AnthropicRequest): XaiResponsesRequest {
+ if (!req || typeof req !== "object") return req as unknown as XaiResponsesRequest;
+ const input: XaiInputItem[] = [];
+
+ for (const m of req.messages ?? []) {
+ if (!m) continue;
+ if (m.role === "user") {
+ // Detect tool_result blocks → emit as function_call_output items
+ const blocks: AnthropicContentBlock[] = Array.isArray(m.content)
+ ? m.content
+ : [{ type: "text", text: m.content as string }];
+ const userBlocks: AnthropicContentBlock[] = [];
+ for (const b of blocks) {
+ if (b?.type === "tool_result") {
+ input.push({
+ type: "function_call_output",
+ call_id: b.tool_use_id,
+ output:
+ typeof b.content === "string"
+ ? b.content
+ : JSON.stringify(b.content ?? ""),
+ });
+ } else {
+ userBlocks.push(b);
+ }
+ }
+ if (userBlocks.length) input.push({ role: "user", content: blocksToXai(userBlocks) });
+ continue;
+ }
+ if (m.role === "assistant") {
+ const blocks: AnthropicContentBlock[] = Array.isArray(m.content)
+ ? m.content
+ : [{ type: "text", text: m.content as string }];
+ const textBlocks: AnthropicContentBlock[] = [];
+ for (const b of blocks) {
+ if (b?.type === "tool_use") {
+ if (textBlocks.length) {
+ input.push({ role: "assistant", content: blocksToXai(textBlocks.splice(0)) });
+ }
+ input.push({
+ type: "function_call",
+ call_id: b.id,
+ name: b.name,
+ arguments:
+ typeof b.input === "string" ? b.input : JSON.stringify(b.input ?? {}),
+ });
+ } else {
+ textBlocks.push(b);
+ }
+ }
+ if (textBlocks.length) input.push({ role: "assistant", content: blocksToXai(textBlocks) });
+ continue;
+ }
+ }
+
+ const out: XaiResponsesRequest = { model: req.model, input };
+
+ // System → instructions
+ if (req.system) {
+ if (typeof req.system === "string") {
+ out.instructions = req.system;
+ } else if (Array.isArray(req.system)) {
+ out.instructions = (req.system as Array<{ text?: string }>)
+ .map((b) => (typeof b === "string" ? b : (b?.text ?? "")))
+ .filter(Boolean)
+ .join("\n\n");
+ }
+ }
+
+ if (req.temperature != null) out.temperature = req.temperature;
+ if (req.top_p != null) out.top_p = req.top_p;
+ if (req.max_tokens != null) out.max_output_tokens = req.max_tokens;
+ if (req.stop_sequences) out.stop = req.stop_sequences;
+ if (req.metadata) out.metadata = req.metadata;
+ if (req.tool_choice) out.tool_choice = req.tool_choice;
+ if (req.thinking) out.reasoning = mapClaudeThinking(req.thinking);
+
+ const tools = req.tools ? toolsAnthropicToXai(req.tools) : undefined;
+ if (tools) out.tools = tools;
+ return out;
+}
+
+/**
+ * Convert an xAI completed response into an Anthropic Messages JSON.
+ */
+export function xaiCompletedToClaudeJson(
+ completed: XaiCompleted,
+ origReq: AnthropicRequest | null = null,
+): object {
+ const content: unknown[] = [];
+ let stopReason = "end_turn";
+ for (const item of completed?.output ?? []) {
+ if (!item) continue;
+ if (item.type === "message" && Array.isArray(item.content)) {
+ for (const c of item.content) {
+ if (c?.type === "output_text") content.push({ type: "text", text: c.text ?? "" });
+ if (c?.type === "refusal") content.push({ type: "text", text: c.refusal ?? "" });
+ }
+ } else if (item.type === "function_call") {
+ stopReason = "tool_use";
+ let inputObj: unknown = {};
+ try {
+ inputObj = item.arguments ? JSON.parse(item.arguments) : {};
+ } catch {
+ inputObj = { _raw: item.arguments };
+ }
+ content.push({
+ type: "tool_use",
+ id: item.call_id ?? item.id ?? genId("toolu"),
+ name: item.name,
+ input: inputObj,
+ });
+ } else if (item.type === "reasoning" && Array.isArray(item.summary)) {
+ const text = (item.summary as Array<{ text?: string }>)
+ .map((s) => s?.text ?? "")
+ .filter(Boolean)
+ .join("\n");
+ if (text) content.push({ type: "thinking", thinking: text });
+ }
+ }
+ const out: Record = {
+ id: completed?.id ?? genId("msg"),
+ type: "message",
+ role: "assistant",
+ model: completed?.model ?? origReq?.model ?? null,
+ content,
+ stop_reason: stopReason,
+ stop_sequence: null,
+ };
+ if (completed?.usage) {
+ const u = completed.usage;
+ out.usage = {
+ input_tokens: u.input_tokens ?? u.prompt_tokens ?? 0,
+ output_tokens: u.output_tokens ?? u.completion_tokens ?? 0,
+ };
+ }
+ return out;
+}
diff --git a/src/lib/providers/xai/translators/gemini.ts b/src/lib/providers/xai/translators/gemini.ts
new file mode 100644
index 0000000000..531dff4a9a
--- /dev/null
+++ b/src/lib/providers/xai/translators/gemini.ts
@@ -0,0 +1,371 @@
+/**
+ * Gemini ↔ xAI Responses translator
+ *
+ * Source of truth: router-for-me/CLIProxyAPI internal/translator/gemini/xai/*
+ *
+ * Inbound: Google Gemini generateContent { contents, systemInstruction, tools, ... }
+ * Outbound (to xAI): xAI Responses { model, input, instructions, tools, ... }
+ *
+ * Reverse direction:
+ * - xAI completed → Gemini generateContent JSON ({ candidates: [...] })
+ * - per-event xAI SSE → Gemini streamGenerateContent chunks
+ */
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+interface GeminiInlineData {
+ mimeType?: string;
+ data?: string;
+}
+
+interface GeminiFileData {
+ fileUri?: string;
+}
+
+interface GeminiFunctionCall {
+ id?: string;
+ name: string;
+ args?: unknown;
+}
+
+interface GeminiFunctionResponse {
+ id?: string;
+ name: string;
+ response?: unknown;
+}
+
+interface GeminiPart {
+ text?: string;
+ inlineData?: GeminiInlineData;
+ fileData?: GeminiFileData;
+ functionCall?: GeminiFunctionCall;
+ functionResponse?: GeminiFunctionResponse;
+}
+
+interface GeminiContent {
+ role?: string;
+ parts?: GeminiPart[];
+}
+
+interface GeminiFunctionDeclaration {
+ name: string;
+ description?: string;
+ parameters?: unknown;
+}
+
+interface GeminiTool {
+ functionDeclarations?: GeminiFunctionDeclaration[];
+ type?: string;
+ function?: unknown;
+}
+
+interface GeminiThinkingConfig {
+ thinkingBudget?: number;
+}
+
+interface GeminiGenerationConfig {
+ temperature?: number;
+ topP?: number;
+ maxOutputTokens?: number;
+ stopSequences?: string[];
+ responseSchema?: unknown;
+ thinkingConfig?: GeminiThinkingConfig;
+}
+
+interface GeminiRequest {
+ model?: string;
+ contents?: GeminiContent[];
+ systemInstruction?: string | { parts?: GeminiPart[] };
+ tools?: GeminiTool[];
+ toolConfig?: {
+ functionCallingConfig?: { mode?: string };
+ };
+ generationConfig?: GeminiGenerationConfig;
+ [key: string]: unknown;
+}
+
+interface XaiInputBlock {
+ type: string;
+ text?: string;
+ image_url?: string;
+}
+
+interface XaiInputItem {
+ role?: string;
+ content?: XaiInputBlock[];
+ type?: string;
+ call_id?: string;
+ name?: string;
+ arguments?: string;
+ output?: string;
+}
+
+interface XaiTool {
+ type: "function";
+ function: {
+ name: string;
+ description?: string;
+ parameters?: unknown;
+ };
+}
+
+interface XaiReasoning {
+ effort: "low" | "medium" | "high";
+}
+
+interface XaiResponsesRequest {
+ model?: string | null;
+ input: XaiInputItem[];
+ instructions?: string;
+ tools?: XaiTool[];
+ tool_choice?: string;
+ temperature?: number;
+ top_p?: number;
+ max_output_tokens?: number;
+ stop?: string[];
+ text?: unknown;
+ reasoning?: XaiReasoning;
+}
+
+interface XaiOutputContent {
+ type: string;
+ text?: string;
+ refusal?: string;
+}
+
+interface XaiFunctionCallItem {
+ type: "function_call";
+ name: string;
+ arguments?: string;
+ [key: string]: unknown;
+}
+
+interface XaiOutputItem {
+ type: string;
+ content?: XaiOutputContent[];
+ name?: string;
+ arguments?: string;
+ [key: string]: unknown;
+}
+
+interface XaiUsage {
+ input_tokens?: number;
+ output_tokens?: number;
+ prompt_tokens?: number;
+ completion_tokens?: number;
+ total_tokens?: number;
+}
+
+interface XaiCompleted {
+ output?: XaiOutputItem[];
+ model?: string;
+ usage?: XaiUsage;
+ [key: string]: unknown;
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+/**
+ * Convert Gemini parts[] into xAI input content blocks.
+ *
+ * Gemini part types:
+ * text, inlineData (mime+data b64), fileData (uri),
+ * functionCall (name, args), functionResponse (name, response)
+ */
+function partsToXaiBlocks(parts: GeminiPart[]): XaiInputBlock[] {
+ if (!Array.isArray(parts)) return [];
+ const out: XaiInputBlock[] = [];
+ for (const p of parts) {
+ if (!p || typeof p !== "object") continue;
+ if (typeof p.text === "string") {
+ out.push({ type: "input_text", text: p.text });
+ } else if (p.inlineData?.data) {
+ const mime = p.inlineData.mimeType ?? "image/png";
+ out.push({
+ type: "input_image",
+ image_url: `data:${mime};base64,${p.inlineData.data}`,
+ });
+ } else if (p.fileData?.fileUri) {
+ out.push({ type: "input_image", image_url: p.fileData.fileUri });
+ }
+ // functionCall / functionResponse handled at message level
+ }
+ return out;
+}
+
+/**
+ * Pull functionCall / functionResponse parts out of a Gemini message
+ * — these need to become standalone xAI input items, not nested content blocks.
+ */
+function extractFunctionItems(parts: GeminiPart[]): XaiInputItem[] {
+ const items: XaiInputItem[] = [];
+ if (!Array.isArray(parts)) return items;
+ for (const p of parts) {
+ if (p?.functionCall) {
+ items.push({
+ type: "function_call",
+ call_id: p.functionCall.id ?? p.functionCall.name,
+ name: p.functionCall.name,
+ arguments:
+ typeof p.functionCall.args === "string"
+ ? p.functionCall.args
+ : JSON.stringify(p.functionCall.args ?? {}),
+ });
+ } else if (p?.functionResponse) {
+ items.push({
+ type: "function_call_output",
+ call_id: p.functionResponse.id ?? p.functionResponse.name,
+ output:
+ typeof p.functionResponse.response === "string"
+ ? p.functionResponse.response
+ : JSON.stringify(p.functionResponse.response ?? {}),
+ });
+ }
+ }
+ return items;
+}
+
+/**
+ * Convert Gemini tools[] into xAI tools[].
+ * Gemini: [{ functionDeclarations: [{ name, description, parameters }] }, ...]
+ * xAI: [{ type: "function", function: { name, description, parameters } }, ...]
+ */
+function toolsGeminiToXai(tools: GeminiTool[]): XaiTool[] | undefined {
+ if (!Array.isArray(tools)) return undefined;
+ const out: XaiTool[] = [];
+ for (const t of tools) {
+ if (!t) continue;
+ if (Array.isArray(t.functionDeclarations)) {
+ for (const fn of t.functionDeclarations) {
+ out.push({
+ type: "function",
+ function: {
+ name: fn.name,
+ description: fn.description,
+ parameters: fn.parameters ?? { type: "object" },
+ },
+ });
+ }
+ } else if (t.type === "function") {
+ out.push(t as unknown as XaiTool);
+ }
+ }
+ return out.length ? out : undefined;
+}
+
+// ─── Public API ──────────────────────────────────────────────────────────────
+
+/**
+ * Translate a Gemini generateContent request body into an xAI Responses body.
+ *
+ * @param req - The Gemini request body
+ * @param model - Gemini path-level model (Gemini puts model in URL)
+ */
+export function geminiRequestToXaiResponses(
+ req: GeminiRequest,
+ model: string | null = null,
+): XaiResponsesRequest {
+ if (!req || typeof req !== "object") return req as unknown as XaiResponsesRequest;
+ const input: XaiInputItem[] = [];
+ for (const c of req.contents ?? []) {
+ if (!c) continue;
+ const role = c.role === "model" ? "assistant" : (c.role ?? "user");
+ // Pull function items first (they become standalone)
+ const fnItems = extractFunctionItems(c.parts ?? []);
+ if (fnItems.length) {
+ for (const it of fnItems) input.push(it);
+ // Filter remaining text/image parts
+ const remaining = (c.parts ?? []).filter(
+ (p) => !p?.functionCall && !p?.functionResponse,
+ );
+ if (remaining.length) input.push({ role, content: partsToXaiBlocks(remaining) });
+ } else {
+ input.push({ role, content: partsToXaiBlocks(c.parts ?? []) });
+ }
+ }
+
+ const out: XaiResponsesRequest = { model: model ?? req.model, input };
+
+ if (req.systemInstruction) {
+ const sys = req.systemInstruction;
+ if (typeof sys === "string") {
+ out.instructions = sys;
+ } else if (sys.parts) {
+ out.instructions = sys.parts
+ .map((p) => p?.text ?? "")
+ .filter(Boolean)
+ .join("\n\n");
+ }
+ }
+
+ const cfg: GeminiGenerationConfig = req.generationConfig ?? {};
+ if (cfg.temperature != null) out.temperature = cfg.temperature;
+ if (cfg.topP != null) out.top_p = cfg.topP;
+ if (cfg.maxOutputTokens != null) out.max_output_tokens = cfg.maxOutputTokens;
+ if (cfg.stopSequences) out.stop = cfg.stopSequences;
+ if (cfg.responseSchema)
+ out.text = { format: { type: "json_schema", schema: cfg.responseSchema } };
+ if (cfg.thinkingConfig?.thinkingBudget != null) {
+ const b = cfg.thinkingConfig.thinkingBudget;
+ if (b >= 16000) out.reasoning = { effort: "high" };
+ else if (b >= 4000) out.reasoning = { effort: "medium" };
+ else if (b > 0) out.reasoning = { effort: "low" };
+ }
+
+ const tools = req.tools ? toolsGeminiToXai(req.tools) : undefined;
+ if (tools) out.tools = tools;
+
+ if (req.toolConfig?.functionCallingConfig?.mode === "ANY") out.tool_choice = "required";
+ return out;
+}
+
+/**
+ * Convert an xAI completed response into a Gemini generateContent JSON.
+ */
+export function xaiCompletedToGeminiJson(
+ completed: XaiCompleted,
+ origReq: GeminiRequest | null = null,
+): object {
+ const parts: unknown[] = [];
+ const finishReason = "STOP";
+ for (const item of completed?.output ?? []) {
+ if (!item) continue;
+ if (item.type === "message" && Array.isArray(item.content)) {
+ for (const c of item.content) {
+ if (c?.type === "output_text") parts.push({ text: c.text ?? "" });
+ if (c?.type === "refusal") parts.push({ text: c.refusal ?? "" });
+ }
+ } else if (item.type === "function_call") {
+ let args: unknown = {};
+ try {
+ args = (item as XaiFunctionCallItem).arguments
+ ? JSON.parse((item as XaiFunctionCallItem).arguments ?? "")
+ : {};
+ } catch {
+ args = { _raw: (item as XaiFunctionCallItem).arguments };
+ }
+ parts.push({
+ functionCall: { name: item.name, args },
+ });
+ }
+ }
+ const candidate = {
+ content: { role: "model", parts },
+ finishReason,
+ index: 0,
+ };
+ const out: Record = {
+ candidates: [candidate],
+ modelVersion: completed?.model ?? origReq?.model ?? null,
+ };
+ if (completed?.usage) {
+ const u = completed.usage;
+ out.usageMetadata = {
+ promptTokenCount: u.input_tokens ?? u.prompt_tokens ?? 0,
+ candidatesTokenCount: u.output_tokens ?? u.completion_tokens ?? 0,
+ totalTokenCount:
+ u.total_tokens ?? ((u.input_tokens ?? 0) + (u.output_tokens ?? 0)),
+ };
+ }
+ return out;
+}
diff --git a/src/lib/providers/xai/translators/openai-chat.ts b/src/lib/providers/xai/translators/openai-chat.ts
new file mode 100644
index 0000000000..aa2bee6deb
--- /dev/null
+++ b/src/lib/providers/xai/translators/openai-chat.ts
@@ -0,0 +1,304 @@
+/**
+ * OpenAI Chat Completions ↔ xAI Responses translator
+ *
+ * Source of truth: router-for-me/CLIProxyAPI internal/translator/openai/xai/*
+ *
+ * Inbound: OpenAI Chat Completions { model, messages, tools, ... }
+ * Outbound (to xAI): xAI Responses { model, input, instructions, tools, ... }
+ *
+ * Reverse direction:
+ * - aggregated xAI response.completed → OpenAI ChatCompletion JSON
+ * - per-event xAI SSE → OpenAI ChatCompletion stream chunks
+ */
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+interface ContentPart {
+ type: string;
+ text?: string;
+ image_url?: unknown;
+ input_audio?: unknown;
+ [key: string]: unknown;
+}
+
+type MessageContent = string | ContentPart[];
+
+interface OpenAiToolCall {
+ id: string;
+ type: "function";
+ function: {
+ name: string;
+ arguments: string;
+ };
+}
+
+interface OpenAiMessage {
+ role: string;
+ content?: MessageContent;
+ tool_calls?: OpenAiToolCall[];
+ tool_call_id?: string;
+}
+
+interface OpenAiChatRequest {
+ model?: string;
+ messages?: OpenAiMessage[];
+ tools?: unknown[];
+ tool_choice?: unknown;
+ temperature?: number;
+ top_p?: number;
+ max_tokens?: number;
+ max_output_tokens?: number;
+ stop?: string | string[];
+ user?: string;
+ metadata?: unknown;
+ response_format?: unknown;
+ parallel_tool_calls?: boolean;
+ seed?: number;
+ reasoning_effort?: string;
+ reasoning?: unknown;
+ [key: string]: unknown;
+}
+
+interface XaiInputBlock {
+ type: string;
+ text?: string;
+ image_url?: unknown;
+ input_audio?: unknown;
+ [key: string]: unknown;
+}
+
+interface XaiInputItem {
+ role?: string;
+ content?: XaiInputBlock[];
+ type?: string;
+ call_id?: string;
+ name?: string;
+ arguments?: string;
+ output?: string;
+ [key: string]: unknown;
+}
+
+interface XaiResponsesRequest {
+ model?: string;
+ input: XaiInputItem[];
+ instructions?: string;
+ tools?: unknown[];
+ tool_choice?: unknown;
+ temperature?: number;
+ top_p?: number;
+ max_output_tokens?: number;
+ stop?: string | string[];
+ user?: string;
+ metadata?: unknown;
+ text?: unknown;
+ parallel_tool_calls?: boolean;
+ seed?: number;
+ reasoning?: unknown;
+ [key: string]: unknown;
+}
+
+interface XaiUsage {
+ input_tokens?: number;
+ output_tokens?: number;
+ prompt_tokens?: number;
+ completion_tokens?: number;
+ total_tokens?: number;
+}
+
+interface XaiOutputItem {
+ type: string;
+ content?: Array<{ type: string; text?: string; refusal?: string }>;
+ call_id?: string;
+ id?: string;
+ name?: string;
+ arguments?: string;
+ [key: string]: unknown;
+}
+
+interface XaiCompleted {
+ id?: string;
+ model?: string;
+ created_at?: number;
+ output?: XaiOutputItem[];
+ usage?: XaiUsage;
+ [key: string]: unknown;
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────
+
+function genId(prefix: string): string {
+ return `${prefix}_${Math.random().toString(36).slice(2, 14)}`;
+}
+
+/**
+ * Convert OpenAI message content (string | array of parts) into xAI input
+ * content blocks. Mirrors CLIProxyAPI mapping:
+ * "text" → { type: "input_text", text }
+ * "image_url" → { type: "input_image", image_url }
+ * "input_audio"→ { type: "input_audio", input_audio }
+ */
+function messageContentToXaiBlocks(content: MessageContent): XaiInputBlock[] {
+ if (typeof content === "string") {
+ return [{ type: "input_text", text: content }];
+ }
+ if (!Array.isArray(content)) return [];
+ return content
+ .map((p): XaiInputBlock | null => {
+ if (!p || typeof p !== "object") return null;
+ if (p.type === "text") return { type: "input_text", text: p.text ?? "" };
+ if (p.type === "image_url") return { type: "input_image", image_url: p.image_url };
+ if (p.type === "input_audio") return { type: "input_audio", input_audio: p.input_audio };
+ return p as XaiInputBlock; // passthrough unknown
+ })
+ .filter((b): b is XaiInputBlock => b !== null);
+}
+
+/**
+ * Convert OpenAI Chat tools[] (function-calling spec) into xAI tools[].
+ * xAI accepts the OpenAI function-tool shape verbatim, so passthrough.
+ */
+function toolsPassthrough(tools: unknown[]): unknown[] | undefined {
+ if (!Array.isArray(tools)) return undefined;
+ return tools.map((t) => ({ ...(t as object) }));
+}
+
+// ─── Public API ──────────────────────────────────────────────────────────────
+
+/**
+ * Translate an inbound OpenAI Chat Completions request body into an xAI Responses body.
+ */
+export function chatRequestToXaiResponses(req: OpenAiChatRequest): XaiResponsesRequest {
+ if (!req || typeof req !== "object") return req as unknown as XaiResponsesRequest;
+ const messages: OpenAiMessage[] = Array.isArray(req.messages) ? req.messages : [];
+ const instructionsParts: string[] = [];
+ const input: XaiInputItem[] = [];
+
+ for (const m of messages) {
+ if (!m) continue;
+ if (m.role === "system" || m.role === "developer") {
+ const txt =
+ typeof m.content === "string"
+ ? m.content
+ : Array.isArray(m.content)
+ ? (m.content as ContentPart[])
+ .map((p) => p?.text ?? "")
+ .filter(Boolean)
+ .join("\n")
+ : "";
+ if (txt) instructionsParts.push(txt);
+ continue;
+ }
+ if (m.role === "tool") {
+ input.push({
+ type: "function_call_output",
+ call_id: m.tool_call_id,
+ output:
+ typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""),
+ });
+ continue;
+ }
+ if (m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
+ // Emit any pre-existing assistant text first
+ if (m.content) {
+ input.push({ role: "assistant", content: messageContentToXaiBlocks(m.content) });
+ }
+ for (const tc of m.tool_calls) {
+ if (tc.type !== "function" || !tc.function) continue;
+ input.push({
+ type: "function_call",
+ call_id: tc.id,
+ name: tc.function.name,
+ arguments: tc.function.arguments ?? "",
+ });
+ }
+ continue;
+ }
+ input.push({ role: m.role ?? "user", content: messageContentToXaiBlocks(m.content ?? "") });
+ }
+
+ const out: XaiResponsesRequest = { model: req.model, input };
+ if (instructionsParts.length) out.instructions = instructionsParts.join("\n\n");
+
+ if (req.temperature != null) out.temperature = req.temperature;
+ if (req.top_p != null) out.top_p = req.top_p;
+ if (req.max_tokens != null) out.max_output_tokens = req.max_tokens;
+ if (req.max_output_tokens != null) out.max_output_tokens = req.max_output_tokens;
+ if (req.stop != null) out.stop = req.stop;
+ if (req.user) out.user = req.user;
+ if (req.metadata) out.metadata = req.metadata;
+ if (req.response_format) out.text = { format: req.response_format };
+ if (req.parallel_tool_calls != null) out.parallel_tool_calls = req.parallel_tool_calls;
+ if (req.seed != null) out.seed = req.seed;
+ if (req.reasoning_effort) out.reasoning = { effort: req.reasoning_effort };
+ if (req.reasoning) out.reasoning = req.reasoning;
+ if (req.tool_choice) out.tool_choice = req.tool_choice;
+
+ const tools = req.tools ? toolsPassthrough(req.tools) : undefined;
+ if (tools) out.tools = tools;
+ return out;
+}
+
+/**
+ * Aggregate output_text content blocks from an xAI completed response.
+ */
+function extractAssistantTextAndCalls(completed: XaiCompleted): {
+ text: string;
+ toolCalls: OpenAiToolCall[];
+ refusal: string | undefined;
+} {
+ let text = "";
+ const toolCalls: OpenAiToolCall[] = [];
+ const refusal: string[] = [];
+ for (const item of completed?.output ?? []) {
+ if (!item) continue;
+ if (item.type === "message" && Array.isArray(item.content)) {
+ for (const c of item.content) {
+ if (c?.type === "output_text" && typeof c.text === "string") text += c.text;
+ if (c?.type === "refusal" && typeof c.refusal === "string") refusal.push(c.refusal);
+ }
+ } else if (item.type === "function_call") {
+ toolCalls.push({
+ id: item.call_id ?? item.id ?? genId("call"),
+ type: "function",
+ function: { name: item.name ?? "", arguments: item.arguments ?? "" },
+ });
+ }
+ }
+ return { text, toolCalls, refusal: refusal.join("\n") || undefined };
+}
+
+/**
+ * Convert an aggregated xAI completed response into an OpenAI ChatCompletion JSON.
+ */
+export function xaiCompletedToChatJson(
+ completed: XaiCompleted,
+ origReq: OpenAiChatRequest | null = null,
+): object {
+ const { text, toolCalls, refusal } = extractAssistantTextAndCalls(completed);
+ const finishReason = toolCalls.length ? "tool_calls" : "stop";
+
+ const message: Record = {
+ role: "assistant",
+ content: text || (toolCalls.length ? null : ""),
+ };
+ if (toolCalls.length) message.tool_calls = toolCalls;
+ if (refusal) message.refusal = refusal;
+
+ const out: Record = {
+ id: completed?.id ?? genId("chatcmpl"),
+ object: "chat.completion",
+ created: completed?.created_at ?? Math.floor(Date.now() / 1000),
+ model: completed?.model ?? origReq?.model ?? null,
+ choices: [{ index: 0, message, finish_reason: finishReason }],
+ };
+ if (completed?.usage) {
+ const u = completed.usage;
+ out.usage = {
+ prompt_tokens: u.input_tokens ?? u.prompt_tokens ?? 0,
+ completion_tokens: u.output_tokens ?? u.completion_tokens ?? 0,
+ total_tokens:
+ u.total_tokens ?? ((u.input_tokens ?? 0) + (u.output_tokens ?? 0)),
+ };
+ }
+ return out;
+}
diff --git a/src/lib/providers/xai/translators/openai-responses.ts b/src/lib/providers/xai/translators/openai-responses.ts
new file mode 100644
index 0000000000..9f5c9f9884
--- /dev/null
+++ b/src/lib/providers/xai/translators/openai-responses.ts
@@ -0,0 +1,77 @@
+/**
+ * OpenAI Responses ↔ xAI Responses translator
+ *
+ * Source of truth: router-for-me/CLIProxyAPI internal/translator/openai-responses/xai/*
+ *
+ * xAI's Responses API is shape-compatible with OpenAI Responses, so this is
+ * mostly a passthrough. Translator responsibilities:
+ * - normalize response.id when synthesized
+ * - reconcile a small set of unsupported fields (drop unknown vendor-only opts)
+ * - apply the thinking patcher hook (delegated upstream)
+ */
+
+interface OpenAiResponsesRequest {
+ service_tier?: string;
+ messages?: unknown[];
+ input?: unknown[];
+ [key: string]: unknown;
+}
+
+interface XaiCompletedResponse {
+ object?: string;
+ status?: string;
+ [key: string]: unknown;
+}
+
+interface SseEvent {
+ event: string;
+ data: string;
+}
+
+/**
+ * Translate an inbound OpenAI-Responses request body into an xAI request body.
+ */
+export function openaiResponsesRequestToXai(req: OpenAiResponsesRequest): OpenAiResponsesRequest {
+ if (!req || typeof req !== "object") return req;
+ const out: OpenAiResponsesRequest = { ...req };
+
+ // xAI does not currently honor `parallel_tool_calls: false` on every model;
+ // mirror CLIProxyAPI: leave the flag as caller specified.
+
+ // Drop OpenAI-specific service_tier hint that xAI rejects.
+ if ("service_tier" in out) delete out.service_tier;
+
+ // xAI expects `input` (Responses-style); if the caller passed `messages`
+ // instead, leave them — xAI also accepts messages, but warn via metadata.
+ return out;
+}
+
+/**
+ * Translate an xAI completed response (already aggregated by collectSseToCompleted)
+ * into the OpenAI Responses JSON shape that callers expect.
+ */
+export function xaiCompletedToOpenaiResponses(
+ completed: XaiCompletedResponse,
+): XaiCompletedResponse {
+ if (!completed || typeof completed !== "object") return completed;
+ return {
+ ...completed,
+ object: completed.object ?? "response",
+ status: completed.status ?? "completed",
+ };
+}
+
+/**
+ * Pass-through transform for SSE event objects { event, data } emitted by
+ * iterateSseEvents(). For OpenAI Responses callers we forward verbatim — only
+ * normalize event names that diverge.
+ *
+ * Returns null to drop the event.
+ */
+export function xaiSseEventToOpenaiResponses(ev: SseEvent): SseEvent | null {
+ if (!ev || !ev.event) return ev;
+ // CLIProxyAPI drops xAI-internal `response.output_text.annotation.added`
+ // when the caller is OpenAI Responses, since OpenAI emits a different name.
+ if (ev.event === "response.output_text.annotation.added") return null;
+ return ev;
+}
diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts
index 40898f7727..7bf6371f87 100644
--- a/src/lib/usage/providerLimits.ts
+++ b/src/lib/usage/providerLimits.ts
@@ -327,7 +327,12 @@ export async function refreshAndUpdateCredentials(
| null;
if (!refreshResult) {
- if (connection.provider === "github" && connection.accessToken) {
+ // Refresh failed but we still have an accessToken — fall back to the
+ // existing token for ANY OAuth provider (graceful degradation) instead of
+ // hard-failing. Previously this was qualified to `provider === "github"`,
+ // which left every other provider stuck on a transient refresh failure even
+ // when a usable access token was still on hand.
+ if (connection.accessToken) {
return { connection, refreshed: false };
}
throw withStatus(
diff --git a/src/server/authz/routeGuard.ts b/src/server/authz/routeGuard.ts
index cab7af2560..a5fda0bc5d 100644
--- a/src/server/authz/routeGuard.ts
+++ b/src/server/authz/routeGuard.ts
@@ -38,6 +38,9 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray = [
"/api/system/version", // auto-update: spawns git checkout + npm install — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate)
"/api/db-backups/exportAll", // spawns tar for export archive (Hard Rules #15 + #17, found by 6A.8 route-guard gate)
"/api/local/", // T-12: 1-click local service launchers (Redis today; spawns podman/docker) — loopback-enforced by isLocalRequestAllowed() in src/lib/security/localEndpoints.ts (Hard Rules #15 + #17)
+ "/api/headroom/start", // Headroom token-saver proxy lifecycle: spawns headroom-ai python CLI (Hard Rules #15 + #17)
+ "/api/headroom/stop", // Headroom token-saver proxy lifecycle: sends SIGTERM/SIGKILL to managed PID (Hard Rules #15 + #17)
+ "/api/oauth/cursor/auto-import", // spawns `execFile("which", ["cursor"])` to verify a local Cursor install before importing creds — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate). Specific path only: the rest of /api/oauth/ (browser redirect/callback flows) must stay remote-reachable.
];
/**
@@ -78,6 +81,8 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray = [
"/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17)
"/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17)
"/api/local/", // T-12: 1-click local service launchers (Redis today) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17)
+ "/api/headroom/start", // spawns headroom-ai python CLI — must never be bypassable (Hard Rules #15 + #17)
+ "/api/headroom/stop", // kills tracked PID — must never be bypassable (Hard Rules #15 + #17)
];
/**
diff --git a/src/shared/components/ModelSelectModal.tsx b/src/shared/components/ModelSelectModal.tsx
index c6360bc920..e44dddecf4 100644
--- a/src/shared/components/ModelSelectModal.tsx
+++ b/src/shared/components/ModelSelectModal.tsx
@@ -3,6 +3,7 @@
import { useState, useMemo, useEffect } from "react";
import { useTranslations } from "next-intl";
import Modal from "./Modal";
+import { buildPassthroughAliasModels } from "./modelSelectModalHelpers";
import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
import {
@@ -235,14 +236,14 @@ export default function ModelSelectModal({
const providerCustomModels = customModels[providerId] || [];
if (providerInfo.passthroughModels) {
- const aliasModels = Object.entries(modelAliases as Record)
- .filter(([, fullModel]: [string, string]) => fullModel.startsWith(`${alias}/`))
- .map(([aliasName, fullModel]: [string, string]) => ({
- id: fullModel.replace(`${alias}/`, ""),
- name: aliasName,
- value: fullModel,
- source: "alias",
- }));
+ // Passthrough aliases are stored prefixed by the canonical providerId
+ // (e.g. "github/gpt-4"), not the public alias (e.g. "gh/"), so we must
+ // filter/strip by providerId — matching the sibling custom-provider
+ // branch below. (port: decolua/9router#485)
+ const aliasModels = buildPassthroughAliasModels(
+ modelAliases as Record,
+ providerId
+ );
// Merge custom models for passthrough providers
const customEntries = providerCustomModels
diff --git a/src/shared/components/layouts/DashboardLayout.tsx b/src/shared/components/layouts/DashboardLayout.tsx
index 0a0a3fa866..f72841a6e7 100644
--- a/src/shared/components/layouts/DashboardLayout.tsx
+++ b/src/shared/components/layouts/DashboardLayout.tsx
@@ -73,8 +73,8 @@ export default function DashboardLayout({ children }) {
/>
)}
- {/* Sidebar - Desktop */}
-
+ {/* Sidebar - Desktop: keep visibility independent from Tailwind hidden/lg:flex ordering. */}
+
,
+ providerId: string
+): PassthroughAliasModel[] {
+ const prefix = `${providerId}/`;
+ return Object.entries(modelAliases || {})
+ .filter(([, fullModel]) => typeof fullModel === "string" && fullModel.startsWith(prefix))
+ .map(([aliasName, fullModel]) => ({
+ id: fullModel.replace(prefix, ""),
+ name: aliasName,
+ value: fullModel,
+ source: "alias" as const,
+ }));
+}
diff --git a/src/shared/constants/config.ts b/src/shared/constants/config.ts
index 453a32e513..2a1e373360 100644
--- a/src/shared/constants/config.ts
+++ b/src/shared/constants/config.ts
@@ -20,6 +20,7 @@ export const API_ENDPOINTS = {
export const PROVIDER_ENDPOINTS = {
agentrouter: "https://agentrouter.org/v1/chat/completions",
openrouter: "https://openrouter.ai/api/v1/chat/completions",
+ dgrid: "https://api.dgrid.ai/v1/chat/completions",
glm: "https://api.z.ai/api/anthropic/v1/messages",
glmt: "https://api.z.ai/api/anthropic/v1/messages",
"bailian-coding-plan": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1/messages",
diff --git a/src/shared/constants/pricing/regional.ts b/src/shared/constants/pricing/regional.ts
index 739ec566fe..7f5886ab7d 100644
--- a/src/shared/constants/pricing/regional.ts
+++ b/src/shared/constants/pricing/regional.ts
@@ -71,6 +71,22 @@ export const DEFAULT_PRICING_REGIONAL = {
"kimi-latest": { input: 1.0, output: 4.0, cached: 0.5, reasoning: 6.0, cache_creation: 1.0 },
},
minimax: {
+ // MiniMax M3 — new default model (upstream upgrade from M2.7).
+ // Same api.minimax.io endpoint; pricing mirrors the M2.x base tier.
+ "minimax-m3": {
+ input: 0.5,
+ output: 2.0,
+ cached: 0.25,
+ reasoning: 3.0,
+ cache_creation: 0.5,
+ },
+ "MiniMax-M3": {
+ input: 0.5,
+ output: 2.0,
+ cached: 0.25,
+ reasoning: 3.0,
+ cache_creation: 0.5,
+ },
"minimax-m2.1": {
input: 0.5,
output: 2.0,
diff --git a/src/shared/constants/providers/apikey/frontier-labs.ts b/src/shared/constants/providers/apikey/frontier-labs.ts
index 1339e374e1..dc99ff4be1 100644
--- a/src/shared/constants/providers/apikey/frontier-labs.ts
+++ b/src/shared/constants/providers/apikey/frontier-labs.ts
@@ -27,6 +27,23 @@ export const APIKEY_PROVIDERS_FRONTIER = {
hasFree: true,
freeNote: "$10/month recurring free API credits",
},
+ pioneer: {
+ id: "pioneer",
+ alias: "pn",
+ name: "Pioneer AI",
+ icon: "rocket_launch",
+ color: "#7C5CFF",
+ textIcon: "PN",
+ website: "https://pioneer.ai",
+ notice: {
+ text: "Pioneer AI by Fastino Labs. Free $75 usage credits, no credit card required. Use API key auth with a pio_sk_... key. Only open-tier models (Qwen3, Llama, Gemma, SmolLM) work directly — gated models (Claude/GPT/Gemini) require prior fine-tuning via the Pioneer platform.",
+ apiKeyUrl: "https://agent.pioneer.ai/settings/api-keys",
+ signupUrl: "https://agent.pioneer.ai/auth",
+ },
+ hasFree: true,
+ freeNote: "$75 free usage credits — no credit card required",
+ serviceKinds: ["llm"],
+ },
anthropic: {
id: "anthropic",
alias: "anthropic",
diff --git a/src/shared/constants/providers/apikey/gateways.ts b/src/shared/constants/providers/apikey/gateways.ts
index 51ca63fbae..3b668e567f 100644
--- a/src/shared/constants/providers/apikey/gateways.ts
+++ b/src/shared/constants/providers/apikey/gateways.ts
@@ -40,6 +40,23 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
hasFree: true,
freeNote: "Free models at $0/token with :free suffix - 20 RPM / 200 RPD",
},
+ dgrid: {
+ id: "dgrid",
+ alias: "dgrid",
+ name: "DGrid",
+ icon: "router",
+ color: "#65A30D",
+ textIcon: "DG",
+ passthroughModels: true,
+ website: "https://dgrid.ai",
+ hasFree: true,
+ freeNote:
+ "DGrid Free Models Router: 10 requests/minute and 100 requests/day. " +
+ "A $5 lifetime top-up unlocks up to 20 requests/minute and 1,000 requests/day.",
+ apiHint:
+ "Create a DGrid API key at https://dgrid.ai, then use https://api.dgrid.ai/v1 " +
+ "as the OpenAI-compatible base URL.",
+ },
orcarouter: {
id: "orcarouter",
alias: "orcarouter",
diff --git a/src/shared/services/droidCustomModels.ts b/src/shared/services/droidCustomModels.ts
new file mode 100644
index 0000000000..f637db7fde
--- /dev/null
+++ b/src/shared/services/droidCustomModels.ts
@@ -0,0 +1,118 @@
+/**
+ * Build the OmniRoute `customModels` entries for Factory Droid's `settings.json`.
+ *
+ * Ported from upstream PR decolua/9router#618 (author Anurag Saxena) — multi-model
+ * support for the Factory Droid CLI tool. Adapted to OmniRoute branding
+ * (`custom:OmniRoute-`) and extracted as a pure helper so the route-handler
+ * logic is unit-testable without touching the filesystem.
+ */
+
+export interface DroidCustomModelOptions {
+ /** Already-normalized base URL (callers must ensure /v1 suffix). */
+ baseUrl: string;
+ /** API key to embed in every entry. */
+ apiKey: string;
+ /**
+ * Model id (e.g. `"openai/gpt-5"`) that should appear first in the array.
+ * When omitted (or not in `models`), entry index 0 stays first.
+ * When the empty string `""` is passed explicitly, no reordering happens
+ * — the caller signalled "do not promote any model to default".
+ */
+ activeModel?: string;
+}
+
+export interface DroidCustomModelEntry {
+ model: string;
+ id: string;
+ index: number;
+ baseUrl: string;
+ apiKey: string;
+ displayName: string;
+ maxOutputTokens: number;
+ noImageSupport: boolean;
+ provider: string;
+}
+
+/**
+ * Returns the trimmed, deduplicated, non-empty model ids in input order.
+ * Accepts either a `models` array (multi-model, upstream #618) or a legacy
+ * `model` string (single-model, pre-#618 behavior).
+ */
+export function normalizeDroidModelList(input: {
+ model?: unknown;
+ models?: unknown;
+}): string[] {
+ const raw: unknown[] = Array.isArray(input.models)
+ ? input.models
+ : typeof input.model === "string"
+ ? [input.model]
+ : [];
+
+ const seen = new Set();
+ const out: string[] = [];
+ for (const m of raw) {
+ if (typeof m !== "string") continue;
+ const trimmed = m.trim();
+ if (!trimmed || seen.has(trimmed)) continue;
+ seen.add(trimmed);
+ out.push(trimmed);
+ }
+ return out;
+}
+
+/**
+ * Build the final `customModels` array. Throws when `models` is empty —
+ * callers should validate before invoking.
+ */
+export function buildDroidCustomModels(
+ models: string[],
+ opts: DroidCustomModelOptions
+): DroidCustomModelEntry[] {
+ if (models.length === 0) {
+ throw new Error("buildDroidCustomModels requires at least one model");
+ }
+
+ // Default index resolution:
+ // - undefined → 0 (first entry stays first)
+ // - "" → -1 (do not promote anything)
+ // - → index of value in models (or 0 if not found)
+ let defaultIndex: number;
+ if (typeof opts.activeModel === "string") {
+ if (opts.activeModel === "") {
+ defaultIndex = -1;
+ } else {
+ const idx = models.indexOf(opts.activeModel);
+ defaultIndex = idx >= 0 ? idx : 0;
+ }
+ } else {
+ defaultIndex = 0;
+ }
+
+ const entries: DroidCustomModelEntry[] = models.map((m, i) => ({
+ model: m,
+ id: `custom:OmniRoute-${i}`,
+ index: i,
+ baseUrl: opts.baseUrl,
+ apiKey: opts.apiKey,
+ displayName: m,
+ maxOutputTokens: 131072,
+ noImageSupport: false,
+ provider: "openai",
+ }));
+
+ if (defaultIndex > 0 && entries[defaultIndex]) {
+ const [defaultEntry] = entries.splice(defaultIndex, 1);
+ entries.unshift({ ...defaultEntry, index: 0 });
+ entries.forEach((e, i) => {
+ e.index = i;
+ e.id = `custom:OmniRoute-${i}`;
+ });
+ }
+
+ return entries;
+}
+
+/** True when a `customModels` entry was written by OmniRoute (any index). */
+export function isOmniRouteCustomModel(entry: { id?: unknown } | null | undefined): boolean {
+ return typeof entry?.id === "string" && entry.id.startsWith("custom:OmniRoute");
+}
diff --git a/src/shared/utils/circuitBreaker.ts b/src/shared/utils/circuitBreaker.ts
index 00dbf6c28f..ee898e319b 100644
--- a/src/shared/utils/circuitBreaker.ts
+++ b/src/shared/utils/circuitBreaker.ts
@@ -25,6 +25,28 @@ import {
} from "../../lib/db/domainState";
import type { FailureKind } from "./classify429";
+/**
+ * #4602 — Detect a LOCAL stream-lifecycle error that must NOT count as a
+ * whole-provider failure. The Codex WebSocket→SSE bridge can throw a bare
+ * `Invalid state: Controller is already closed` (an enqueue-after-close on our
+ * own ReadableStream controller). It carries no `statusCode`, so it defaults to
+ * HTTP 502 and would otherwise trip the provider circuit breaker — blacklisting
+ * the entire Codex provider for a bug that lives in our bridge, not upstream.
+ * Use this with the breaker's `isFailure` option so the bridge error is ignored
+ * by the provider breaker while genuine upstream 5xx failures still count.
+ */
+export function isLocalStreamLifecycleError(error: unknown): boolean {
+ if (!error) return false;
+ const message =
+ typeof error === "string"
+ ? error
+ : typeof (error as { message?: unknown }).message === "string"
+ ? ((error as { message: string }).message as string)
+ : "";
+ if (!message) return false;
+ return /controller is already closed/i.test(message);
+}
+
export const STATE = {
CLOSED: "CLOSED",
DEGRADED: "DEGRADED",
diff --git a/src/shared/utils/clineAuth.ts b/src/shared/utils/clineAuth.ts
new file mode 100644
index 0000000000..929e61cbae
--- /dev/null
+++ b/src/shared/utils/clineAuth.ts
@@ -0,0 +1,62 @@
+/**
+ * Cline (cline.bot) auth-shape helpers.
+ *
+ * Cline's API expects the bearer token to be prefixed with `workos:` (the
+ * upstream auth provider), and a set of Cline client-identification headers
+ * (HTTP-Referer / X-Title / X-CLIENT-* / X-PLATFORM*). Plain `Bearer `
+ * without the `workos:` prefix is rejected upstream, so every Cline request
+ * must route its headers through `buildClineHeaders()`.
+ */
+
+const APP_VERSION = process.env.npm_package_version || "0.0.0";
+
+/**
+ * Normalize a raw Cline token into the `workos:`-prefixed access-token shape
+ * Cline expects. Idempotent: a token that already carries the prefix is
+ * returned untouched. Non-string / empty input yields an empty string.
+ */
+export function getClineAccessToken(token: unknown): string {
+ if (typeof token !== "string") return "";
+ const trimmed = token.trim();
+ if (!trimmed) return "";
+ return trimmed.startsWith("workos:") ? trimmed : `workos:${trimmed}`;
+}
+
+/**
+ * Build the full `Authorization` header value for a Cline request, or an empty
+ * string when no usable token is present.
+ */
+export function getClineAuthorizationHeader(token: unknown): string {
+ const accessToken = getClineAccessToken(token);
+ return accessToken ? `Bearer ${accessToken}` : "";
+}
+
+/**
+ * Build the complete Cline client header set, optionally merged with caller
+ * extras. The `Authorization` header is only added when a usable token is
+ * present (so callers can build probe headers without a token).
+ */
+export function buildClineHeaders(
+ token: unknown,
+ extraHeaders: Record = {}
+): Record {
+ const authorization = getClineAuthorizationHeader(token);
+ const headers: Record = {
+ "HTTP-Referer": "https://cline.bot",
+ "X-Title": "Cline",
+ "User-Agent": `OmniRoute/${APP_VERSION}`,
+ "X-PLATFORM": process.platform || "unknown",
+ "X-PLATFORM-VERSION": process.version || "unknown",
+ "X-CLIENT-TYPE": "omniroute",
+ "X-CLIENT-VERSION": APP_VERSION,
+ "X-CORE-VERSION": APP_VERSION,
+ "X-IS-MULTIROOT": "false",
+ ...extraHeaders,
+ };
+
+ if (authorization) {
+ headers.Authorization = authorization;
+ }
+
+ return headers;
+}
diff --git a/src/shared/validation/compressionConfigSchemas.ts b/src/shared/validation/compressionConfigSchemas.ts
index 293c11694f..e5ba212711 100644
--- a/src/shared/validation/compressionConfigSchemas.ts
+++ b/src/shared/validation/compressionConfigSchemas.ts
@@ -177,6 +177,26 @@ export const stackedPipelineStepSchema = z.discriminatedUnion("engine", [
.strict(),
]);
+/**
+ * Canonical engine → selectable-intensities map for the named-combos pipeline editor
+ * (Engine Combos UI). This is the SINGLE source of truth shared by the dashboard
+ * dropdowns and `stackedPipelineStepSchema`: every engine/intensity offered here is,
+ * by construction, accepted by the API update schema.
+ *
+ * Do NOT add an engine here that is not a branch of `stackedPipelineStepSchema` — the
+ * `PUT /api/context/combos/[id]` route validates against that discriminated union and
+ * would reject the payload with HTTP 400 (#4955: the UI previously offered `headroom`,
+ * `session-dedup`, `ccr`, `llmlingua`, none of which the union accepts, so selecting
+ * one silently failed the save). The parity is guarded by a unit test.
+ */
+export const STACKED_PIPELINE_ENGINE_INTENSITIES: Record = {
+ rtk: ["minimal", "standard", "aggressive"],
+ caveman: ["lite", "full", "ultra"],
+ lite: ["lite"],
+ aggressive: ["standard"],
+ ultra: ["ultra"],
+};
+
export const engineToggleSchema = z.object({
enabled: z.boolean(),
level: z.string().optional(),
diff --git a/src/shared/validation/schemas/cli.ts b/src/shared/validation/schemas/cli.ts
index 61e45ca001..b3e6a223e8 100644
--- a/src/shared/validation/schemas/cli.ts
+++ b/src/shared/validation/schemas/cli.ts
@@ -72,4 +72,15 @@ export const cliModelConfigSchema = z.object({
reasoningEffort: z.enum(["none", "low", "medium", "high", "xhigh"]).optional(),
wireApi: z.enum(["chat", "responses"]).optional(),
modelMappings: z.record(z.string().trim().min(1), z.string().trim().min(1)).optional(),
+});
+
+/**
+ * Multi-model variant of `cliModelConfigSchema`. Adds optional `models`
+ * (array of strings, takes precedence over `model`) and `activeModel`
+ * (string id to promote to first position). Ported from upstream PR
+ * decolua/9router#618 for the Factory Droid CLI tool.
+ */
+export const cliMultiModelConfigSchema = cliModelConfigSchema.extend({
+ models: z.array(z.string().trim().min(1)).optional(),
+ activeModel: z.string().optional(),
});
\ No newline at end of file
diff --git a/src/shared/validation/schemas/combo.ts b/src/shared/validation/schemas/combo.ts
index 8590ff9ebf..92d87757b5 100644
--- a/src/shared/validation/schemas/combo.ts
+++ b/src/shared/validation/schemas/combo.ts
@@ -229,6 +229,12 @@ export const createComboSchema = z.object({
tool_filter_regex: z.string().max(1000).optional(),
context_cache_protection: z.boolean().optional(),
context_length: z.number().int().min(1000).max(2000000).optional(),
+ // Optional embedding dimensions override for embedding combos.
+ // When set, the value is injected into every upstream embedding request as
+ // the `dimensions` field (and translated to `outputDimensionality` for Gemini).
+ // Stored as a string to match the OpenAI API convention; coerced to number
+ // by the embedding handler. Leave unset to use each model's default.
+ dimensions: z.string().regex(/^\d+$/, "dimensions must be a positive integer string").optional().nullable(),
});
export const updateComboDefaultsSchema = z
@@ -278,6 +284,7 @@ export const updateComboSchema = z
context_cache_protection: z.boolean().optional(),
context_length: z.number().int().min(1000).max(2000000).optional().nullable(),
compressionOverride: comboCompressionOverrideSchema.optional(),
+ dimensions: z.string().regex(/^\d+$/, "dimensions must be a positive integer string").optional().nullable(),
})
.superRefine((value, ctx) => {
if (
@@ -292,7 +299,8 @@ export const updateComboSchema = z
value.tool_filter_regex === undefined &&
value.context_cache_protection === undefined &&
value.context_length === undefined &&
- value.compressionOverride === undefined
+ value.compressionOverride === undefined &&
+ value.dimensions === undefined
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts
index d52a9fab00..0856af5cb9 100644
--- a/src/sse/handlers/chat.ts
+++ b/src/sse/handlers/chat.ts
@@ -17,6 +17,7 @@ import {
isDailyQuotaExhausted,
} from "@omniroute/open-sse/services/accountFallback.ts";
import { getModelInfo, getComboForModel } from "../services/model";
+import { resolveBareModelToConnectionDefault } from "@omniroute/open-sse/services/model.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { applyNoThinkingAlias } from "@omniroute/open-sse/utils/noThinkingAlias.ts";
import { handleComboChat } from "@omniroute/open-sse/services/combo.ts";
@@ -45,6 +46,8 @@ import * as log from "../utils/logger";
import { checkAndRefreshToken } from "../services/tokenRefresh";
import { createHookContext, runHooks, initPreRequestRegistry } from "@/lib/middleware/registry";
import { deleteHandoff, getHandoff } from "@/lib/db/contextHandoffs";
+import { updateCombo } from "@/lib/db/combos";
+import { promoteSuccessfulComboModel } from "@/lib/combos/autoPromote";
import {
deleteSessionAccountAffinity,
getCachedSettings,
@@ -74,7 +77,7 @@ import { connectionHasExtraKeys } from "@omniroute/open-sse/services/apiKeyRotat
// Pipeline integration — wired modules
import { classify429FromError, type FailureKind } from "@/shared/utils/classify429";
import { resolveUseUpstream429BreakerHints } from "@/shared/utils/providerHints";
-import { getCircuitBreaker } from "../../shared/utils/circuitBreaker";
+import { getCircuitBreaker, isLocalStreamLifecycleError } from "../../shared/utils/circuitBreaker";
import { markAccountExhaustedFrom429 } from "../../domain/quotaCache";
import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTelemetry";
import { generateRequestId } from "../../shared/utils/requestId";
@@ -191,6 +194,7 @@ function intersectAllowedConnectionIds(primary: unknown, secondary: unknown): st
}
const PROVIDER_BREAKER_FAILURE_STATUSES = new Set([408, 500, 502, 503, 504]);
+const comboPromoteDeps = { updateCombo, info: log.info, warn: log.warn };
/**
* Handle chat completion request
@@ -269,11 +273,13 @@ export async function handleChat(
`${url.pathname} | ${modelStr} | ${msgCount} msgs${toolCount ? ` | ${toolCount} tools` : ""}${effort ? ` | effort=${effort}` : ""}`
);
- // Log API key (masked)
+ // Log only that an API key was provided — never the key itself, not even a
+ // masked prefix/last4. These debug lines get copied verbatim into bug reports
+ // and support tickets, so any key fragment is sensitive.
const authHeader = request.headers.get("Authorization");
const apiKey = extractApiKey(request);
if (authHeader && apiKey) {
- log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
+ log.debug("AUTH", "API key provided");
} else {
log.debug("AUTH", "No API key provided (local mode)");
}
@@ -670,7 +676,17 @@ export async function handleChat(
},
target?.effectiveComboStrategy ?? combo.strategy,
true
- ),
+ ).then(async (res: Response) => {
+ // Auto-promote the winning combo model to position #1 (opt-in flag).
+ if (res?.ok)
+ await promoteSuccessfulComboModel(
+ combo,
+ m,
+ settings as Record,
+ comboPromoteDeps
+ );
+ return res;
+ }),
isModelAvailable: checkModelAvailable,
log,
settings,
@@ -934,6 +950,9 @@ async function handleSingleModelChat(
const breaker = getCircuitBreaker(provider, {
failureThreshold: providerProfile.failureThreshold,
resetTimeout: providerProfile.resetTimeoutMs,
+ // #4602: a local WS-bridge "Controller is already closed" throw is not an
+ // upstream outage — keep it from tripping the whole-provider breaker.
+ isFailure: (e) => !isLocalStreamLifecycleError(e),
onStateChange: (name: string, from: string, to: string) =>
log.info("CIRCUIT", `${name}: ${from} → ${to}`),
...(useHints429
@@ -1076,7 +1095,15 @@ async function handleSingleModelChat(
const accountId = credentials.connectionId.slice(0, 8);
log.info("AUTH", `Using ${provider} account: ${accountId}...`);
- let requestBody = body;
+ // #474: when the request used a bare model name (no "/" — e.g. an alias
+ // that resolved to "auto") and the selected connection declares a
+ // defaultModel, resolve the bare name to that real model ID before the
+ // upstream call so the provider receives a concrete model rather than the
+ // placeholder. A "/"-qualified model name is always left untouched.
+ const effectiveModel =
+ resolveBareModelToConnectionDefault(modelStr, model, credentials.defaultModel) ?? model;
+ let requestBody =
+ effectiveModel !== model ? { ...body, model: `${provider}/${effectiveModel}` } : body;
let injectedHandoff = null;
if (
comboStrategy === "context-relay" &&
@@ -1139,7 +1166,7 @@ async function handleSingleModelChat(
breaker,
body: requestBody,
provider,
- model,
+ model: effectiveModel,
refreshedCredentials,
proxyInfo,
log,
diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts
index e8abf6533e..daad871601 100644
--- a/src/sse/handlers/chatHelpers.ts
+++ b/src/sse/handlers/chatHelpers.ts
@@ -26,7 +26,11 @@ import {
isTlsFingerprintActive,
} from "@omniroute/open-sse/utils/proxyFetch.ts";
import { resolveProxyForConnection } from "@/lib/localDb";
-import { CircuitBreakerOpenError, getCircuitBreaker } from "../../shared/utils/circuitBreaker";
+import {
+ CircuitBreakerOpenError,
+ getCircuitBreaker,
+ isLocalStreamLifecycleError,
+} from "../../shared/utils/circuitBreaker";
import { classify429FromError, type FailureKind } from "../../shared/utils/classify429";
import { resolveUseUpstream429BreakerHints } from "../../shared/utils/providerHints";
@@ -324,6 +328,9 @@ export async function checkPipelineGates(
failureThreshold: providerProfile.failureThreshold ?? providerProfile.circuitBreakerThreshold,
degradationThreshold: providerProfile.degradationThreshold,
resetTimeout: providerProfile.resetTimeoutMs ?? providerProfile.circuitBreakerReset,
+ // #4602: a local WS-bridge "Controller is already closed" throw is not an
+ // upstream outage — keep it from tripping the whole-provider breaker.
+ isFailure: (e) => !isLocalStreamLifecycleError(e),
onStateChange: (name: string, from: string, to: string) =>
log.info("CIRCUIT", `${name}: ${from} → ${to}`),
...(useHints429
diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts
index f11681a23b..1ea8d7f3dc 100644
--- a/src/sse/services/auth.ts
+++ b/src/sse/services/auth.ts
@@ -78,6 +78,7 @@ interface ProviderConnectionView {
tokenExpiresAt: string | null;
expiresAt: string | null;
projectId: string | null;
+ defaultModel: string | null;
providerSpecificData: JsonRecord;
lastUsedAt: string | null;
consecutiveUseCount: number;
@@ -171,6 +172,7 @@ function toProviderConnection(value: unknown): ProviderConnectionView {
tokenExpiresAt: toStringOrNull(row.tokenExpiresAt),
expiresAt: toStringOrNull(row.expiresAt),
projectId: toStringOrNull(row.projectId),
+ defaultModel: toStringOrNull(row.defaultModel),
providerSpecificData: asRecord(row.providerSpecificData),
lastUsedAt: toStringOrNull(row.lastUsedAt),
consecutiveUseCount: toNumber(row.consecutiveUseCount, 0),
@@ -787,6 +789,7 @@ function buildSyntheticNoAuthCredentials(providerSpecificData: JsonRecord = {}):
refreshToken: null;
expiresAt: null;
projectId: null;
+ defaultModel: null;
copilotToken: null;
providerSpecificData: JsonRecord;
connectionId: typeof SYNTHETIC_NOAUTH_CONNECTION_ID;
@@ -808,6 +811,7 @@ function buildSyntheticNoAuthCredentials(providerSpecificData: JsonRecord = {}):
refreshToken: null,
expiresAt: null,
projectId: null,
+ defaultModel: null,
copilotToken: null,
providerSpecificData,
connectionId: SYNTHETIC_NOAUTH_CONNECTION_ID,
@@ -1600,6 +1604,10 @@ export async function getProviderCredentials(
refreshToken: connection.refreshToken,
expiresAt: connection.tokenExpiresAt || connection.expiresAt || null,
projectId: connection.projectId,
+ // #474: surface the connection's configured defaultModel so the chat /
+ // embeddings handlers can resolve a bare model name (e.g. an alias that
+ // resolved to "auto") to a real provider model ID before the upstream call.
+ defaultModel: connection.defaultModel || null,
copilotToken:
typeof connection.providerSpecificData.copilotToken === "string"
? connection.providerSpecificData.copilotToken
diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts
index 651694281f..cb49cf4f4e 100644
--- a/src/sse/services/model.ts
+++ b/src/sse/services/model.ts
@@ -1,5 +1,12 @@
// Re-export from open-sse with localDb integration
-import { getModelAliases, getComboByName, getProviderNodes, getCustomModels } from "@/lib/localDb";
+import {
+ getModelAliases,
+ getComboByName,
+ getComboById,
+ getComboByNameInsensitive,
+ getProviderNodes,
+ getCustomModels,
+} from "@/lib/localDb";
import { getCachedSettings } from "@/lib/localDb";
import { getComboStepTarget } from "@/lib/combos/steps";
import {
@@ -213,6 +220,22 @@ export async function getCombo(modelStr) {
}
}
+ // #4446: the opencode-plugin publishes combos as ModelV2 `id: combo.id`, and
+ // the OpenCode `--model` dispatch path forwards a lowercased bare slug. The
+ // exact, case-sensitive name match above misses both a combo addressed by its
+ // stored id (UUID/slug) and a lowercased display name (e.g. "master-light" for
+ // a combo named "MASTER-LIGHT"). These two fallbacks only run after the exact
+ // match fails, so they never re-route a combo that already resolves today.
+ combo = await getComboById(modelStr);
+ if (combo && combo.models && combo.models.length > 0) {
+ return combo;
+ }
+
+ combo = await getComboByNameInsensitive(modelStr);
+ if (combo && combo.models && combo.models.length > 0) {
+ return combo;
+ }
+
return null;
}
diff --git a/src/sse/services/tokenRefresh.ts b/src/sse/services/tokenRefresh.ts
index 63415b09b0..ab51be2742 100755
--- a/src/sse/services/tokenRefresh.ts
+++ b/src/sse/services/tokenRefresh.ts
@@ -174,7 +174,7 @@ export async function checkAndRefreshToken(provider: string, credentials: any) {
if (updatedCredentials.expiresAt) {
const expiresAt = new Date(updatedCredentials.expiresAt).getTime();
const now = Date.now();
- const refreshLead = _getRefreshLeadMs(provider);
+ const refreshLead = _getRefreshLeadMs(provider, updatedCredentials.providerSpecificData);
if (expiresAt - now < refreshLead) {
log.info("TOKEN_REFRESH", "Token expiring soon, refreshing proactively", {
diff --git a/tests/snapshots/provider/translate-path.json b/tests/snapshots/provider/translate-path.json
new file mode 100644
index 0000000000..8a81378082
--- /dev/null
+++ b/tests/snapshots/provider/translate-path.json
@@ -0,0 +1,4146 @@
+{
+ "adapta-web": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://agent.adapta.one/api/chat/stream/v1",
+ "stream": "https://agent.adapta.one/api/chat/stream/v1"
+ }
+ },
+ "agentrouter": {
+ "format": "claude",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07",
+ "Anthropic-Dangerous-Direct-Browser-Access": "true",
+ "Anthropic-Version": "2023-06-01",
+ "Content-Type": "application/json",
+ "User-Agent": "claude-cli/2.1.187 (external, cli)",
+ "X-App": "cli",
+ "X-Stainless-Arch": "x64",
+ "X-Stainless-Helper-Method": "stream",
+ "X-Stainless-Lang": "js",
+ "X-Stainless-Os": "Linux",
+ "X-Stainless-Package-Version": "0.94.0",
+ "X-Stainless-Retry-Count": "0",
+ "X-Stainless-Runtime": "node",
+ "X-Stainless-Runtime-Version": "v24.3.0",
+ "X-Stainless-Timeout": "600",
+ "x-api-key": ""
+ },
+ "nonStream": {
+ "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07",
+ "Anthropic-Dangerous-Direct-Browser-Access": "true",
+ "Anthropic-Version": "2023-06-01",
+ "Content-Type": "application/json",
+ "User-Agent": "claude-cli/2.1.187 (external, cli)",
+ "X-App": "cli",
+ "X-Stainless-Arch": "x64",
+ "X-Stainless-Helper-Method": "stream",
+ "X-Stainless-Lang": "js",
+ "X-Stainless-Os": "Linux",
+ "X-Stainless-Package-Version": "0.94.0",
+ "X-Stainless-Retry-Count": "0",
+ "X-Stainless-Runtime": "node",
+ "X-Stainless-Runtime-Version": "v24.3.0",
+ "X-Stainless-Timeout": "600",
+ "x-api-key": ""
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07",
+ "Anthropic-Dangerous-Direct-Browser-Access": "true",
+ "Anthropic-Version": "2023-06-01",
+ "Content-Type": "application/json",
+ "User-Agent": "claude-cli/2.1.187 (external, cli)",
+ "X-App": "cli",
+ "X-Stainless-Arch": "x64",
+ "X-Stainless-Helper-Method": "stream",
+ "X-Stainless-Lang": "js",
+ "X-Stainless-Os": "Linux",
+ "X-Stainless-Package-Version": "0.94.0",
+ "X-Stainless-Retry-Count": "0",
+ "X-Stainless-Runtime": "node",
+ "X-Stainless-Runtime-Version": "v24.3.0",
+ "X-Stainless-Timeout": "600",
+ "x-api-key": ""
+ }
+ },
+ "url": {
+ "nonStream": "https://agentrouter.org/v1/messages",
+ "stream": "https://agentrouter.org/v1/messages"
+ }
+ },
+ "agy": {
+ "format": "antigravity",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/132.0.6834.160 Electron/39.2.3"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/132.0.6834.160 Electron/39.2.3"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/132.0.6834.160 Electron/39.2.3"
+ }
+ },
+ "url": {
+ "nonStream": "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent",
+ "stream": "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"
+ }
+ },
+ "ai21": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.ai21.com/studio/v1/chat/completions",
+ "stream": "https://api.ai21.com/studio/v1/chat/completions"
+ }
+ },
+ "aimlapi": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.aimlapi.com/v1/chat/completions",
+ "stream": "https://api.aimlapi.com/v1/chat/completions"
+ }
+ },
+ "alibaba": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
+ "stream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions"
+ }
+ },
+ "alibaba-cn": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
+ "stream": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
+ }
+ },
+ "anthropic": {
+ "format": "claude",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07",
+ "Anthropic-Version": "2023-06-01",
+ "Content-Type": "application/json",
+ "x-api-key": ""
+ },
+ "nonStream": {
+ "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07",
+ "Anthropic-Version": "2023-06-01",
+ "Content-Type": "application/json",
+ "x-api-key": ""
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07",
+ "Anthropic-Version": "2023-06-01",
+ "Content-Type": "application/json",
+ "x-api-key": ""
+ }
+ },
+ "url": {
+ "nonStream": "https://api.anthropic.com/v1/messages?beta=true",
+ "stream": "https://api.anthropic.com/v1/messages?beta=true"
+ }
+ },
+ "antigravity": {
+ "format": "antigravity",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/132.0.6834.160 Electron/39.2.3"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/132.0.6834.160 Electron/39.2.3"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "Antigravity/4.2.0 (X11; Linux x86_64) Chrome/132.0.6834.160 Electron/39.2.3"
+ }
+ },
+ "url": {
+ "nonStream": "https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent",
+ "stream": "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"
+ }
+ },
+ "api-airforce": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://endpoint-proxy.local",
+ "X-Title": "Endpoint Proxy"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://endpoint-proxy.local",
+ "X-Title": "Endpoint Proxy"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://endpoint-proxy.local",
+ "X-Title": "Endpoint Proxy"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.airforce/v1/chat/completions",
+ "stream": "https://api.airforce/v1/chat/completions"
+ }
+ },
+ "baichuan": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.baichuan-ai.com/v1/chat/completions",
+ "stream": "https://api.baichuan-ai.com/v1/chat/completions"
+ }
+ },
+ "baidu": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://qianfan.baidubce.com/v2/chat/completions",
+ "stream": "https://qianfan.baidubce.com/v2/chat/completions"
+ }
+ },
+ "bailian-coding-plan": {
+ "format": "claude",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Anthropic-Version": "2023-06-01",
+ "Content-Type": "application/json",
+ "x-api-key": ""
+ },
+ "nonStream": {
+ "Anthropic-Version": "2023-06-01",
+ "Content-Type": "application/json",
+ "x-api-key": ""
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Anthropic-Version": "2023-06-01",
+ "Content-Type": "application/json",
+ "x-api-key": ""
+ }
+ },
+ "url": {
+ "nonStream": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1",
+ "stream": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic/v1"
+ }
+ },
+ "baseten": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://inference.baseten.co/v1/chat/completions",
+ "stream": "https://inference.baseten.co/v1/chat/completions"
+ }
+ },
+ "bazaarlink": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://bazaarlink.ai/api/v1/chat/completions",
+ "stream": "https://bazaarlink.ai/api/v1/chat/completions"
+ }
+ },
+ "bedrock": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {}
+ },
+ "blackbox": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.blackbox.ai/v1/chat/completions",
+ "stream": "https://api.blackbox.ai/v1/chat/completions"
+ }
+ },
+ "blackbox-web": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://app.blackbox.ai/api/chat",
+ "stream": "https://app.blackbox.ai/api/chat"
+ }
+ },
+ "bluesminds": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.bluesminds.com/v1/chat/completions",
+ "stream": "https://api.bluesminds.com/v1/chat/completions"
+ }
+ },
+ "byteplus": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://ark.ap-southeast.bytepluses.com/api/v3/chat/completions",
+ "stream": "https://ark.ap-southeast.bytepluses.com/api/v3/chat/completions"
+ }
+ },
+ "bytez": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.bytez.com/models/v2",
+ "stream": "https://api.bytez.com/models/v2"
+ }
+ },
+ "cerebras": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.cerebras.ai/v1/chat/completions",
+ "stream": "https://api.cerebras.ai/v1/chat/completions"
+ }
+ },
+ "chatgpt-web": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://chatgpt.com/backend-api/conversation",
+ "stream": "https://chatgpt.com/backend-api/conversation"
+ }
+ },
+ "chipotle": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://amelia.chipotle.com",
+ "stream": "https://amelia.chipotle.com"
+ }
+ },
+ "chutes": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.chutesai.com/v1/chat/completions",
+ "stream": "https://api.chutesai.com/v1/chat/completions"
+ }
+ },
+ "claude": {
+ "format": "claude",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07",
+ "Anthropic-Dangerous-Direct-Browser-Access": "true",
+ "Anthropic-Version": "2023-06-01",
+ "Content-Type": "application/json",
+ "User-Agent": "claude-cli/2.1.187 (external, cli)",
+ "X-App": "cli",
+ "X-Stainless-Arch": "x64",
+ "X-Stainless-Helper-Method": "stream",
+ "X-Stainless-Lang": "js",
+ "X-Stainless-Os": "Linux",
+ "X-Stainless-Package-Version": "0.94.0",
+ "X-Stainless-Retry-Count": "0",
+ "X-Stainless-Runtime": "node",
+ "X-Stainless-Runtime-Version": "v24.3.0",
+ "X-Stainless-Timeout": "600",
+ "x-api-key": ""
+ },
+ "nonStream": {
+ "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07",
+ "Anthropic-Dangerous-Direct-Browser-Access": "true",
+ "Anthropic-Version": "2023-06-01",
+ "Content-Type": "application/json",
+ "User-Agent": "claude-cli/2.1.187 (external, cli)",
+ "X-App": "cli",
+ "X-Stainless-Arch": "x64",
+ "X-Stainless-Helper-Method": "stream",
+ "X-Stainless-Lang": "js",
+ "X-Stainless-Os": "Linux",
+ "X-Stainless-Package-Version": "0.94.0",
+ "X-Stainless-Retry-Count": "0",
+ "X-Stainless-Runtime": "node",
+ "X-Stainless-Runtime-Version": "v24.3.0",
+ "X-Stainless-Timeout": "600",
+ "x-api-key": ""
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28,advisor-tool-2026-03-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07",
+ "Anthropic-Dangerous-Direct-Browser-Access": "true",
+ "Anthropic-Version": "2023-06-01",
+ "Content-Type": "application/json",
+ "User-Agent": "claude-cli/2.1.187 (external, cli)",
+ "X-App": "cli",
+ "X-Stainless-Arch": "x64",
+ "X-Stainless-Helper-Method": "stream",
+ "X-Stainless-Lang": "js",
+ "X-Stainless-Os": "Linux",
+ "X-Stainless-Package-Version": "0.94.0",
+ "X-Stainless-Retry-Count": "0",
+ "X-Stainless-Runtime": "node",
+ "X-Stainless-Runtime-Version": "v24.3.0",
+ "X-Stainless-Timeout": "600",
+ "x-api-key": ""
+ }
+ },
+ "url": {
+ "nonStream": "https://api.anthropic.com/v1/messages?beta=true",
+ "stream": "https://api.anthropic.com/v1/messages?beta=true"
+ }
+ },
+ "claude-web": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://claude.ai/api/organizations",
+ "stream": "https://claude.ai/api/organizations"
+ }
+ },
+ "cline": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://cline.bot",
+ "User-Agent": "OmniRoute/0.0.0",
+ "X-CLIENT-TYPE": "omniroute",
+ "X-CLIENT-VERSION": "0.0.0",
+ "X-CORE-VERSION": "0.0.0",
+ "X-IS-MULTIROOT": "false",
+ "X-PLATFORM": "linux",
+ "X-PLATFORM-VERSION": "v24.16.0",
+ "X-Title": "Cline"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://cline.bot",
+ "User-Agent": "OmniRoute/0.0.0",
+ "X-CLIENT-TYPE": "omniroute",
+ "X-CLIENT-VERSION": "0.0.0",
+ "X-CORE-VERSION": "0.0.0",
+ "X-IS-MULTIROOT": "false",
+ "X-PLATFORM": "linux",
+ "X-PLATFORM-VERSION": "v24.16.0",
+ "X-Title": "Cline"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "HTTP-Referer": "https://cline.bot",
+ "User-Agent": "OmniRoute/0.0.0",
+ "X-CLIENT-TYPE": "omniroute",
+ "X-CLIENT-VERSION": "0.0.0",
+ "X-CORE-VERSION": "0.0.0",
+ "X-IS-MULTIROOT": "false",
+ "X-PLATFORM": "linux",
+ "X-PLATFORM-VERSION": "v24.16.0",
+ "X-Title": "Cline"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.cline.bot/api/v1/chat/completions",
+ "stream": "https://api.cline.bot/api/v1/chat/completions"
+ }
+ },
+ "cloudflare-ai": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.cloudflare.com/client/v4/accounts",
+ "stream": "https://api.cloudflare.com/client/v4/accounts"
+ }
+ },
+ "codebuddy-cn": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1",
+ "X-IDE-Name": "CLI",
+ "X-IDE-Type": "CLI",
+ "X-Product": "SaaS",
+ "x-codebuddy-request": "1",
+ "x-requested-with": "XMLHttpRequest"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1",
+ "X-IDE-Name": "CLI",
+ "X-IDE-Type": "CLI",
+ "X-Product": "SaaS",
+ "x-codebuddy-request": "1",
+ "x-requested-with": "XMLHttpRequest"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1",
+ "X-IDE-Name": "CLI",
+ "X-IDE-Type": "CLI",
+ "X-Product": "SaaS",
+ "x-codebuddy-request": "1",
+ "x-requested-with": "XMLHttpRequest"
+ }
+ },
+ "url": {
+ "nonStream": "https://copilot.tencent.com/v2/chat/completions",
+ "stream": "https://copilot.tencent.com/v2/chat/completions"
+ }
+ },
+ "codestral": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://codestral.mistral.ai/v1/chat/completions",
+ "stream": "https://codestral.mistral.ai/v1/chat/completions"
+ }
+ },
+ "codex": {
+ "format": "openai-responses",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "Openai-Beta": "responses=experimental",
+ "User-Agent": "codex-cli/0.142.0 (Windows 10.0.26200; x64)",
+ "Version": "0.142.0",
+ "X-Codex-Beta-Features": "responses_websockets"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "Openai-Beta": "responses=experimental",
+ "User-Agent": "codex-cli/0.142.0 (Windows 10.0.26200; x64)",
+ "Version": "0.142.0",
+ "X-Codex-Beta-Features": "responses_websockets"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json",
+ "Openai-Beta": "responses=experimental",
+ "User-Agent": "codex-cli/0.142.0 (Windows 10.0.26200; x64)",
+ "Version": "0.142.0",
+ "X-Codex-Beta-Features": "responses_websockets"
+ }
+ },
+ "url": {
+ "nonStream": "https://chatgpt.com/backend-api/codex/responses",
+ "stream": "https://chatgpt.com/backend-api/codex/responses"
+ }
+ },
+ "cohere": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.cohere.com/compatibility/v1/chat/completions",
+ "stream": "https://api.cohere.com/compatibility/v1/chat/completions"
+ }
+ },
+ "command-code": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.commandcode.ai",
+ "stream": "https://api.commandcode.ai"
+ }
+ },
+ "copilot-web": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "wss://copilot.microsoft.com/c/api/chat?api-version=2",
+ "stream": "wss://copilot.microsoft.com/c/api/chat?api-version=2"
+ }
+ },
+ "coze": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "oauth": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ }
+ },
+ "url": {
+ "nonStream": "https://api.coze.com/v1/chat/completions",
+ "stream": "https://api.coze.com/v1/chat/completions"
+ }
+ },
+ "crof": {
+ "format": "openai",
+ "headers": {
+ "apiKey": {
+ "Accept": "text/event-stream",
+ "Authorization": "Bearer ",
+ "Content-Type": "application/json"
+ },
+ "nonStream": {
+ "Authorization": "Bearer