chore: resolve merge conflicts in Dockerfile

This commit is contained in:
diegosouzapw
2026-05-07 08:59:02 -03:00
204 changed files with 3651 additions and 1131 deletions

View File

@@ -42,8 +42,8 @@ function validateKeyName(
if (name.length > MAX_KEY_NAME_LENGTH) {
return { valid: false, error: t("keyNameTooLong", { max: MAX_KEY_NAME_LENGTH }) };
}
// Only allow alphanumeric, spaces, hyphens, underscores
if (!/^[a-zA-Z0-9_\-\s]+$/.test(name)) {
// Allow Unicode letters (accented chars), numbers, spaces, hyphens, underscores
if (!/^[\p{L}\p{N}_\-\s]+$/u.test(name)) {
return {
valid: false,
error: t("keyNameInvalid"),
@@ -184,13 +184,17 @@ export default function ApiManagerPageClient() {
const stats: Record<string, KeyUsageStats> = {};
for (const key of apiKeys) {
// Match analytics entry by key name (reliable across both systems)
const analyticsMatch = byApiKey.find((entry: any) => entry.apiKeyName === key.name);
const analyticsMatch = byApiKey.find(
(entry: any) =>
entry.apiKeyId === key.id || (!entry.apiKeyId && entry.apiKeyName === key.name)
);
// The call-logs endpoint returns entries sorted by timestamp DESC,
// so the first match is the most recent one.
const lastUsed =
(logs || []).find((log: any) => log.apiKeyName === key.name)?.timestamp || null;
(logs || []).find(
(log: any) => log.apiKeyId === key.id || (!log.apiKeyId && log.apiKeyName === key.name)
)?.timestamp || null;
stats[key.id] = {
totalRequests: analyticsMatch?.requests ?? 0,

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect } from "react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { IMAGE_PROVIDERS } from "@omniroute/open-sse/config/imageRegistry.ts";
@@ -53,7 +53,7 @@ const MODALITY_CONFIG: Record<
label: "Video Generation",
placeholder: "A timelapse of a flower blooming...",
color: "from-blue-500 to-cyan-500",
needsCredentials: [],
needsCredentials: ["kie"],
},
music: {
icon: "music_note",
@@ -61,7 +61,7 @@ const MODALITY_CONFIG: Record<
label: "Music Generation",
placeholder: "Upbeat electronic music with synth pads...",
color: "from-orange-500 to-yellow-500",
needsCredentials: [],
needsCredentials: ["kie"],
},
speech: {
icon: "record_voice_over",
@@ -89,6 +89,24 @@ const PROVIDER_MODELS: Record<
> = {
image: IMAGE_PROVIDER_MODELS,
video: [
{
id: "kie",
name: "KIE.AI",
models: [
{ id: "kie/veo/veo-3-1", name: "Veo 3.1" },
{ id: "kie/veo/veo-3-1-fast", name: "Veo 3.1 Fast" },
{ id: "kie/kling/kling-v2-1-master-text-to-video", name: "Kling v2.1 Master T2V" },
{ id: "kie/kling/kling-v2-1-master-image-to-video", name: "Kling v2.1 Master I2V" },
{ id: "kie/kling/v2-5-turbo-text-to-video", name: "Kling v2.5 Turbo T2V" },
{ id: "kie/kling/v2-5-turbo-image-to-video", name: "Kling v2.5 Turbo I2V" },
{ id: "kie/wan/2-7-text-to-video", name: "Wan 2.7 T2V" },
{ id: "kie/wan/2-7-image-to-video", name: "Wan 2.7 I2V" },
{ id: "kie/sora2/sora-2-text-to-video", name: "Sora 2 T2V" },
{ id: "kie/hailuo/02-text-to-video-pro", name: "Hailuo 02 T2V Pro" },
{ id: "kie/grok-imagine/text-to-video", name: "Grok Imagine T2V" },
{ id: "kie/bytedance/v2-0-text-to-video", name: "Seedance v2.0 T2V" },
],
},
{
id: "comfyui",
name: "ComfyUI",
@@ -104,6 +122,14 @@ const PROVIDER_MODELS: Record<
},
],
music: [
{
id: "kie",
name: "KIE.AI",
models: [
{ id: "kie/suno-v3.5", name: "Suno V3.5" },
{ id: "kie/suno-v4.0", name: "Suno V4.0" },
],
},
{
id: "comfyui",
name: "ComfyUI",
@@ -123,6 +149,16 @@ const PROVIDER_MODELS: Record<
{ id: "openai/gpt-4o-mini-tts", name: "GPT-4o Mini TTS" },
],
},
{
id: "kie",
name: "KIE.AI",
models: [
{ id: "kie/elevenlabs/text-to-speech-multilingual-v2", name: "ElevenLabs TTS v2" },
{ id: "kie/elevenlabs/text-to-speech-turbo-2-5", name: "ElevenLabs TTS Turbo 2.5" },
{ id: "kie/elevenlabs/text-to-dialogue-v3", name: "ElevenLabs Text to Dialogue v3" },
{ id: "kie/elevenlabs/sound-effect-v2", name: "ElevenLabs Sound Effect v2" },
],
},
{
id: "elevenlabs",
name: "ElevenLabs",
@@ -204,6 +240,14 @@ const PROVIDER_MODELS: Record<
{ id: "deepgram/base", name: "Base" },
],
},
{
id: "kie",
name: "KIE.AI",
models: [
{ id: "kie/elevenlabs/speech-to-text", name: "ElevenLabs STT" },
{ id: "kie/elevenlabs/audio-isolation", name: "ElevenLabs Audio Isolation" },
],
},
{
id: "assemblyai",
name: "AssemblyAI ($50 free)",
@@ -242,6 +286,8 @@ const PROVIDER_MODELS: Record<
{ id: "qwen", name: "Qwen", models: [{ id: "qwen/qwen3-asr", name: "Qwen3 ASR" }] },
],
};
const INITIAL_IMAGE_PROVIDER = PROVIDER_MODELS.image[0];
const INITIAL_IMAGE_MODEL = INITIAL_IMAGE_PROVIDER?.models[0];
// Voice presets per TTS provider
const VOICE_PRESETS: Record<string, { id: string; label: string }[]> = {
@@ -264,6 +310,13 @@ const VOICE_PRESETS: Record<string, { id: string; label: string }[]> = {
{ id: "pNInz6obpgDQGcFmaJgB", label: "Adam (EN)" },
{ id: "yoZ06aMxZJJ28mfd3POQ", label: "Sam (EN)" },
],
kie: [
{ id: "Rachel", label: "Rachel (EN)" },
{ id: "Adam", label: "Adam (EN)" },
{ id: "Brian", label: "Brian (EN)" },
{ id: "Roger", label: "Roger (EN)" },
{ id: "Bella", label: "Bella (EN)" },
],
cartesia: [
{ id: "a0e99841-438c-4a64-b679-ae501e7d6091", label: "Barbershop Man" },
{ id: "694f9389-aac1-45b6-b726-9d9369183238", label: "Friendly Reading Man" },
@@ -433,8 +486,10 @@ export default function MediaPageClient() {
const [prompt, setPrompt] = useState("");
// Selected provider and model per modality
const [selectedProvider, setSelectedProvider] = useState<string>("");
const [selectedModel, setSelectedModel] = useState<string>("");
const [selectedProvider, setSelectedProvider] = useState<string>(
INITIAL_IMAGE_PROVIDER?.id ?? ""
);
const [selectedModel, setSelectedModel] = useState<string>(INITIAL_IMAGE_MODEL?.id ?? "");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<GenerationResult | null>(null);
@@ -536,16 +591,6 @@ export default function MediaPageClient() {
}
};
// Initialize on mount — pick first provider/model for image tab
const initialized = useRef(false);
if (!initialized.current) {
initialized.current = true;
const providers = PROVIDER_MODELS["image"] ?? [];
const firstProvider = providers[0];
setSelectedProvider(firstProvider?.id ?? "");
setSelectedModel(firstProvider?.models[0]?.id ?? "");
}
const handleGenerate = async () => {
setLoading(true);
setError(null);

View File

@@ -2,9 +2,10 @@
import { useState, useEffect } from "react";
import { Card, Button, Badge, Modal, Input, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
import { useTranslations } from "next-intl";
import ProviderIcon from "@/shared/components/ProviderIcon";
export default function AntigravityToolCard({
tool,
isExpanded,
@@ -226,17 +227,7 @@ export default function AntigravityToolCard({
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
<div className="flex items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image
src={tool.image || "/providers/antigravity.png"}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
<ProviderIcon providerId={tool.id || "antigravity"} size={32} type="color" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import ProviderIcon from "@/shared/components/ProviderIcon";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
import {
@@ -286,17 +286,7 @@ export default function ClaudeToolCard({
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
<div className="flex items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image
src="/providers/claude.png"
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
<ProviderIcon providerId="claude" size={32} type="color" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import ProviderIcon from "@/shared/components/ProviderIcon";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks";
@@ -241,23 +241,7 @@ export default function ClineToolCard({
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
<div className="flex items-center gap-3">
<div className="size-8 rounded-lg flex items-center justify-center shrink-0">
{tool.image ? (
<Image
src={tool.image}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
) : (
<span className="material-symbols-outlined text-xl" style={{ color: tool.color }}>
terminal
</span>
)}
<ProviderIcon providerId={tool.id || "cline"} size={32} type="color" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">

View File

@@ -2,10 +2,11 @@
import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
import ProviderIcon from "@/shared/components/ProviderIcon";
export default function CodexToolCard({
tool,
isExpanded,
@@ -408,17 +409,7 @@ openai_base_url = "${getEffectiveBaseUrl()}"
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
<div className="flex items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image
src="/providers/codex.png"
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
<ProviderIcon providerId="codex" size={32} type="color" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">

View File

@@ -8,6 +8,7 @@ import { copyToClipboard } from "@/shared/utils/clipboard";
import { buildOpenCodeConfigDocument } from "@/shared/services/opencodeConfig";
import { useTheme } from "@/shared/hooks/useTheme";
import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks";
import ProviderIcon from "@/shared/components/ProviderIcon";
export default function DefaultToolCard({
toolId,
@@ -659,19 +660,7 @@ export default function DefaultToolCard({
</span>
);
}
return (
<Image
src={`/providers/${toolId}.png`}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
);
return <ProviderIcon providerId={toolId} size={32} type="color" />;
};
return (

View File

@@ -2,10 +2,11 @@
import { useState, useEffect, useRef } from "react";
import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components";
import Image from "next/image";
import CliStatusBadge from "./CliStatusBadge";
import { useTranslations } from "next-intl";
import ProviderIcon from "@/shared/components/ProviderIcon";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function DroidToolCard({
@@ -276,17 +277,7 @@ export default function DroidToolCard({
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
<div className="flex items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<Image
src="/providers/droid.png"
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
<ProviderIcon providerId="droid" size={32} type="color" />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">

View File

@@ -5,7 +5,6 @@ import { createPortal } from "react-dom";
import { useNotificationStore } from "@/store/notificationStore";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import {
Card,
@@ -49,6 +48,7 @@ import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases";
import { maskEmail, pickMaskedDisplayValue, pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
import ProviderIcon from "@/shared/components/ProviderIcon";
import {
getClaudeCodeCompatibleRequestDefaults as _getClaudeCodeCompatibleRequestDefaults,
getCodexRequestDefaults as _getCodexRequestDefaults,
@@ -980,7 +980,6 @@ export default function ProviderDetailPage() {
const [batchTesting, setBatchTesting] = useState(false);
const [batchTestResults, setBatchTestResults] = useState<any>(null);
const [modelAliases, setModelAliases] = useState({});
const [headerImgError, setHeaderImgError] = useState(false);
const { copied, copy } = useCopyToClipboard();
const t = useTranslations("providers");
const emailsVisible = useEmailPrivacyStore((s) => s.emailsVisible);
@@ -2666,17 +2665,15 @@ export default function ProviderDetailPage() {
);
}
// Determine icon path: OpenAI Compatible providers use specialized icons
const getHeaderIconPath = () => {
// OpenAI/Anthropic compatible providers use their specialized pseudo-provider icons.
const getHeaderIconProviderId = () => {
if (isOpenAICompatible && providerInfo.apiType) {
return providerInfo.apiType === "responses"
? "/providers/oai-r.png"
: "/providers/oai-cc.png";
return providerInfo.apiType === "responses" ? "oai-r" : "oai-cc";
}
if (isAnthropicProtocolCompatible) {
return "/providers/anthropic-m.png";
return "anthropic-m";
}
return `/providers/${providerInfo.id}.png`;
return providerInfo.id;
};
return (
@@ -2695,21 +2692,7 @@ export default function ProviderDetailPage() {
className="rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${providerInfo.color}15` }}
>
{headerImgError ? (
<span className="text-sm font-bold" style={{ color: providerInfo.color }}>
{providerInfo.textIcon || providerInfo.id.slice(0, 2).toUpperCase()}
</span>
) : (
<Image
src={getHeaderIconPath()}
alt={providerInfo.name}
width={48}
height={48}
className="object-contain rounded-lg max-w-[48px] max-h-[48px]"
sizes="48px"
onError={() => setHeaderImgError(true)}
/>
)}
<ProviderIcon providerId={getHeaderIconProviderId()} size={48} type="color" />
</div>
<div>
{providerInfo.website ? (
@@ -6012,6 +5995,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
codexOpenaiStoreEnabled: false,
consoleApiKey: "",
ccCompatibleContext1m: false,
geminiProjectId: "",
blockExtraUsage:
connection?.provider === "claude"
? isClaudeExtraUsageBlockEnabled(connection?.provider, connection?.providerSpecificData)
@@ -6037,6 +6021,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
const isCloudflare = connection?.provider === "cloudflare-ai";
const isCodex = connection?.provider === "codex";
const isClaude = connection?.provider === "claude";
const isGeminiCli = connection?.provider === "gemini-cli";
const localProviderMetadata = getLocalProviderMetadata(connection?.provider);
const isLocalSelfHostedProvider = !!localProviderMetadata;
const isSearxng = connection?.provider === "searxng-search";
@@ -6099,6 +6084,7 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
consoleApiKey: existingConsoleApiKey,
ccCompatibleContext1m: ccRequestDefaults.context1m,
geminiProjectId: (connection.providerSpecificData?.projectId as string) || "",
blockExtraUsage: isClaudeExtraUsageBlockEnabled(
connection.provider,
connection.providerSpecificData
@@ -6205,6 +6191,10 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
healthCheckInterval: formData.healthCheckInterval,
};
if (isGeminiCli) {
updates.projectId = formData.geminiProjectId.trim() || null;
}
if (isGooglePse && !formData.cx.trim()) {
setSaveError(t("searchEngineIdRequired"));
return;
@@ -6326,6 +6316,9 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
updates.providerSpecificData.openaiStoreEnabled =
formData.codexOpenaiStoreEnabled === true;
}
if (isGeminiCli) {
updates.providerSpecificData.projectId = formData.geminiProjectId.trim() || undefined;
}
}
const error = (await onSave(updates)) as void | unknown;
if (error) {
@@ -6420,6 +6413,18 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec
/>
</div>
)}
{isGeminiCli && (
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
<Input
label={t("geminiCliProjectIdLabel")}
value={formData.geminiProjectId}
onChange={(e) => setFormData({ ...formData, geminiProjectId: e.target.value })}
placeholder={t("geminiCliProjectIdPlaceholder")}
hint={t("geminiCliProjectIdHint")}
className="font-mono text-xs"
/>
</div>
)}
{isOAuth && connection.email && (
<div className="bg-sidebar/50 p-3 rounded-lg">
<p className="text-sm text-text-muted mb-1">{t("email")}</p>

View File

@@ -3,7 +3,6 @@
import { useTranslations } from "next-intl";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import Image from "next/image";
import {
parseQuotaData,
calculatePercentage,
@@ -18,6 +17,7 @@ import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
import { pickMaskedDisplayValue, pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
import ProviderIcon from "@/shared/components/ProviderIcon";
const LS_GROUP_BY = "omniroute:limits:groupBy";
const LS_EXPANDED_GROUPS = "omniroute:limits:expandedGroups";
@@ -551,14 +551,7 @@ export default function ProviderLimits() {
{/* Account Info */}
<div className="flex items-center gap-2.5 min-w-0">
<div className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden shrink-0">
<Image
src={`/providers/${conn.provider}.png`}
alt={conn.provider}
width={32}
height={32}
className="object-contain"
sizes="32px"
/>
<ProviderIcon providerId={conn.provider} size={32} type="color" />
</div>
<div className="min-w-0">
<div className="text-[13px] font-semibold text-text-main truncate">

View File

@@ -126,6 +126,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
healthCheckInterval,
group,
maxConcurrent,
projectId,
providerSpecificData: incomingPsd,
} = body;
@@ -152,6 +153,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
if (healthCheckInterval !== undefined) updateData.healthCheckInterval = healthCheckInterval;
if (group !== undefined) updateData.group = group;
if (maxConcurrent !== undefined) updateData.maxConcurrent = maxConcurrent;
if (projectId !== undefined) updateData.projectId = projectId;
// Merge providerSpecificData (partial update — preserve existing keys not sent by caller)
if (incomingPsd !== undefined && incomingPsd !== null && typeof incomingPsd === "object") {

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { getApiKeys } from "@/lib/db/apiKeys";
import { getDbInstance } from "@/lib/db/core";
function getRangeStartIso(range: string): string | null {
@@ -76,6 +77,16 @@ function uniqueValues(values: Array<string | null | undefined>): string[] {
return result;
}
function makeApiKeyUsageGroup(apiKeyId: string, fallbackName: string): string {
return apiKeyId ? `id:${apiKeyId}` : `name:${fallbackName}`;
}
function addApiKeyAlias(target: Set<string>, value: unknown): void {
if (typeof value !== "string") return;
const trimmed = value.trim();
if (trimmed) target.add(trimmed);
}
function stripCodexEffortSuffix(model: string): string {
return model.replace(/-(?:xhigh|high|medium|low|none)$/i, "");
}
@@ -244,6 +255,13 @@ export async function GET(request: Request) {
const presetsParam = searchParams.get("presets");
const db = getDbInstance();
const apiKeys = await getApiKeys();
const currentApiKeyNames = new Map<string, string>();
for (const apiKey of apiKeys) {
if (typeof apiKey.id === "string" && typeof apiKey.name === "string") {
currentApiKeyNames.set(apiKey.id, apiKey.name);
}
}
const conditions = [];
const params: Record<string, string> = {};
@@ -296,7 +314,7 @@ export async function GET(request: Request) {
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens,
COUNT(DISTINCT model) as uniqueModels,
COUNT(DISTINCT connection_id) as uniqueAccounts,
COUNT(DISTINCT api_key_id) as uniqueApiKeys,
COUNT(DISTINCT COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''))) as uniqueApiKeys,
COALESCE(SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END), 0) as successfulRequests,
COALESCE(AVG(latency_ms), 0) as avgLatencyMs,
COALESCE(MIN(timestamp), '') as firstRequest,
@@ -489,8 +507,8 @@ export async function GET(request: Request) {
.prepare(
`
SELECT
api_key_id as apiKeyId,
COALESCE(NULLIF(api_key_name, ''), NULLIF(api_key_id, ''), 'Unknown API key') as apiKeyName,
NULLIF(api_key_id, '') as apiKeyId,
COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''), 'unknown') as apiKeyGroupKey,
LOWER(provider) as provider,
LOWER(model) as model,
COUNT(*) as requests,
@@ -502,11 +520,42 @@ export async function GET(request: Request) {
COALESCE(SUM(tokens_input + tokens_output), 0) as totalTokens
FROM usage_history
${apiKeyWhereClause}
GROUP BY api_key_id, api_key_name, LOWER(provider), LOWER(model)
GROUP BY COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''), 'unknown'), NULLIF(api_key_id, ''), LOWER(provider), LOWER(model)
`
)
.all(params) as Array<Record<string, unknown>>;
const apiKeyMetadataRows = db
.prepare(
`
SELECT
NULLIF(api_key_id, '') as apiKeyId,
NULLIF(api_key_name, '') as apiKeyName,
COALESCE(NULLIF(api_key_id, ''), NULLIF(api_key_name, ''), 'unknown') as apiKeyGroupKey,
MAX(timestamp) as lastUsed
FROM usage_history
${apiKeyWhereClause}
GROUP BY NULLIF(api_key_id, ''), NULLIF(api_key_name, '')
ORDER BY lastUsed DESC
`
)
.all(params) as Array<Record<string, unknown>>;
const apiKeyMetadata = new Map<string, { latestName: string; aliases: Set<string> }>();
for (const row of apiKeyMetadataRows) {
const apiKeyId = toStringValue(row.apiKeyId);
const apiKeyGroupKey = toStringValue(row.apiKeyGroupKey, "unknown");
const groupKey = makeApiKeyUsageGroup(apiKeyId, apiKeyGroupKey);
const existing = apiKeyMetadata.get(groupKey) || {
latestName: "",
aliases: new Set<string>(),
};
const apiKeyName = toStringValue(row.apiKeyName);
if (!existing.latestName && apiKeyName) existing.latestName = apiKeyName;
addApiKeyAlias(existing.aliases, apiKeyName);
apiKeyMetadata.set(groupKey, existing);
}
const weeklyRows = db
.prepare(
`
@@ -742,6 +791,7 @@ export async function GET(request: Request) {
apiKey: string;
apiKeyId: string | null;
apiKeyName: string;
historicalApiKeyNames: string[];
requests: number;
promptTokens: number;
completionTokens: number;
@@ -751,12 +801,20 @@ export async function GET(request: Request) {
>();
for (const row of apiKeyRows) {
const apiKeyId = toStringValue(row.apiKeyId);
const apiKeyName = toStringValue(row.apiKeyName, apiKeyId || "Unknown API key");
const key = `${apiKeyId || "unknown"}::${apiKeyName}`;
const apiKeyGroupKey = toStringValue(row.apiKeyGroupKey, "unknown");
const key = makeApiKeyUsageGroup(apiKeyId, apiKeyGroupKey);
const metadata = apiKeyMetadata.get(key);
const apiKeyName =
(apiKeyId ? currentApiKeyNames.get(apiKeyId) : undefined) ||
metadata?.latestName ||
apiKeyId ||
apiKeyGroupKey ||
"Unknown API key";
const existing = apiKeyMap.get(key) || {
apiKey: apiKeyId && apiKeyName !== apiKeyId ? `${apiKeyName} (${apiKeyId})` : apiKeyName,
apiKeyId: apiKeyId || null,
apiKeyName,
historicalApiKeyNames: Array.from(metadata?.aliases || []),
requests: 0,
promptTokens: 0,
completionTokens: 0,

View File

@@ -16,6 +16,7 @@ import { getAllModerationModels } from "@omniroute/open-sse/config/moderationReg
import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry.ts";
import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry.ts";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { CODEX_NATIVE_UNPREFIXED_MODELS } from "@omniroute/open-sse/services/model.ts";
import { getAllSyncedAvailableModels } from "@/lib/db/models";
import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels";
import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules";
@@ -25,6 +26,8 @@ import {
getCatalogDiagnosticsHeaders,
} from "@/lib/modelMetadataRegistry";
import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
import { parseModel } from "@omniroute/open-sse/services/model.ts";
import { getTokenLimit } from "@omniroute/open-sse/services/contextManager.ts";
const FALLBACK_ALIAS_TO_PROVIDER = {
ag: "antigravity",
@@ -313,6 +316,30 @@ export async function getUnifiedModelsResponse(
// Add combos first (they appear at the top) — only active ones
for (const combo of combos) {
if (combo.isActive === false || combo.isHidden === true) continue;
// Calculate combo context length from its model targets.
// OpenCode and other clients read context_length from the catalog; without it
// they fall back to a conservative ~4000 token limit, causing truncation.
const comboContextLength = Array.isArray(combo.models)
? combo.models
.filter((step) => step && step.kind === "model" && step.model)
.map((step) => {
const parsed = parseModel(step.model);
const provider = parsed.provider || (step as any).providerId || "unknown";
const model = parsed.model || step.model;
return getTokenLimit(provider, model);
})
.filter((limit): limit is number => typeof limit === "number" && limit > 0)
.reduce((min, limit) => Math.min(min, limit), Infinity)
: undefined;
const effectiveContextLength =
typeof combo.context_length === "number" && combo.context_length > 0
? combo.context_length
: comboContextLength !== undefined && comboContextLength !== Infinity
? comboContextLength
: undefined;
models.push({
id: combo.name,
object: "model",
@@ -321,7 +348,7 @@ export async function getUnifiedModelsResponse(
permission: [],
root: combo.name,
parent: null,
...(combo.context_length ? { context_length: combo.context_length } : {}),
...(effectiveContextLength !== undefined ? { context_length: effectiveContextLength } : {}),
});
}
@@ -377,6 +404,33 @@ export async function getUnifiedModelsResponse(
}
}
for (const modelId of CODEX_NATIVE_UNPREFIXED_MODELS) {
if (!providerSupportsModel("codex", modelId)) continue;
if (getModelIsHidden("codex", modelId)) continue;
const alias = providerIdToAlias.codex || "cx";
const aliasId = `${alias}/${modelId}`;
const providerIdModel = `codex/${modelId}`;
const entries = [
{ id: aliasId, parent: null },
{ id: providerIdModel, parent: aliasId },
{ id: modelId, parent: providerIdModel },
];
for (const entry of entries) {
if (models.some((existingModel) => existingModel.id === entry.id)) continue;
models.push({
id: entry.id,
object: "model",
created: timestamp,
owned_by: "codex",
permission: [],
root: modelId,
parent: entry.parent,
});
}
}
try {
const syncedModelsByProvider = await getAllSyncedAvailableModels();
for (const [providerId, syncedModels] of Object.entries(syncedModelsByProvider)) {

View File

@@ -12,7 +12,7 @@ export function DocsSidebarClient({ mobileOnly = false }: { mobileOnly?: boolean
const [isOpen, setIsOpen] = useState(false);
// Extract slug from pathname (e.g., /docs/setup-guide -> setup-guide)
const currentSlug = pathname.split("/").filter(Boolean).pop() || "";
const currentSlug = pathname.split("/").filter(Boolean).pop();
const isActive = (slug: string) => currentSlug === slug;

View File

@@ -1,17 +1,18 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { useTranslations } from "next-intl";
import ProviderIcon from "@/shared/components/ProviderIcon";
export default function FlowAnimation() {
const t = useTranslations("landing");
const [activeFlow, setActiveFlow] = useState(0);
const cliTools = [
{ id: "claude", name: t("flowToolClaudeCode"), image: "/providers/claude.png" },
{ id: "codex", name: t("flowToolOpenAICodex"), image: "/providers/codex.png" },
{ id: "cline", name: t("flowToolCline"), image: "/providers/cline.png" },
{ id: "cursor", name: t("flowToolCursor"), image: "/providers/cursor.png" },
{ id: "claude", name: t("flowToolClaudeCode") },
{ id: "codex", name: t("flowToolOpenAICodex") },
{ id: "cline", name: t("flowToolCline") },
{ id: "cursor", name: t("flowToolCursor") },
];
const providers = [
@@ -70,14 +71,7 @@ export default function FlowAnimation() {
className="flex items-center gap-3 opacity-70 hover:opacity-100 transition-opacity group"
>
<div className="w-16 h-16 rounded-2xl bg-[#111520] border border-[#2D333B] flex items-center justify-center overflow-hidden p-2 hover:border-[#E54D5E]/50 transition-all hover:scale-105">
<Image
src={tool.image}
alt={tool.name}
width={48}
height={48}
className="object-contain rounded-xl max-w-[48px] max-h-[48px]"
sizes="48px"
/>
<ProviderIcon providerId={tool.id} size={48} type="color" />
</div>
</div>
))}

View File

@@ -977,7 +977,7 @@
"tipSeparate": "Create separate keys for different clients or environments",
"tipRestrict": "Restrict keys to specific models for better security and cost control",
"keyName": "Key Name",
"keyNamePlaceholder": "e.g., Production Key, Development Key",
"keyNamePlaceholder": "e.g. Production Key",
"keyNameDesc": "Choose a descriptive name to identify this key's purpose",
"keyCreated": "API Key Created",
"keyCreatedSuccess": "Key created successfully!",
@@ -3011,6 +3011,9 @@
"extraApiKeysHint": "Extra Api Keys Hint",
"extraApiKeysLabel": "Extra Api Keys Label",
"googlePseInfo": "Google Pse Info",
"geminiCliProjectIdHint": "Your Google Cloud Project ID. Required for accounts with exceptions. Enter your GCP Project ID to use with Gemini CLI.",
"geminiCliProjectIdLabel": "Google Cloud Project ID",
"geminiCliProjectIdPlaceholder": "my-gcp-project-id",
"grokWebCookieHint": "Grok Web Cookie Hint",
"grokWebCookiePlaceholder": "Grok Web Cookie Placeholder",
"herokuBaseUrlHint": "Heroku Base Url Hint",

View File

@@ -875,7 +875,7 @@
"tipSeparate": "Crie chaves separadas para diferentes clientes ou ambientes",
"tipRestrict": "Restrinja chaves a modelos específicos para maior segurança e controle de custos",
"keyName": "Nome da Chave",
"keyNamePlaceholder": "ex: Chave de Produção, Chave de Desenvolvimento",
"keyNamePlaceholder": "ex: Chave de Produção",
"keyNameDesc": "Escolha um nome descritivo para identificar o propósito desta chave",
"keyCreated": "Chave de API Criada",
"keyCreatedSuccess": "Chave criada com sucesso!",

View File

@@ -1,4 +1,80 @@
import crypto from "node:crypto";
import { CLAUDE_CONFIG } from "../constants/oauth";
import { CLAUDE_CODE_VERSION } from "@omniroute/open-sse/executors/claudeIdentity.ts";
const BOOTSTRAP_FETCH_TIMEOUT_MS = 10_000;
// Best-effort: failure must not block OAuth — the access token is valid.
async function fetchClaudeBootstrap(accessToken) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), BOOTSTRAP_FETCH_TIMEOUT_MS);
try {
const res = await fetch("https://api.anthropic.com/api/claude_cli/bootstrap", {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/json",
"User-Agent": `claude-cli/${CLAUDE_CODE_VERSION} (external, cli)`,
"anthropic-beta": "oauth-2025-04-20",
},
signal: ctrl.signal,
});
if (!res.ok) return null;
const data = await res.json();
const acct = data?.oauth_account;
if (!acct || typeof acct !== "object") return null;
return {
account_uuid: acct.account_uuid || null,
account_email: acct.account_email || null,
organization_uuid: acct.organization_uuid || null,
organization_name: acct.organization_name || null,
organization_type: acct.organization_type || null,
organization_rate_limit_tier: acct.organization_rate_limit_tier || null,
};
} catch {
return null;
} finally {
clearTimeout(timer);
}
}
function toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function firstNonEmptyString(...values: unknown[]): string | undefined {
for (const value of values) {
if (typeof value !== "string") {
continue;
}
const trimmed = value.trim();
if (trimmed) {
return trimmed;
}
}
return undefined;
}
function extractPlanFromPayload(payload: unknown): string | undefined {
const data = toRecord(payload);
const billing = toRecord(data.billing);
return firstNonEmptyString(data.account_tier, data.plan, data.subscription_type, billing.plan);
}
function extractClaudePlan(tokens: unknown, extra: unknown): string | undefined {
const extraData = toRecord(extra);
return firstNonEmptyString(
extractPlanFromPayload(tokens),
extractPlanFromPayload(extraData.userInfo),
extractPlanFromPayload(extra)
);
}
export const claude = {
config: CLAUDE_CONFIG,
@@ -48,10 +124,39 @@ export const claude = {
return await response.json();
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
}),
// Runs after exchangeToken; result is passed as `extra` to mapTokens.
postExchange: async (tokens) => {
if (!tokens?.access_token) return null;
return await fetchClaudeBootstrap(tokens.access_token);
},
mapTokens: (tokens, extra) => {
const plan = extractClaudePlan(tokens, extra);
const bs = extra || {};
const providerSpecificData: any = {
// Generated once at provisioning; preserved across token refresh.
cliUserID: crypto.randomBytes(32).toString("hex"),
};
if (bs.account_uuid) providerSpecificData.accountUUID = bs.account_uuid;
if (bs.organization_uuid) providerSpecificData.organizationUUID = bs.organization_uuid;
if (bs.organization_name) providerSpecificData.organizationName = bs.organization_name;
if (bs.organization_type) providerSpecificData.organizationType = bs.organization_type;
if (bs.organization_rate_limit_tier)
providerSpecificData.organizationRateLimitTier = bs.organization_rate_limit_tier;
if (plan) providerSpecificData.plan = plan;
const result: any = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
};
if (bs.account_email) {
result.email = bs.account_email;
result.displayName = bs.account_email;
}
if (Object.keys(providerSpecificData).length > 0) {
result.providerSpecificData = providerSpecificData;
}
return result;
},
};

View File

@@ -11,6 +11,7 @@ import {
stripClaudeCodeCompatibleEndpointSuffix,
stripAnthropicMessagesSuffix,
} from "@omniroute/open-sse/services/claudeCodeCompatible.ts";
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
import {
isClaudeCodeCompatibleProvider,
isAnthropicCompatibleProvider,
@@ -544,6 +545,13 @@ async function validateAnthropicLikeProvider({
return { valid: false, error: "Missing base URL" };
}
// OAuth tokens need the same Claude Code cloak as production traffic in
// base.ts; a bare validation request gets flagged on the user:sessions:
// claude_code scope.
if (typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat")) {
return validateClaudeOAuthInline({ apiKey, modelId, providerSpecificData });
}
const requestHeaders = applyCustomUserAgent(
{
"Content-Type": "application/json",
@@ -580,6 +588,45 @@ async function validateAnthropicLikeProvider({
return { valid: true, error: null };
}
// Probe a Claude OAuth credential through the same executor that handles
// production traffic so the cloak/signing/identity logic isn't duplicated.
async function validateClaudeOAuthInline({
apiKey,
modelId,
providerSpecificData = {},
}: {
apiKey: string;
modelId: string | null | undefined;
providerSpecificData?: Record<string, unknown>;
}) {
const testModelId =
providerSpecificData?.validationModelId || modelId || "claude-haiku-4-5-20251001";
try {
const { response } = await getExecutor("claude").execute({
model: testModelId,
body: {
model: testModelId,
max_tokens: 1,
messages: [{ role: "user", content: "test" }],
},
stream: false,
credentials: { accessToken: apiKey, providerSpecificData },
});
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid OAuth token" };
}
if (response.status >= 500) {
return { valid: false, error: `Provider unavailable (${response.status})` };
}
// 2xx and non-auth 4xx (429 quota, 400 model) both mean the token is valid.
return { valid: true, error: null };
} catch (error: any) {
return toValidationErrorResult(error);
}
}
async function validateGeminiLikeProvider({
apiKey,
baseUrl,
@@ -755,6 +802,55 @@ async function validateInworldProvider({ apiKey, providerSpecificData = {} }: an
}
}
async function validateKieProvider({ apiKey, providerSpecificData = {} }: any) {
try {
// Use credit check endpoint as requested by user based on Kie.ai docs.
const response = await validationRead("https://api.kie.ai/api/v1/chat/credit", {
method: "GET",
headers: applyCustomUserAgent(
{
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
providerSpecificData
),
});
if (response.ok) {
return { valid: true, error: null };
}
if (response.status === 401 || response.status === 403) {
return { valid: false, error: "Invalid Kie.ai API key" };
}
// Fallback: if credits endpoint is 404/not supported, try minimal chat probe.
const chatRes = await validationWrite("https://api.kie.ai/api/v1/chat/completions", {
method: "POST",
headers: buildBearerHeaders(apiKey, providerSpecificData),
body: JSON.stringify({
model: providerSpecificData.validationModelId || "gpt-4o-mini",
messages: [{ role: "user", content: "test" }],
max_tokens: 1,
}),
});
if (
chatRes.ok ||
(chatRes.status >= 400 &&
chatRes.status < 500 &&
chatRes.status !== 401 &&
chatRes.status !== 403)
) {
return { valid: true, error: null };
}
return { valid: false, error: `Validation failed: ${chatRes.status}` };
} catch (error: unknown) {
return toValidationErrorResult(error);
}
}
function getAwsProviderString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
@@ -2920,6 +3016,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi
validateImageProviderApiKey({ provider: "topaz", apiKey, providerSpecificData }),
elevenlabs: validateElevenLabsProvider,
inworld: validateInworldProvider,
kie: validateKieProvider,
"aws-polly": validateAwsPollyProvider,
"bailian-coding-plan": validateBailianCodingPlanProvider,
heroku: validateHerokuProvider,

View File

@@ -8,6 +8,7 @@
*/
import { getDbInstance } from "../db/core";
import { getApiKeys } from "../db/apiKeys";
import { getPendingRequests } from "./usageHistory";
import { getAccountDisplayName } from "@/lib/display/names";
import { calculateCost } from "./costCalculator";
@@ -29,6 +30,7 @@ type UsageBreakdown = UsageBucket & {
accountName?: string;
apiKeyId?: string | null;
apiKeyName?: string;
historicalApiKeyNames?: string[];
};
type ActiveRequest = {
@@ -55,6 +57,10 @@ function toStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value : "";
}
function getApiKeyStatsKey(apiKeyId: string | null, apiKeyName: string | null): string {
return apiKeyId ? `id:${apiKeyId}` : `name:${apiKeyName || "unknown"}`;
}
/**
* Get aggregated usage stats.
* Uses UNION of recent raw data and older aggregated data when aggregation is enabled.
@@ -126,6 +132,18 @@ export async function getUsageStats() {
toStringOrEmpty(conn.name) || toStringOrEmpty(conn.email) || connectionId;
}
const currentApiKeyNames = new Map<string, string>();
try {
const apiKeys = await getApiKeys();
for (const apiKey of apiKeys) {
if (typeof apiKey.id === "string" && typeof apiKey.name === "string") {
currentApiKeyNames.set(apiKey.id, apiKey.name);
}
}
} catch {
// Stats can still be computed from usage_history when api_keys is unavailable.
}
const pendingRequests = getPendingRequests();
const stats: {
@@ -286,26 +304,35 @@ export async function getUsageStats() {
// By API key
if (apiKeyId || apiKeyName) {
const keyName = apiKeyName || apiKeyId || "unknown";
const keyId = apiKeyId || null;
const apiKey = keyId ? `${keyName} (${keyId})` : keyName;
if (!stats.byApiKey[apiKey]) {
stats.byApiKey[apiKey] = {
const key = getApiKeyStatsKey(apiKeyId, apiKeyName);
const displayName =
(apiKeyId ? currentApiKeyNames.get(apiKeyId) : undefined) ||
apiKeyName ||
apiKeyId ||
"unknown";
if (!stats.byApiKey[key]) {
stats.byApiKey[key] = {
requests: 0,
promptTokens: 0,
completionTokens: 0,
cost: 0,
apiKeyId: keyId,
apiKeyName: keyName,
apiKeyId,
apiKeyName: displayName,
historicalApiKeyNames: [],
lastUsed: timestamp,
};
}
stats.byApiKey[apiKey].requests++;
stats.byApiKey[apiKey].promptTokens += promptTokens;
stats.byApiKey[apiKey].completionTokens += completionTokens;
stats.byApiKey[apiKey].cost += entryCost;
if (new Date(timestamp) > new Date(stats.byApiKey[apiKey].lastUsed || timestamp)) {
stats.byApiKey[apiKey].lastUsed = timestamp;
const apiKeyStats = stats.byApiKey[key];
if (apiKeyName && !apiKeyStats.historicalApiKeyNames?.includes(apiKeyName)) {
apiKeyStats.historicalApiKeyNames?.push(apiKeyName);
}
apiKeyStats.apiKeyName = displayName;
apiKeyStats.requests++;
apiKeyStats.promptTokens += promptTokens;
apiKeyStats.completionTokens += completionTokens;
apiKeyStats.cost += entryCost;
if (new Date(timestamp) > new Date(apiKeyStats.lastUsed || timestamp)) {
apiKeyStats.lastUsed = timestamp;
}
}
}

View File

@@ -76,6 +76,13 @@ function shortModelName(model: string) {
return parts[parts.length - 1] || model;
}
function getApiKeyAnalyticsKey(
apiKeyId: string | null | undefined,
apiKeyName: string | null | undefined
) {
return apiKeyId ? `id:${apiKeyId}` : `name:${apiKeyName || "unknown"}`;
}
/**
* Compute all analytics data from usage history
* @param {Array} history - Array of usage entries
@@ -184,7 +191,7 @@ export async function computeAnalytics(
if (entry.model) summary.uniqueModels.add(modelShort);
if (entry.connectionId) summary.uniqueAccounts.add(entry.connectionId);
if (entry.apiKeyId || entry.apiKeyName) {
summary.uniqueApiKeys.add(entry.apiKeyId || entry.apiKeyName);
summary.uniqueApiKeys.add(getApiKeyAnalyticsKey(entry.apiKeyId, entry.apiKeyName));
}
// Daily trend
@@ -260,12 +267,14 @@ export async function computeAnalytics(
// By API key
if (entry.apiKeyId || entry.apiKeyName) {
const keyName = entry.apiKeyName || entry.apiKeyId || "unknown";
const key = getApiKeyAnalyticsKey(entry.apiKeyId, entry.apiKeyName);
const keyLabel = entry.apiKeyId ? `${keyName} (${entry.apiKeyId})` : keyName;
if (!byApiKeyMap[keyLabel]) {
byApiKeyMap[keyLabel] = {
if (!byApiKeyMap[key]) {
byApiKeyMap[key] = {
apiKey: keyLabel,
apiKeyId: entry.apiKeyId || null,
apiKeyName: keyName,
historicalApiKeyNames: [],
requests: 0,
promptTokens: 0,
completionTokens: 0,
@@ -273,11 +282,14 @@ export async function computeAnalytics(
cost: 0,
};
}
byApiKeyMap[keyLabel].requests++;
byApiKeyMap[keyLabel].promptTokens += pt;
byApiKeyMap[keyLabel].completionTokens += ct;
byApiKeyMap[keyLabel].totalTokens += totalTkns;
byApiKeyMap[keyLabel].cost += cost;
if (entry.apiKeyName && !byApiKeyMap[key].historicalApiKeyNames.includes(entry.apiKeyName)) {
byApiKeyMap[key].historicalApiKeyNames.push(entry.apiKeyName);
}
byApiKeyMap[key].requests++;
byApiKeyMap[key].promptTokens += pt;
byApiKeyMap[key].completionTokens += ct;
byApiKeyMap[key].totalTokens += totalTkns;
byApiKeyMap[key].cost += cost;
}
}

View File

@@ -38,76 +38,26 @@ function GenericProviderIcon({ size }: { size: number }) {
const KNOWN_PNGS = new Set([
"aimlapi",
"alibaba",
"alicode-intl",
"alicode",
"anthropic-m",
"anthropic",
"antigravity",
"bailian-coding-plan",
"blackbox",
"brave-search",
"brave",
"cerebras",
"claude",
"cline",
"codex",
"cohere",
"continue",
"copilot",
"cursor",
"deepgram",
"deepseek",
"droid",
"exa-search",
"fireworks",
"gemini-cli",
"gemini",
"github",
"glm",
"glmt",
"groq",
"ironclaw",
"kilo-gateway",
"kilocode",
"kimi-coding-apikey",
"kimi-coding",
"kimi",
"kiro",
"longcat",
"minimax-cn",
"minimax",
"mistral",
"kie",
"nanobot",
"nebius",
"nvidia",
"oai-cc",
"oai-r",
"ollama-cloud",
"openai",
"openclaw",
"openrouter",
"perplexity-search",
"perplexity",
"pollinations",
"qwen",
"roo",
"serper-search",
"serper",
"siliconflow",
"tavily-search",
"tavily",
"together",
"xai",
"zeroclaw",
"aws-polly",
"blackbox-web",
"cliproxyapi",
"databricks",
"empower",
"gigachat",
"gitlab-duo",
"gitlab",
"heroku",
"linkup-search",
"llamagate",
@@ -118,45 +68,32 @@ const KNOWN_PNGS = new Set([
"oci",
"ovhcloud",
"piapi",
"poe",
"predibase",
"qoder",
"recraft",
"reka",
"runwayml",
"triton",
"venice",
"voyage-ai",
"wandb",
"youcom-search",
]);
const KNOWN_SVGS = new Set([
"apikey",
"assemblyai",
"brave",
"brave-search",
"cartesia",
"cloudflare-ai",
"comfyui",
"elevenlabs",
"exa-search",
"exa",
"huggingface",
"hyperbolic",
"droid",
"gemini-cli",
"gitlab",
"gitlab-duo",
"inworld",
"nanobanana",
"kiro",
"kilo-gateway",
"kilocode",
"oauth",
"opencode-go",
"opencode-zen",
"opencode",
"playht",
"puter",
"qianfan",
"scaleway",
"sdwebui",
"synthetic",
"vertex",
"windsurf",
"zai",
]);
const ProviderIcon = memo(function ProviderIcon({

View File

@@ -61,16 +61,15 @@ export default function UsageAnalytics() {
setError(null);
// Update available keys from unfiltered data (only when no filter is active).
// Use apiKeyName as the stable identifier — it is always populated
// for every OmniRoute API key regardless of the downstream provider.
if (selectedApiKeys.length === 0 && data.byApiKey?.length > 0) {
const seen = new Set<string>();
const keys: { id: string; name: string }[] = [];
for (const k of data.byApiKey) {
const id = k.apiKeyId || k.apiKeyName || "unknown";
const name = k.apiKeyName || k.apiKeyId || "unknown";
if (seen.has(name)) continue;
seen.add(name);
keys.push({ id: name, name });
if (seen.has(id)) continue;
seen.add(id);
keys.push({ id, name });
}
setAvailableApiKeys(keys);
}

View File

@@ -75,6 +75,8 @@ import KimiColorIcon from "@lobehub/icons/es/Kimi/components/Color";
import KimiMonoIcon from "@lobehub/icons/es/Kimi/components/Mono";
import LambdaMonoIcon from "@lobehub/icons/es/Lambda/components/Mono";
import LmStudioMonoIcon from "@lobehub/icons/es/LmStudio/components/Mono";
import LongCatColorIcon from "@lobehub/icons/es/LongCat/components/Color";
import LongCatMonoIcon from "@lobehub/icons/es/LongCat/components/Mono";
import MetaColorIcon from "@lobehub/icons/es/Meta/components/Color";
import MetaMonoIcon from "@lobehub/icons/es/Meta/components/Mono";
import MetaAIColorIcon from "@lobehub/icons/es/MetaAI/components/Color";
@@ -104,12 +106,14 @@ import PerplexityColorIcon from "@lobehub/icons/es/Perplexity/components/Color";
import PerplexityMonoIcon from "@lobehub/icons/es/Perplexity/components/Mono";
import PoeColorIcon from "@lobehub/icons/es/Poe/components/Color";
import PoeMonoIcon from "@lobehub/icons/es/Poe/components/Mono";
import PollinationsMonoIcon from "@lobehub/icons/es/Pollinations/components/Mono";
import QoderColorIcon from "@lobehub/icons/es/Qoder/components/Color";
import QoderMonoIcon from "@lobehub/icons/es/Qoder/components/Mono";
import QwenColorIcon from "@lobehub/icons/es/Qwen/components/Color";
import QwenMonoIcon from "@lobehub/icons/es/Qwen/components/Mono";
import RecraftMonoIcon from "@lobehub/icons/es/Recraft/components/Mono";
import ReplicateMonoIcon from "@lobehub/icons/es/Replicate/components/Mono";
import RooCodeMonoIcon from "@lobehub/icons/es/RooCode/components/Mono";
import RunwayMonoIcon from "@lobehub/icons/es/Runway/components/Mono";
import SambaNovaColorIcon from "@lobehub/icons/es/SambaNova/components/Color";
import SambaNovaMonoIcon from "@lobehub/icons/es/SambaNova/components/Mono";
@@ -139,6 +143,7 @@ import VolcengineColorIcon from "@lobehub/icons/es/Volcengine/components/Color";
import VolcengineMonoIcon from "@lobehub/icons/es/Volcengine/components/Mono";
import VoyageColorIcon from "@lobehub/icons/es/Voyage/components/Color";
import VoyageMonoIcon from "@lobehub/icons/es/Voyage/components/Mono";
import WindsurfMonoIcon from "@lobehub/icons/es/Windsurf/components/Mono";
import WorkersAIColorIcon from "@lobehub/icons/es/WorkersAI/components/Color";
import WorkersAIMonoIcon from "@lobehub/icons/es/WorkersAI/components/Mono";
import XAIMonoIcon from "@lobehub/icons/es/XAI/components/Mono";
@@ -208,6 +213,7 @@ const LOBE_ICON_COMPONENTS = {
Kimi: { mono: KimiMonoIcon, color: KimiColorIcon },
Lambda: { mono: LambdaMonoIcon },
LmStudio: { mono: LmStudioMonoIcon },
LongCat: { mono: LongCatMonoIcon, color: LongCatColorIcon },
Meta: { mono: MetaMonoIcon, color: MetaColorIcon },
MetaAI: { mono: MetaAIMonoIcon, color: MetaAIColorIcon },
Minimax: { mono: MinimaxMonoIcon, color: MinimaxColorIcon },
@@ -226,10 +232,12 @@ const LOBE_ICON_COMPONENTS = {
OpenRouter: { mono: OpenRouterMonoIcon },
Perplexity: { mono: PerplexityMonoIcon, color: PerplexityColorIcon },
Poe: { mono: PoeMonoIcon, color: PoeColorIcon },
Pollinations: { mono: PollinationsMonoIcon },
Qoder: { mono: QoderMonoIcon, color: QoderColorIcon },
Qwen: { mono: QwenMonoIcon, color: QwenColorIcon },
Recraft: { mono: RecraftMonoIcon },
Replicate: { mono: ReplicateMonoIcon },
RooCode: { mono: RooCodeMonoIcon },
Runway: { mono: RunwayMonoIcon },
SambaNova: { mono: SambaNovaMonoIcon, color: SambaNovaColorIcon },
SearchApi: { mono: SearchApiMonoIcon },
@@ -247,6 +255,7 @@ const LOBE_ICON_COMPONENTS = {
Vllm: { mono: VllmMonoIcon, color: VllmColorIcon },
Volcengine: { mono: VolcengineMonoIcon, color: VolcengineColorIcon },
Voyage: { mono: VoyageMonoIcon, color: VoyageColorIcon },
Windsurf: { mono: WindsurfMonoIcon },
WorkersAI: { mono: WorkersAIMonoIcon, color: WorkersAIColorIcon },
XAI: { mono: XAIMonoIcon },
XiaomiMiMo: { mono: XiaomiMiMoMonoIcon },
@@ -325,6 +334,7 @@ const LOBE_PROVIDER_ALIASES = {
"lambda-ai": "Lambda",
"lm-studio": "LmStudio",
lmstudio: "LmStudio",
longcat: "LongCat",
"meta-llama": "Meta",
minimax: "Minimax",
"minimax-cn": "Minimax",
@@ -352,10 +362,12 @@ const LOBE_PROVIDER_ALIASES = {
"perplexity-search": "Perplexity",
"perplexity-web": "Perplexity",
poe: "Poe",
pollinations: "Pollinations",
qoder: "Qoder",
qwen: "Qwen",
recraft: "Recraft",
replicate: "Replicate",
roo: "RooCode",
runwayml: "Runway",
sambanova: "SambaNova",
sdwebui: "Automatic",
@@ -382,6 +394,7 @@ const LOBE_PROVIDER_ALIASES = {
voyage: "Voyage",
"voyage-ai": "Voyage",
watsonx: "IBM",
windsurf: "Windsurf",
"workers-ai": "WorkersAI",
workersai: "WorkersAI",
xai: "XAI",

View File

@@ -65,7 +65,6 @@ export const CLI_TOOLS = {
codex: {
id: "codex",
name: "OpenAI Codex CLI",
image: "/providers/codex.png",
color: "#10A37F",
description: "OpenAI Codex CLI",
docsUrl: "https://github.com/openai/codex",
@@ -75,7 +74,7 @@ export const CLI_TOOLS = {
droid: {
id: "droid",
name: "Factory Droid",
image: "/providers/droid.png",
image: "/providers/droid.svg",
color: "#00D4FF",
description: "Factory Droid AI Assistant",
docsUrl: "/docs?section=cli-tools&tool=droid",
@@ -121,7 +120,6 @@ export const CLI_TOOLS = {
windsurf: {
id: "windsurf",
name: "Windsurf",
image: "/providers/windsurf.svg",
color: "#4A90E2",
description: "Windsurf AI-first IDE by Codeium",
docsUrl: "https://windsurf.com/",
@@ -151,7 +149,6 @@ export const CLI_TOOLS = {
cline: {
id: "cline",
name: "Cline",
image: "/providers/cline.png",
color: "#00D1B2",
description: "Cline AI Coding Assistant CLI",
docsUrl: "https://docs.cline.bot/",
@@ -161,7 +158,7 @@ export const CLI_TOOLS = {
kilo: {
id: "kilo",
name: "Kilo Code",
image: "/providers/kilocode.png",
image: "/providers/kilocode.svg",
color: "#FF6B6B",
description: "Kilo Code AI Assistant CLI",
docsUrl: "/docs?section=cli-tools&tool=kilocode",
@@ -200,7 +197,6 @@ export const CLI_TOOLS = {
antigravity: {
id: "antigravity",
name: "Antigravity",
image: "/providers/antigravity.png",
color: "#4285F4",
description: "Google Antigravity IDE with MITM",
docsUrl: "/docs?section=cli-tools&tool=antigravity",
@@ -381,7 +377,7 @@ amp --model "{{model}}"
kiro: {
id: "kiro",
name: "Kiro AI",
image: "/providers/kiro.png",
image: "/providers/kiro.svg",
icon: "psychology_alt",
color: "#FF6B35",
description: "Amazon Kiro — AI-powered IDE with MITM",

View File

@@ -618,6 +618,15 @@ export const APIKEY_PROVIDERS = {
textIcon: "NB",
website: "https://nanobananaapi.ai",
},
kie: {
id: "kie",
alias: "kie",
name: "KIE.AI",
icon: "hub",
color: "#2563EB",
textIcon: "KIE",
website: "https://kie.ai",
},
"ollama-cloud": {
id: "ollama-cloud",
alias: "ollamacloud",

View File

@@ -1557,6 +1557,7 @@ export const updateProviderConnectionSchema = z
healthCheckInterval: z.coerce.number().int().min(0).optional(),
group: z.union([z.string().max(100), z.null()]).optional(),
maxConcurrent: z.union([z.null(), z.coerce.number().int().min(0)]).optional(),
projectId: z.union([z.string(), z.null()]).optional(),
// Partial patch of per-connection provider-specific settings (e.g. quota toggles)
providerSpecificData: z
.record(z.string(), z.unknown())

View File

@@ -278,7 +278,21 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
// Check if model is a combo (has multiple models with fallback)
telemetry.startPhase("resolve");
const combo: any = await getComboForModel(resolvedModelStr);
let combo: any = await getComboForModel(resolvedModelStr);
// "auto" prefix fuzzy matching: "auto/fast" → "auto/best-fast", etc.
// parseModel splits "auto/fast" into provider="auto" which isn't a real provider.
if (!combo && resolvedModelStr.startsWith("auto/")) {
const suffix = resolvedModelStr.slice(5);
for (const candidate of [`auto/best-${suffix}`, `auto/${suffix}`]) {
combo = await getComboForModel(candidate);
if (combo) {
log.info("ROUTING", `"${resolvedModelStr}" → combo "${candidate}" (auto fuzzy)`);
break;
}
}
}
if (combo) {
log.info(
"CHAT",
@@ -485,9 +499,61 @@ async function handleSingleModelChat(
isCombo: boolean = false
) {
// 1. Resolve model → provider/model
const resolved = await resolveModelOrError(modelStr, body, clientRawRequest?.endpoint);
const resolved = await resolveModelOrError(
modelStr,
body,
clientRawRequest?.endpoint,
clientRawRequest?.headers
);
if (resolved.error) return resolved.error;
// Safety net: if auto-combo resolution returned a combo object, redirect
// to combo flow. This handles the case where the auto-fuzzy match in
// resolveModelOrError found a combo but the main handler's combo lookup missed it.
if ((resolved as any).combo) {
const redirectCombo = (resolved as any).combo;
log.info("ROUTING", `Auto-combo redirect from handleSingleModelChat for "${modelStr}"`);
log.info("ROUTING", `Auto-combo redirect to combo flow for "${modelStr}"`);
return handleComboChat({
body,
combo: redirectCombo,
handleSingleModel: (
b: any,
m: string,
target?: {
connectionId?: string | null;
executionKey?: string | null;
stepId?: string | null;
}
) =>
handleSingleModelChat(
b,
m,
clientRawRequest,
request,
redirectCombo.name ?? modelStr,
apiKeyInfo,
telemetry,
{
sessionId: "", // safety-net redirect doesn't have session context
forceLiveComboTest: false,
forcedConnectionId: null,
allowedConnectionIds: null,
comboStepId: null,
comboExecutionKey: null,
},
redirectCombo.strategy ?? "priority",
false
),
isModelAvailable: async () => true,
log,
settings: {},
allCombos: [],
relayOptions: undefined,
signal: request?.signal ?? null,
});
}
const { provider, model, sourceFormat, targetFormat, extendedContext } = resolved;
const forceLiveComboTest = runtimeOptions.forceLiveComboTest === true;
const hasForcedConnection =

View File

@@ -1,4 +1,4 @@
import { getModelInfo } from "../services/model";
import { getModelInfo, getComboForModel } from "../services/model";
import { clearAccountError, markAccountUnavailable } from "../services/auth";
import * as log from "../utils/logger";
import { updateProviderCredentials } from "../services/tokenRefresh";
@@ -43,8 +43,59 @@ const PREFERRED_BY_FAMILY: Record<string, string> = {
mimo: "moonshot",
};
export async function resolveModelOrError(modelStr: string, body: any, endpointPath: string = "") {
const CODEX_NATIVE_RESPONSES_MODELS = new Set(["gpt-5.5"]);
function getHeaderValue(headers: Record<string, unknown> | null | undefined, name: string) {
if (!headers || typeof headers !== "object") return "";
const lowerName = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() !== lowerName) continue;
return Array.isArray(value) ? value.join(",") : String(value ?? "");
}
return "";
}
function isCodexNativeResponsesRequest(
body: any,
endpointPath: string,
headers: Record<string, unknown> | null | undefined
) {
const normalizedEndpoint = String(endpointPath || "").replace(/\/+$/, "");
if (!/(^|\/)responses(?=\/|$)/i.test(normalizedEndpoint)) return false;
if (/\/responses\/compact$/i.test(normalizedEndpoint)) return true;
const userAgent = getHeaderValue(headers, "user-agent").toLowerCase();
if (userAgent.includes("codex")) return true;
if (getHeaderValue(headers, "x-codex-session-id")) return true;
if (getHeaderValue(headers, "x-codex-window-id")) return true;
if (getHeaderValue(headers, "x-codex-turn-metadata")) return true;
const metadataSource =
body && typeof body === "object" && body.metadata && typeof body.metadata === "object"
? String(body.metadata.source || "")
: "";
return metadataSource.toLowerCase().includes("codex");
}
export async function resolveModelOrError(
modelStr: string,
body: any,
endpointPath: string = "",
requestHeaders: Record<string, unknown> | null | undefined = null
) {
const modelInfo = await getModelInfo(modelStr);
const sourceFormat = detectFormatFromEndpoint(body, endpointPath);
if (
modelInfo.provider === "openai" &&
typeof modelInfo.model === "string" &&
CODEX_NATIVE_RESPONSES_MODELS.has(modelInfo.model) &&
sourceFormat === "openai-responses" &&
isCodexNativeResponsesRequest(body, endpointPath, requestHeaders)
) {
log.info("ROUTING", `${modelStr} → codex/${modelInfo.model} (Codex native responses)`);
modelInfo.provider = "codex";
}
// Forced-rewrite: codex provider doesn't serve DeepSeek/Qwen/Kimi/etc. Reroute
// these to their canonical native provider so the request lands on the right
@@ -79,6 +130,46 @@ export async function resolveModelOrError(modelStr: string, body: any, endpointP
}
}
// "auto" is a combo prefix, not a provider. parseModel("auto/fast") splits it into
// provider="auto" model="fast" — redirect to matching combo before credential lookup fails.
if (modelInfo.provider === "auto") {
const exactCombo = await getComboForModel(modelStr);
if (exactCombo) {
log.info("ROUTING", `"auto" provider → combo "${modelStr}"`);
return { combo: exactCombo, provider: "auto", model: modelInfo.model };
}
// Fuzzy: "fast" → "auto/best-fast", "chat" → "auto/best-chat"
const suffix = modelInfo.model || "";
for (const candidate of [`auto/best-${suffix}`, `auto/${suffix}`]) {
const fuzzyCombo = await getComboForModel(candidate);
if (fuzzyCombo) {
log.info("ROUTING", `"auto/${suffix}" → combo "${candidate}" (fuzzy)`);
return { combo: fuzzyCombo, provider: "auto", model: suffix };
}
}
// List available auto/* combos in error
const available: string[] = [];
try {
const { getCombos } = await import("@/lib/localDb");
const all = await getCombos();
for (const c of all) {
if (c.name?.startsWith("auto/")) available.push(c.name);
}
} catch {
/* DB unavailable */
}
const hint =
available.length > 0
? ` Available auto combos: ${available.join(", ")}`
: " No auto combos configured — create one in the Dashboard.";
const message = `Model '${modelStr}' is not a valid combo or provider.${hint}`;
log.warn("CHAT", message, { model: modelStr });
return { error: errorResponse(HTTP_STATUS.BAD_REQUEST, message) };
}
if (!modelInfo.provider) {
if ((modelInfo as any).errorType === "ambiguous_model") {
// Family disambiguation: if the model name begins with a known
@@ -113,7 +204,6 @@ export async function resolveModelOrError(modelStr: string, body: any, endpointP
}
const { provider, model, extendedContext } = modelInfo;
const sourceFormat = detectFormatFromEndpoint(body, endpointPath);
const providerAlias = PROVIDER_ID_TO_ALIAS[provider] || provider;
let targetFormat = getModelTargetFormat(providerAlias, model) || getTargetFormat(provider);
if ((modelInfo as any).apiFormat === "responses") {