mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 22:02:08 +03:00
chore: resolve merge conflicts in Dockerfile
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user