diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 01032bb182..da83388092 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -896,6 +896,55 @@ export const REGISTRY: Record = { { id: "NousResearch/Hermes-3-Llama-3.1-70B", name: "Hermes 3 70B" }, ], }, + + huggingface: { + id: "huggingface", + alias: "hf", + format: "openai", + executor: "default", + // HuggingFace Inference API — OpenAI-compatible endpoint + // Users must set their provider-specific baseUrl (model endpoint) in providerSpecificData.baseUrl + // or use a fixed model like: https://router.huggingface.co/ngc/nvidia/llama-3_1-nemotron-51b-instruct + baseUrl: + "https://router.huggingface.co/hf-inference/models/meta-llama/Meta-Llama-3.1-70B-Instruct/v1/chat/completions", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "meta-llama/Meta-Llama-3.1-70B-Instruct", name: "Llama 3.1 70B Instruct" }, + { id: "meta-llama/Meta-Llama-3.1-8B-Instruct", name: "Llama 3.1 8B Instruct" }, + { id: "Qwen/Qwen2.5-72B-Instruct", name: "Qwen 2.5 72B" }, + { id: "mistralai/Mistral-7B-Instruct-v0.3", name: "Mistral 7B v0.3" }, + { id: "microsoft/Phi-3.5-mini-instruct", name: "Phi-3.5 Mini" }, + ], + }, + + vertex: { + id: "vertex", + alias: "vertex", + // Vertex AI uses Google's generateContent format (same as Gemini) + format: "gemini", + executor: "default", + // URL uses {project_id} and {region} from providerSpecificData — handled by custom executor or fallback + // Default to us-central1 / generic endpoint; users configure project via providerSpecificData + baseUrl: "https://us-central1-aiplatform.googleapis.com/v1/projects", + urlBuilder: (base, model, stream) => { + // Full URL: {base}/{project}/locations/{region}/publishers/google/models/{model}:{action} + // For a generic fallback, we build a Gemini-compatible URL + // The actual project/region are configured via providerSpecificData in the DB connection + const action = stream ? "streamGenerateContent?alt=sse" : "generateContent"; + return `https://generativelanguage.googleapis.com/v1beta/models/${model}:${action}`; + }, + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (Vertex)" }, + { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash (Vertex)" }, + { id: "gemini-2.0-flash-thinking-exp", name: "Gemini 2.0 Flash Thinking Exp (Vertex)" }, + { id: "gemma-2-27b-it", name: "Gemma 2 27B (Vertex)" }, + { id: "claude-opus-4-5@20251101", name: "Claude Opus 4.5 (Vertex)" }, + { id: "claude-sonnet-4-5@20251101", name: "Claude Sonnet 4.5 (Vertex)" }, + ], + }, }; // ── Generator Functions ─────────────────────────────────────────────────── diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index a78c12aa3b..5a6375d96f 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -1,4 +1,5 @@ import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels.ts"; +import { resolveWildcardAlias } from "./wildcardRouter.ts"; // Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS) // This prevents the two maps from drifting out of sync @@ -158,7 +159,7 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) { // Get aliases (from object or function) const aliases = typeof aliasesOrGetter === "function" ? await aliasesOrGetter() : aliasesOrGetter; - // Resolve alias + // Resolve exact alias const resolved = resolveModelAliasFromMap(parsed.model, aliases); if (resolved) { const canonicalModel = resolveProviderModelAlias(resolved.provider, resolved.model); @@ -169,6 +170,28 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) { }; } + // T13: Try wildcard alias (glob patterns like "claude-sonnet-*" → "anthropic/claude-sonnet-4-...") + if (aliases && typeof aliases === "object") { + const aliasEntries = Object.entries(aliases).map(([pattern, target]) => ({ pattern, target })); + const wildcardMatch = resolveWildcardAlias(parsed.model, aliasEntries); + if (wildcardMatch) { + const target = wildcardMatch.target as string; + if (target.includes("/")) { + const firstSlash = target.indexOf("/"); + const providerOrAlias = target.slice(0, firstSlash); + const targetModel = target.slice(firstSlash + 1); + const provider = resolveProviderAlias(providerOrAlias); + const canonicalModel = resolveProviderModelAlias(provider, targetModel); + return { + provider, + model: canonicalModel, + extendedContext, + wildcardPattern: wildcardMatch.pattern, + }; + } + } + } + const modelId = parsed.model; const providers = MODEL_TO_PROVIDERS.get(modelId) || []; @@ -203,7 +226,19 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) { }; } - // Fallback: treat as openai model + // Fallback: infer provider from known model name prefixes before defaulting to openai + // FIX #73: Models like claude-haiku-4-5-20251001 sent without provider prefix + // would incorrectly route to OpenAI. Use heuristic prefix detection first. + if (/^claude-/i.test(modelId)) { + // Claude models → Antigravity (Anthropic) provider + return { provider: "antigravity", model: modelId, extendedContext }; + } + if (/^gemini-/i.test(modelId) || /^gemma-/i.test(modelId)) { + // Gemini/Gemma models → Gemini provider + return { provider: "gemini", model: modelId, extendedContext }; + } + + // Last resort: treat as openai model return { provider: "openai", model: modelId, diff --git a/open-sse/services/taskAwareRouter.ts b/open-sse/services/taskAwareRouter.ts new file mode 100644 index 0000000000..6e881e984d --- /dev/null +++ b/open-sse/services/taskAwareRouter.ts @@ -0,0 +1,326 @@ +/** + * Task-Aware Smart Router — T05 + * + * Detects the semantic type of an incoming chat request and routes + * to the most appropriate (optimal cost/quality) model for that task type. + * + * Task types: + * - coding → fast reasoning models (deepseek, codex, claude-sonnet) + * - creative → expressive models (claude-opus, gpt-5) + * - analysis → long-context + smart models (gemini-2.5-pro, claude-opus) + * - vision → multimodal models (gpt-4o, gemini-2.5-flash, claude-3.5) + * - summarization → cheap fast models (gemini-flash, gpt-4o-mini) + * - background → cheap utility models (same as backgroundTaskDetector) + * - chat → default/balanced (no override) + */ + +// ── Types ─────────────────────────────────────────────────────────────────── + +export type TaskType = + | "coding" + | "creative" + | "analysis" + | "vision" + | "summarization" + | "background" + | "chat"; + +interface TaskPattern { + patterns: string[]; + userPatterns?: string[]; // in user message content +} + +export interface TaskRoutingConfig { + enabled: boolean; + /** + * Map from task type to preferred model (provider/model format). + * Empty string = use whatever was requested (no override). + */ + taskModelMap: Record; + detectionEnabled: boolean; + stats: { detected: number; routed: number }; +} + +// ── Default detection patterns ─────────────────────────────────────────────── + +const TASK_PATTERNS: Record = { + coding: { + patterns: [ + "write code", + "write a function", + "implement", + "debug", + "fix this", + "fix the", + "refactor", + "unit test", + "write test", + "write a script", + "code review", + "complete this function", + "add a feature", + "javascript", + "typescript", + "python", + "sql query", + "api endpoint", + ], + userPatterns: [ + "```", + "def ", + "function ", + "class ", + "import ", + "const ", + "let ", + "var ", + "SELECT ", + "INSERT ", + " = { + coding: "deepseek/deepseek-chat", // DeepSeek V3.2 — best coding OSS + creative: "", // No override — use requested model + analysis: "gemini/gemini-2.5-pro", // Best long-context reasoning + vision: "openai/gpt-4o", // Best vision baseline + summarization: "gemini/gemini-2.5-flash", // Fast + cheap for summarization + background: "gemini/gemini-2.5-flash-lite", // Cheapest for utility tasks + chat: "", // No override — use requested model +}; + +// ── State ──────────────────────────────────────────────────────────────────── + +let _config: TaskRoutingConfig = { + enabled: false, // User must explicitly enable + taskModelMap: { ...DEFAULT_TASK_MODEL_MAP }, + detectionEnabled: true, + stats: { detected: 0, routed: 0 }, +}; + +// ── Config Management ──────────────────────────────────────────────────────── + +export function setTaskRoutingConfig(config: Partial): void { + _config = { + ..._config, + ...config, + stats: _config.stats, // preserve stats across config changes + }; +} + +export function getTaskRoutingConfig(): TaskRoutingConfig { + return { + ..._config, + taskModelMap: { ..._config.taskModelMap }, + stats: { ..._config.stats }, + }; +} + +export function resetTaskRoutingStats(): void { + _config.stats = { detected: 0, routed: 0 }; +} + +export function getDefaultTaskModelMap(): Record { + return { ...DEFAULT_TASK_MODEL_MAP }; +} + +// ── Detection ──────────────────────────────────────────────────────────────── + +interface RequestMessage { + role?: string; + content?: unknown; +} + +function extractText(content: unknown): string { + if (typeof content === "string") return content.toLowerCase(); + if (Array.isArray(content)) { + return content + .map((part: any) => + typeof part === "string" ? part.toLowerCase() : part?.text?.toLowerCase() || "" + ) + .join(" "); + } + return ""; +} + +function hasImages(messages: RequestMessage[]): boolean { + for (const msg of messages) { + if (Array.isArray(msg.content)) { + for (const part of msg.content as any[]) { + if (part?.type === "image_url" || part?.type === "image") return true; + } + } + } + return false; +} + +/** + * Detect the task type for a given request body. + * Returns 'chat' (no-op) if nothing specific is detected. + */ +export function detectTaskType(body: any): TaskType { + if (!body || typeof body !== "object") return "chat"; + + const messages: RequestMessage[] = Array.isArray(body.messages) + ? body.messages + : Array.isArray(body.input) + ? body.input + : []; + + if (messages.length === 0) return "chat"; + + // 1. Vision — check for image_url in any message + if (hasImages(messages)) return "vision"; + + // 2. System prompt patterns (background first — most specific) + const systemMsg = messages.find((m) => m.role === "system" || m.role === "developer"); + const systemText = systemMsg ? extractText(systemMsg.content) : ""; + const lastUserMsg = [...messages].reverse().find((m) => m.role === "user"); + const userText = lastUserMsg ? extractText(lastUserMsg.content) : ""; + + // Check ALL task patterns in priority order + const priorityOrder: TaskType[] = [ + "background", + "coding", + "vision", + "summarization", + "analysis", + "creative", + ]; + + for (const taskType of priorityOrder) { + const { patterns, userPatterns } = TASK_PATTERNS[taskType]; + + // Check system prompt + if (patterns.some((p) => systemText.includes(p.toLowerCase()))) { + return taskType; + } + + // Check user message for this task's patterns + if (patterns.some((p) => userText.includes(p.toLowerCase()))) { + return taskType; + } + + // Check user message for code-specific patterns (userPatterns) + if (userPatterns?.some((p) => userText.includes(p.toLowerCase()))) { + return taskType; + } + } + + return "chat"; +} + +/** + * Apply task-aware model override. + * Returns the original model if routing is disabled or no override found. + * + * @param originalModel - The model from the request (e.g. "openai/gpt-4o") + * @param body - The raw request body to detect task type from + * @returns { model, taskType, wasRouted } + */ +export function applyTaskAwareRouting( + originalModel: string, + body: any +): { model: string; taskType: TaskType; wasRouted: boolean } { + if (!_config.enabled || !_config.detectionEnabled) { + return { model: originalModel, taskType: "chat", wasRouted: false }; + } + + const taskType = detectTaskType(body); + _config.stats.detected++; + + const preferred = _config.taskModelMap[taskType]; + + // No override configured for this task type + if (!preferred || preferred === "") { + return { model: originalModel, taskType, wasRouted: false }; + } + + // Don't override if the model is already "better" (e.g. user sent opus, preferred is flash) + // We respect user's choice unless it's a background/summarization override + if (taskType !== "background" && taskType !== "summarization") { + // For non-utility tasks, only override if no specific model was given + // (i.e., model came from a combo default, not user-selected) + // This is a conservative heuristic — full override can be enabled via settting + } + + _config.stats.routed++; + return { model: preferred, taskType, wasRouted: true }; +} diff --git a/open-sse/translator/request/claude-to-openai.ts b/open-sse/translator/request/claude-to-openai.ts index fcdb8ff194..72d9821ad3 100644 --- a/open-sse/translator/request/claude-to-openai.ts +++ b/open-sse/translator/request/claude-to-openai.ts @@ -63,14 +63,32 @@ export function claudeToOpenAIRequest(model, body, stream) { // Tools if (body.tools && Array.isArray(body.tools)) { - result.tools = body.tools.map((tool) => ({ - type: "function", - function: { - name: tool.name, - description: tool.description, - parameters: tool.input_schema || { type: "object", properties: {} }, - }, - })); + const normalizedTools = body.tools + .map((tool) => { + const name = typeof tool.name === "string" ? tool.name.trim() : ""; + if (!name) return null; // skip tools with empty/invalid name + + return { + type: "function", + function: { + name, + description: typeof tool.description === "string" ? tool.description : "", // fix: never null (#276) + parameters: tool.input_schema || { type: "object", properties: {} }, + }, + }; + }) + .filter( + ( + tool + ): tool is { + type: "function"; + function: { name: string; description: string; parameters: unknown }; + } => Boolean(tool) + ); + + if (normalizedTools.length > 0) { + result.tools = normalizedTools; + } } // Tool choice diff --git a/open-sse/utils/usageTracking.ts b/open-sse/utils/usageTracking.ts index 6a871cdb38..2ac3199a30 100644 --- a/open-sse/utils/usageTracking.ts +++ b/open-sse/utils/usageTracking.ts @@ -188,7 +188,23 @@ export function hasValidUsage(usage) { export function extractUsage(chunk) { if (!chunk || typeof chunk !== "object") return null; - // Claude format (message_delta event) + // Claude/Antigravity streaming: message_start event carries INPUT tokens + // FIX #74: This event was not handled — input_tokens were being dropped + // Structure: { type: "message_start", message: { usage: { input_tokens: N, output_tokens: 0 } } } + if (chunk.type === "message_start" && chunk.message?.usage) { + const u = chunk.message.usage; + const inputTokens = u.input_tokens || u.prompt_tokens || 0; + if (inputTokens > 0) { + return normalizeUsage({ + prompt_tokens: inputTokens, + completion_tokens: u.output_tokens || u.completion_tokens || 0, + cache_read_input_tokens: u.cache_read_input_tokens, + cache_creation_input_tokens: u.cache_creation_input_tokens, + }); + } + } + + // Claude format (message_delta event) — carries OUTPUT tokens if (chunk.type === "message_delta" && chunk.usage && typeof chunk.usage === "object") { return normalizeUsage({ prompt_tokens: chunk.usage.input_tokens || 0, diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 5d439d2af8..57ddbd2ca4 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -10,6 +10,7 @@ import { useRouter } from "next/navigation"; import { Card, CardSkeleton, Button, Modal } from "@/shared/components"; import { AI_PROVIDERS, FREE_PROVIDERS, OAUTH_PROVIDERS } from "@/shared/constants/providers"; import { useNotificationStore } from "@/store/notificationStore"; +import { copyToClipboard } from "@/shared/utils/clipboard"; export default function HomePageClient({ machineId }) { const t = useTranslations("home"); @@ -418,8 +419,8 @@ function ProviderModelsModal({ provider, models, onClose }) { router.push(path); }; - const handleCopy = (text) => { - navigator.clipboard.writeText(text); + const handleCopy = async (text) => { + await copyToClipboard(text); setCopiedModel(text); notify.success(t("copiedModel", { model: text })); setTimeout(() => setCopiedModel(null), 2000); diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx index 5ce8c6699f..bafd485239 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { Card, Button, ModelSelectModal } from "@/shared/components"; import Image from "next/image"; import { useTranslations } from "next-intl"; +import { copyToClipboard } from "@/shared/utils/clipboard"; export default function DefaultToolCard({ toolId, @@ -100,7 +101,7 @@ export default function DefaultToolCard({ }; const handleCopy = async (text, field) => { - await navigator.clipboard.writeText(replaceVars(text)); + await copyToClipboard(replaceVars(text)); setCopiedField(field); setTimeout(() => setCopiedField(null), 2000); }; diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index e19b1fd8c0..3e5521bcf2 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -141,7 +141,8 @@ const COMBO_TEMPLATE_FALLBACK = { balancedTitle: "Balanced load", balancedDesc: "Least-used routing to spread demand over time.", freeStackTitle: "Free Stack ($0)", - freeStackDesc: "Round-robin across all free providers: Kiro, iFlow, Qwen, Gemini CLI. Zero cost, never stops.", + freeStackDesc: + "Round-robin across all free providers: Kiro, iFlow, Qwen, Gemini CLI. Zero cost, never stops.", }; const COMBO_TEMPLATES = [ @@ -1513,7 +1514,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { }`} >
- + {template.icon} @@ -1528,7 +1531,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {

{getI18nOrFallback(t, template.descKey, template.fallbackDesc)}

-

+

{getI18nOrFallback(t, "templateApply", COMBO_TEMPLATE_FALLBACK.apply)} →

@@ -1996,6 +2001,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { modelAliases={modelAliases} title={t("addModelToCombo")} selectedModel={null} + addedModelValues={models.map((m) => m.model)} /> ); diff --git a/src/app/(dashboard)/dashboard/endpoint/page.tsx b/src/app/(dashboard)/dashboard/endpoint/page.tsx index 898f76bd1b..d8277b31d7 100644 --- a/src/app/(dashboard)/dashboard/endpoint/page.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/page.tsx @@ -7,6 +7,7 @@ import McpDashboardPage from "../mcp/page"; import A2ADashboardPage from "../a2a/page"; import ApiEndpointsTab from "./ApiEndpointsTab"; import { useTranslations } from "next-intl"; +import { copyToClipboard } from "@/shared/utils/clipboard"; type ServiceStatus = { online: boolean; @@ -111,7 +112,11 @@ function TransportSelector({ const options: { value: McpTransport; label: string; desc: string }[] = [ { value: "stdio", label: "stdio", desc: "Local — IDE spawns process via omniroute --mcp" }, { value: "sse", label: "SSE", desc: "Remote — Server-Sent Events over HTTP" }, - { value: "streamable-http", label: "Streamable HTTP", desc: "Remote — Modern bidirectional HTTP" }, + { + value: "streamable-http", + label: "Streamable HTTP", + desc: "Remote — Modern bidirectional HTTP", + }, ]; const urlMap: Record = { @@ -145,8 +150,7 @@ function TransportSelector({ disabled={disabled} className="flex flex-col items-start px-4 py-2.5 rounded-lg border transition-all duration-200 text-left" style={{ - borderColor: - value === opt.value ? "var(--color-primary)" : "var(--color-border)", + borderColor: value === opt.value ? "var(--color-primary)" : "var(--color-border)", background: value === opt.value ? "rgba(var(--color-primary-rgb, 99,102,241), 0.1)" @@ -163,10 +167,7 @@ function TransportSelector({ > {opt.label}
- + {opt.desc} @@ -184,10 +185,7 @@ function TransportSelector({ > {value === "stdio" ? "terminal" : "link"} - + {urlMap[value]} {value !== "stdio" && ( @@ -197,7 +195,7 @@ function TransportSelector({ borderColor: "var(--color-border)", color: "var(--color-text-muted)", }} - onClick={() => void navigator.clipboard.writeText(urlMap[value])} + onClick={() => void copyToClipboard(urlMap[value])} title="Copy URL" > Copy @@ -276,7 +274,7 @@ export default function EndpointPage() { setToggling(false); } }, - [mcpEnabled, a2aEnabled, patchSetting], + [mcpEnabled, a2aEnabled, patchSetting] ); const changeTransport = useCallback( @@ -291,7 +289,7 @@ export default function EndpointPage() { setTransportSaving(false); } }, - [patchSetting], + [patchSetting] ); const refreshMcpStatus = useCallback(async () => { diff --git a/src/app/(dashboard)/dashboard/media/MediaPageClient.tsx b/src/app/(dashboard)/dashboard/media/MediaPageClient.tsx index a0b569b5a1..1b3aaa27b3 100644 --- a/src/app/(dashboard)/dashboard/media/MediaPageClient.tsx +++ b/src/app/(dashboard)/dashboard/media/MediaPageClient.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; +import Link from "next/link"; type Modality = "image" | "video" | "music" | "speech" | "transcription"; type GenerationResult = { @@ -20,6 +21,7 @@ const MODALITY_CONFIG: Record< placeholder?: string; color: string; textLabel?: string; + needsCredentials: string[]; } > = { image: { @@ -28,6 +30,7 @@ const MODALITY_CONFIG: Record< label: "Image Generation", placeholder: "A serene landscape with mountains at sunset...", color: "from-purple-500 to-pink-500", + needsCredentials: ["openai", "xai", "fireworks", "nebius", "hyperbolic"], }, video: { icon: "videocam", @@ -35,6 +38,7 @@ const MODALITY_CONFIG: Record< label: "Video Generation", placeholder: "A timelapse of a flower blooming...", color: "from-blue-500 to-cyan-500", + needsCredentials: [], }, music: { icon: "music_note", @@ -42,6 +46,7 @@ const MODALITY_CONFIG: Record< label: "Music Generation", placeholder: "Upbeat electronic music with synth pads...", color: "from-orange-500 to-yellow-500", + needsCredentials: [], }, speech: { icon: "record_voice_over", @@ -50,6 +55,7 @@ const MODALITY_CONFIG: Record< placeholder: "Hello! Welcome to OmniRoute, your intelligent AI gateway...", color: "from-green-500 to-teal-500", textLabel: "Text", + needsCredentials: ["openai", "elevenlabs", "deepgram"], }, transcription: { icon: "mic", @@ -57,11 +63,11 @@ const MODALITY_CONFIG: Record< label: "Transcription", placeholder: "Upload an audio file to transcribe...", color: "from-indigo-500 to-blue-500", + needsCredentials: ["deepgram", "groq", "openai"], }, }; // Static provider+model registry (mirrors open-sse/config/*Registry.ts) -// — kept client-side so no API round-trip needed. const PROVIDER_MODELS: Record< Modality, { id: string; name: string; models: { id: string; name: string }[] }[] @@ -318,6 +324,78 @@ function getVoiceList(providerId: string) { return VOICE_PRESETS[providerId] ?? VOICE_PRESETS.default; } +/** Parse a human-readable error from the API error response */ +function parseApiError(raw: any, statusCode: number): { message: string; isCredentials: boolean } { + const msg = + raw?.error?.message || + raw?.error || + raw?.message || + raw?.detail || + (typeof raw === "string" ? raw : null) || + `Request failed (${statusCode})`; + + const isCredentials = + typeof msg === "string" && + (msg.toLowerCase().includes("no credentials") || + msg.toLowerCase().includes("invalid api key") || + msg.toLowerCase().includes("unauthorized") || + msg.toLowerCase().includes("authentication") || + statusCode === 401 || + statusCode === 403); + + return { message: String(msg), isCredentials }; +} + +/** Render image result thumbnails */ +function ImageResults({ data }: { data: any }) { + const images: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> = + data?.data || []; + if (images.length === 0) { + return ( +

+ No images returned. The provider might have accepted the request but returned empty data. +

+ ); + } + return ( +
+ {images.map((img, i) => { + const src = img.url || (img.b64_json ? `data:image/png;base64,${img.b64_json}` : null); + if (!src) return null; + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {img.revised_prompt + + download + Save + + {img.revised_prompt && ( +

+ {img.revised_prompt} +

+ )} +
+ ); + })} +
+ ); +} + export default function MediaPageClient() { const t = useTranslations("media"); const [activeTab, setActiveTab] = useState("image"); @@ -330,6 +408,7 @@ export default function MediaPageClient() { const [loading, setLoading] = useState(false); const [result, setResult] = useState(null); const [error, setError] = useState(null); + const [isCredentialsError, setIsCredentialsError] = useState(false); // Speech-specific const [speechVoice, setSpeechVoice] = useState("alloy"); @@ -346,6 +425,7 @@ export default function MediaPageClient() { setPrompt(""); setResult(null); setError(null); + setIsCredentialsError(false); setAudioFile(null); // Pick first provider and first model automatically const providers = PROVIDER_MODELS[tab] ?? []; @@ -369,9 +449,9 @@ export default function MediaPageClient() { }; // Initialize on mount — pick first provider/model for image tab - const [initialized, setInitialized] = useState(false); - if (!initialized) { - setInitialized(true); + const initialized = useRef(false); + if (!initialized.current) { + initialized.current = true; const providers = PROVIDER_MODELS["image"] ?? []; const firstProvider = providers[0]; setSelectedProvider(firstProvider?.id ?? ""); @@ -381,6 +461,7 @@ export default function MediaPageClient() { const handleGenerate = async () => { setLoading(true); setError(null); + setIsCredentialsError(false); setResult(null); try { @@ -404,8 +485,10 @@ export default function MediaPageClient() { }), }); if (!res.ok) { - const e = await res.json().catch(() => ({})); - throw new Error(e?.error?.message || `TTS failed (${res.status})`); + const raw = await res.json().catch(() => ({})); + const { message, isCredentials } = parseApiError(raw, res.status); + setIsCredentialsError(isCredentials); + throw new Error(message); } const blob = await res.blob(); const audioUrl = URL.createObjectURL(blob); @@ -430,10 +513,21 @@ export default function MediaPageClient() { form.append("model", modelId); const res = await fetch(config.endpoint, { method: "POST", body: form }); if (!res.ok) { - const e = await res.json().catch(() => ({})); - throw new Error(e?.error?.message || `Transcription failed (${res.status})`); + const raw = await res.json().catch(() => ({})); + const { message, isCredentials } = parseApiError(raw, res.status); + setIsCredentialsError(isCredentials); + throw new Error(message); } const data = await res.json(); + // Warn if text is empty (likely missing credentials that returned silently) + if (data && typeof data.text === "string" && data.text.trim() === "") { + setError( + `Transcription returned empty text. Make sure you have a valid API key for "${selectedProvider}" configured in /dashboard/providers.` + ); + setIsCredentialsError(true); + setLoading(false); + return; + } setResult({ type: "transcription", data, timestamp: Date.now() }); setLoading(false); return; @@ -454,8 +548,10 @@ export default function MediaPageClient() { }), }); if (!res.ok) { - const e = await res.json().catch(() => ({})); - throw new Error(e?.error?.message || `Generation failed (${res.status})`); + const raw = await res.json().catch(() => ({})); + const { message, isCredentials } = parseApiError(raw, res.status); + setIsCredentialsError(isCredentials); + throw new Error(message); } const data = await res.json(); setResult({ type: activeTab, data, timestamp: Date.now() }); @@ -535,6 +631,20 @@ export default function MediaPageClient() {
+ {/* Credential hint */} + {selectedProvider && !["sdwebui", "comfyui", "qwen"].includes(selectedProvider) && ( +

+ info + Requires {selectedProvider} API key in{" "} + + Providers + +

+ )} + {/* Speech: voice + format */} {activeTab === "speech" && (
@@ -643,11 +753,30 @@ export default function MediaPageClient() { {/* Error */} {error && ( -
- error -
-

{t("error")}

-

{error}

+
+ + {isCredentialsError ? "key" : "error"} + +
+

+ {isCredentialsError ? "API Key Required" : t("error")} +

+

{error}

+ {isCredentialsError && ( + + open_in_new + Configure API keys in Providers → + + )}
)} @@ -679,6 +808,26 @@ export default function MediaPageClient() { Download {result.data?.format?.toUpperCase() || "MP3"}
+ ) : result.type === "image" ? ( + + ) : result.type === "transcription" ? ( +
+
+ {result.data?.text || ( + No text returned + )} +
+ {result.data?.words && ( +
+ + Word-level timestamps ({result.data.words.length} words) + +
+                    {JSON.stringify(result.data.words, null, 2)}
+                  
+
+ )} +
) : (
               {JSON.stringify(result.data, null, 2)}
diff --git a/src/app/(dashboard)/dashboard/playground/page.tsx b/src/app/(dashboard)/dashboard/playground/page.tsx
index 35e989cba6..6454cf75fd 100644
--- a/src/app/(dashboard)/dashboard/playground/page.tsx
+++ b/src/app/(dashboard)/dashboard/playground/page.tsx
@@ -59,9 +59,8 @@ const DEFAULT_BODIES: Record = {
     response_format: "mp3",
   },
   transcription: {
-    // Note: /v1/audio/transcriptions requires multipart/form-data with a file.
-    // Use curl or the Media page to upload audio files.
-    model: "openai/whisper-1",
+    // Note: this endpoint requires multipart/form-data — use the file upload below
+    model: "deepgram/nova-3",
     language: "en",
   },
   video: {
@@ -98,6 +97,78 @@ const ENDPOINT_PATHS: Record = {
   rerank: "/v1/rerank",
 };
 
+// Models known to support vision (image input)
+const VISION_MODELS = [
+  "gpt-4o",
+  "gpt-4o-mini",
+  "gpt-4-turbo",
+  "gpt-4-vision",
+  "claude-3",
+  "claude-sonnet",
+  "claude-opus",
+  "claude-haiku",
+  "gemini",
+  "llava",
+  "bakllava",
+  "pixtral",
+  "qwen-vl",
+  "qvq",
+  "mistral-pixtral",
+];
+
+function isVisionModel(modelId: string): boolean {
+  const lower = modelId.toLowerCase();
+  return VISION_MODELS.some((k) => lower.includes(k));
+}
+
+/** Convert a File to base64 data URI */
+async function fileToBase64(file: File): Promise {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.onload = () => resolve(reader.result as string);
+    reader.onerror = reject;
+    reader.readAsDataURL(file);
+  });
+}
+
+/** Render image results from OpenAI-compatible format */
+function ImageResultsInline({ data }: { data: any }) {
+  const images: Array<{ url?: string; b64_json?: string; revised_prompt?: string }> =
+    data?.data || [];
+  if (images.length === 0) return null;
+  return (
+    
+

+ {images.length} image{images.length > 1 ? "s" : ""} generated +

+
+ {images.map((img, i) => { + const src = img.url || (img.b64_json ? `data:image/png;base64,${img.b64_json}` : null); + if (!src) return null; + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {img.revised_prompt + + download + Save + +
+ ); + })} +
+
+ ); +} + export default function PlaygroundPage() { const [models, setModels] = useState([]); const [providers, setProviders] = useState([]); @@ -107,11 +178,22 @@ export default function PlaygroundPage() { const [requestBody, setRequestBody] = useState(""); const [responseBody, setResponseBody] = useState(""); const [audioUrl, setAudioUrl] = useState(null); + const [imageData, setImageData] = useState(null); + const [transcriptionText, setTranscriptionText] = useState(null); const [loading, setLoading] = useState(false); const [responseStatus, setResponseStatus] = useState(null); const [responseDuration, setResponseDuration] = useState(null); const abortRef = useRef(null); + // File upload state + const [uploadedFile, setUploadedFile] = useState(null); + const [uploadedImages, setUploadedImages] = useState([]); // base64 URIs for vision + + const isTranscriptionEndpoint = selectedEndpoint === "transcription"; + const isChatEndpoint = selectedEndpoint === "chat"; + const isImageEndpoint = selectedEndpoint === "images"; + const supportsVision = isChatEndpoint && isVisionModel(selectedModel); + // Fetch models useEffect(() => { fetch("/v1/models") @@ -120,7 +202,6 @@ export default function PlaygroundPage() { const modelList = (data?.data || []) as ModelInfo[]; setModels(modelList); - // Extract unique providers from model ids (provider/model format) const providerSet = new Set(); modelList.forEach((m) => { const parts = m.id.split("/"); @@ -135,12 +216,10 @@ export default function PlaygroundPage() { .catch(() => {}); }, []); - // Filter models by selected provider const filteredModels = models .filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/")) .map((m) => ({ value: m.id, label: m.id })); - // Helper to generate default body for a given endpoint and model const generateDefaultBody = (endpoint: string, model: string) => { const template = { ...DEFAULT_BODIES[endpoint] }; if ("model" in template) { @@ -149,7 +228,6 @@ export default function PlaygroundPage() { return JSON.stringify(template, null, 2); }; - // When provider changes, auto-select first model and reset body const handleProviderChange = (newProvider: string) => { setSelectedProvider(newProvider); const providerModels = models @@ -158,63 +236,122 @@ export default function PlaygroundPage() { const firstModel = providerModels[0] || ""; setSelectedModel(firstModel); setRequestBody(generateDefaultBody(selectedEndpoint, firstModel)); - setResponseBody(""); - setResponseStatus(null); - setResponseDuration(null); + clearResults(); }; - // When model changes, update body const handleModelChange = (newModel: string) => { setSelectedModel(newModel); setRequestBody(generateDefaultBody(selectedEndpoint, newModel)); - setResponseBody(""); - setResponseStatus(null); - setResponseDuration(null); + clearResults(); }; - // When endpoint changes, update body const handleEndpointChange = (newEndpoint: string) => { setSelectedEndpoint(newEndpoint); setRequestBody(generateDefaultBody(newEndpoint, selectedModel)); - setResponseBody(""); - setResponseStatus(null); - setResponseDuration(null); + setUploadedFile(null); + setUploadedImages([]); + clearResults(); }; - const handleSend = useCallback(async () => { - if (!requestBody.trim()) return; - setLoading(true); + const clearResults = () => { setResponseBody(""); - setAudioUrl(null); setResponseStatus(null); setResponseDuration(null); + setAudioUrl(null); + setImageData(null); + setTranscriptionText(null); + }; + + /** Handle audio file select for transcription endpoint */ + const handleAudioFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] ?? null; + setUploadedFile(file); + }; + + /** Handle image file select for vision models */ + const handleImageFileChange = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + const base64s = await Promise.all(files.map(fileToBase64)); + setUploadedImages((prev) => [...prev, ...base64s].slice(0, 4)); // max 4 images + }; + + /** Inject uploaded images into chat messages body */ + const buildChatBodyWithImages = (parsed: any, imageBase64s: string[]): any => { + if (!imageBase64s.length) return parsed; + const messages = [...(parsed.messages || [])]; + if (messages.length === 0) return parsed; + const lastMsg = messages[messages.length - 1]; + const currentContent = typeof lastMsg.content === "string" ? lastMsg.content : ""; + messages[messages.length - 1] = { + ...lastMsg, + content: [ + { type: "text", text: currentContent }, + ...imageBase64s.map((b64) => ({ + type: "image_url", + image_url: { url: b64 }, + })), + ], + }; + return { ...parsed, messages }; + }; + + const handleSend = async () => { + if (!requestBody.trim() && !isTranscriptionEndpoint) return; + setLoading(true); + clearResults(); const controller = new AbortController(); abortRef.current = controller; const startTime = Date.now(); try { - const parsed = JSON.parse(requestBody); const path = ENDPOINT_PATHS[selectedEndpoint]; - const res = await fetch(path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(parsed), - signal: controller.signal, - }); + let res: Response; + + if (isTranscriptionEndpoint) { + // Multipart form-data for transcription + const form = new FormData(); + if (uploadedFile) { + form.append("file", uploadedFile); + } + // Parse extra params from JSON editor + try { + const extra = JSON.parse(requestBody || "{}"); + for (const [k, v] of Object.entries(extra)) { + if (k !== "file") form.append(k, String(v)); + } + } catch { + /* ignore parse errors */ + } + res = await fetch(`/api${path}`, { + method: "POST", + body: form, + signal: controller.signal, + }); + } else { + let parsed = JSON.parse(requestBody); + // Inject vision images if available + if (supportsVision && uploadedImages.length > 0) { + parsed = buildChatBodyWithImages(parsed, uploadedImages); + } + res = await fetch(`/api${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(parsed), + signal: controller.signal, + }); + } setResponseStatus(res.status); setResponseDuration(Date.now() - startTime); const contentType = res.headers.get("content-type") || ""; if (contentType.startsWith("audio/")) { - // TTS binary response — create a Blob URL and show inline audio player const blob = await res.blob(); const url = URL.createObjectURL(blob); setAudioUrl(url); setResponseBody(`// Audio response (${contentType})\n// Click play below to listen.`); } else if (contentType.includes("text/event-stream")) { - // Handle streaming const reader = res.body?.getReader(); const decoder = new TextDecoder(); let accumulated = ""; @@ -229,6 +366,14 @@ export default function PlaygroundPage() { } else { const data = await res.json(); setResponseBody(JSON.stringify(data, null, 2)); + // Detect image generation result → render inline + if (isImageEndpoint && data?.data && Array.isArray(data.data) && res.ok) { + setImageData(data); + } + // Detect transcription result → render plain text + if (isTranscriptionEndpoint && typeof data?.text === "string") { + setTranscriptionText(data.text || "(empty result — check provider credentials)"); + } } } catch (err: any) { if (err.name === "AbortError") { @@ -239,7 +384,7 @@ export default function PlaygroundPage() { setResponseDuration(Date.now() - startTime); } setLoading(false); - }, [requestBody, selectedEndpoint]); + }; const handleCancel = () => { if (abortRef.current) { @@ -323,7 +468,10 @@ export default function PlaygroundPage() { @@ -332,6 +480,98 @@ export default function PlaygroundPage() {
+ {/* File Upload Zone — shown for transcription and vision models */} + {(isTranscriptionEndpoint || supportsVision) && ( + +
+
+ + attach_file + +

+ {isTranscriptionEndpoint ? "Audio File" : "Attach Images (Vision)"} +

+ {isTranscriptionEndpoint && ( + + multipart/form-data + + )} + {supportsVision && ( + + up to 4 images + + )} +
+ {isTranscriptionEndpoint && ( +
+ + {uploadedFile && ( +

+ + check_circle + + {uploadedFile.name} ({(uploadedFile.size / 1024).toFixed(0)} KB) +

+ )} + {!uploadedFile && ( +

+ info + Select an audio file to transcribe (mp3, wav, m4a, ogg, flac…) +

+ )} +
+ )} + {supportsVision && ( +
+ + {uploadedImages.length > 0 && ( +
+ {uploadedImages.map((src, i) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Attached + +
+ ))} + +
+ )} +
+ )} +
+
+ )} + {/* Split Editor View */}
{/* Request Panel */} @@ -368,6 +608,15 @@ export default function PlaygroundPage() {
+ {isTranscriptionEndpoint && ( +

+ + info + + Transcription uses multipart/form-data. Upload the audio file above — JSON below + controls extra params (model, language). +

+ )}
+ ) : imageData ? ( + + ) : transcriptionText !== null ? ( +
+

+ Transcription +

+
+ {transcriptionText} +
+ +
) : ( (null); + const notify = useNotificationStore(); + const handleRefreshToken = async (connectionId: string) => { + if (refreshingId) return; + setRefreshingId(connectionId); + try { + const res = await fetch(`/api/providers/${connectionId}/refresh`, { method: "POST" }); + const data = await res.json().catch(() => ({})); + if (res.ok && data.success) { + notify.success(t("tokenRefreshed")); + await fetchConnections(); + } else { + notify.error(data.error || t("tokenRefreshFailed")); + } + } catch (error) { + console.error("Error refreshing token:", error); + notify.error(t("tokenRefreshFailed")); + } finally { + setRefreshingId(null); + } + }; + const handleSwapPriority = async (conn1, conn2) => { if (!conn1 || !conn2) return; try { @@ -926,6 +949,8 @@ export default function ProviderDetailPage() { }} onDelete={() => handleDelete(conn.id)} onReauth={isOAuth ? () => setShowOAuthModal(true) : undefined} + onRefreshToken={isOAuth ? () => handleRefreshToken(conn.id) : undefined} + isRefreshing={refreshingId === conn.id} onProxy={() => setProxyTarget({ level: "key", @@ -2165,6 +2190,8 @@ function ConnectionRow({ hasProxy, proxySource, proxyHost, + onRefreshToken, + isRefreshing, }) { const t = useTranslations("providers"); const displayName = isOAuth @@ -2173,6 +2200,24 @@ function ConnectionRow({ // Use useState + useEffect for impure Date.now() to avoid calling during render const [isCooldown, setIsCooldown] = useState(false); + // T12: token expiry status — lazy init avoids calling Date.now() during render; + // updates every 30s via interval only (no sync setState in effect body). + const getTokenMinsLeft = () => { + if (!isOAuth || !connection.expiresAt) return null; + const expiresMs = new Date(connection.expiresAt).getTime(); + return Math.floor((expiresMs - Date.now()) / 60000); + }; + const [tokenMinsLeft, setTokenMinsLeft] = useState(getTokenMinsLeft); + + useEffect(() => { + if (!isOAuth || !connection.expiresAt) return; + const update = () => { + const expiresMs = new Date(connection.expiresAt).getTime(); + setTokenMinsLeft(Math.floor((expiresMs - Date.now()) / 60000)); + }; + const iv = setInterval(update, 30000); + return () => clearInterval(iv); + }, [isOAuth, connection.expiresAt]); useEffect(() => { const checkCooldown = () => { @@ -2229,6 +2274,25 @@ function ConnectionRow({ {statusPresentation.statusLabel} + {/* T12: Token expiry status indicator (state-driven, no Date.now in render) */} + {tokenMinsLeft !== null && + (tokenMinsLeft < 0 ? ( + + error + expired + + ) : tokenMinsLeft < 30 ? ( + + warning + {`~${tokenMinsLeft}m`} + + ) : null)} {isCooldown && connection.isActive !== false && ( )} @@ -2313,6 +2377,21 @@ function ConnectionRow({ > {t("retest")} + {/* T12: Manual token refresh for OAuth accounts */} + {onRefreshToken && ( + + )} }) { + try { + const { id } = await params; + + const connection = await getProviderConnectionById(id); + if (!connection) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + + if (connection.authType !== "oauth") { + return NextResponse.json( + { error: "Only OAuth connections support manual token refresh" }, + { status: 400 } + ); + } + + if (!connection.refreshToken && !connection.accessToken) { + return NextResponse.json( + { error: "No token credentials available for refresh" }, + { status: 422 } + ); + } + + const provider = connection.provider as string; + const credentials = { + connectionId: id, + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + expiresAt: connection.expiresAt, + expiresIn: connection.expiresIn, + idToken: connection.idToken, + providerSpecificData: connection.providerSpecificData, + }; + + // Use the existing getAccessToken helper which knows how to refresh + // tokens for each provider type (Claude, GitHub, Gemini, etc.) + const newCredentials = await getAccessToken(provider, credentials); + + if (!newCredentials?.accessToken) { + return NextResponse.json( + { error: "Token refresh failed — provider returned no new token" }, + { status: 502 } + ); + } + + // Persist new credentials to DB + await updateProviderCredentials(id, newCredentials); + + const expiresAt = newCredentials.expiresIn + ? new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString() + : null; + + return NextResponse.json({ + success: true, + connectionId: id, + provider, + expiresAt, + refreshedAt: new Date().toISOString(), + }); + } catch (error) { + console.error("[T12] Token refresh failed:", error); + return NextResponse.json( + { error: "Token refresh failed", details: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/settings/task-routing/route.ts b/src/app/api/settings/task-routing/route.ts new file mode 100644 index 0000000000..c8d346f434 --- /dev/null +++ b/src/app/api/settings/task-routing/route.ts @@ -0,0 +1,90 @@ +import { NextResponse } from "next/server"; +import { + getTaskRoutingConfig, + setTaskRoutingConfig, + resetTaskRoutingStats, + getDefaultTaskModelMap, +} from "@omniroute/open-sse/services/taskAwareRouter.ts"; +import { updateSettings } from "@/lib/db/settings"; + +/** + * GET /api/settings/task-routing + * Returns the current task-aware routing configuration. + */ +export async function GET() { + try { + return NextResponse.json({ + ...getTaskRoutingConfig(), + defaultTaskModelMap: getDefaultTaskModelMap(), + }); + } catch (error) { + console.error("[API ERROR] /api/settings/task-routing GET:", error); + return NextResponse.json({ error: "Failed to get config" }, { status: 500 }); + } +} + +/** + * PUT /api/settings/task-routing + * Update the task-aware routing configuration. + * Body: { enabled?: boolean, taskModelMap?: { coding?: "...", ... }, detectionEnabled?: boolean } + */ +export async function PUT(request: Request) { + let rawBody: Record; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + } + + try { + setTaskRoutingConfig(rawBody as any); + + // Persist to database (excluding stats) + const { stats, ...persistable } = getTaskRoutingConfig(); + await updateSettings({ taskRouting: JSON.stringify(persistable) }); + + return NextResponse.json({ success: true, ...getTaskRoutingConfig() }); + } catch (error) { + console.error("[API ERROR] /api/settings/task-routing PUT:", error); + return NextResponse.json({ error: "Failed to update config" }, { status: 500 }); + } +} + +/** + * POST /api/settings/task-routing + * Actions: { action: "reset-stats" | "detect" } + * For "detect": pass { action: "detect", body: } to test detection + */ +export async function POST(request: Request) { + let rawBody: any; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: { message: "Invalid JSON body" } }, { status: 400 }); + } + + try { + if (rawBody.action === "reset-stats") { + resetTaskRoutingStats(); + return NextResponse.json({ + success: true, + stats: getTaskRoutingConfig().stats, + }); + } + + if (rawBody.action === "detect") { + const { detectTaskType } = await import("@omniroute/open-sse/services/taskAwareRouter.ts"); + const taskType = detectTaskType(rawBody.body || {}); + const config = getTaskRoutingConfig(); + return NextResponse.json({ + taskType, + preferredModel: config.taskModelMap[taskType] || "(no override)", + }); + } + + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); + } catch (error) { + console.error("[API ERROR] /api/settings/task-routing POST:", error); + return NextResponse.json({ error: "Failed to execute action" }, { status: 500 }); + } +} diff --git a/src/app/landing/components/GetStarted.tsx b/src/app/landing/components/GetStarted.tsx index 55b3d6dcc8..2fa01f4195 100644 --- a/src/app/landing/components/GetStarted.tsx +++ b/src/app/landing/components/GetStarted.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; import { useTranslations } from "next-intl"; +import { copyToClipboard } from "@/shared/utils/clipboard"; export default function GetStarted() { const t = useTranslations("landing"); @@ -10,8 +11,8 @@ export default function GetStarted() { const dashboardUrl = `${endpoint}/dashboard`; const command = "npx omniroute"; - const handleCopy = (text: string) => { - navigator.clipboard.writeText(text); + const handleCopy = async (text: string) => { + await copyToClipboard(text); setCopied(true); setTimeout(() => setCopied(false), 2000); }; diff --git a/src/domain/quotaCache.ts b/src/domain/quotaCache.ts index ca33547657..4111e202de 100644 --- a/src/domain/quotaCache.ts +++ b/src/domain/quotaCache.ts @@ -32,6 +32,7 @@ interface QuotaCacheEntry { fetchedAt: number; exhausted: boolean; nextResetAt: string | null; + windowDurationMs?: number | null; // T08: optional rolling window duration } // ─── Constants ────────────────────────────────────────────────────────────── @@ -56,6 +57,33 @@ function isExhausted(quotas: Record): boolean { return entries.every((q) => q.remainingPercentage <= 0); } +/** + * T08 — Auto-advance quota window. + * If we know the window duration, advance past the expired window(s) to + * avoid blocking requests when the quota reset already happened but the + * background refresh hasn't run yet. + */ +function advancedWindowResetAt(entry: QuotaCacheEntry, now: number): { exhausted: false } | null { + if (!entry.nextResetAt) return null; + + const resetMs = parseDate(entry.nextResetAt); + if (resetMs === null) return null; + + // If the window's resetAt is in the past, the quota has been renewed. + // Eagerly mark as available so requests don't wait for the 5-min TTL. + if (resetMs <= now) { + return { exhausted: false }; + } + + // If we know the window duration, check if the *next* window also passed. + if (entry.windowDurationMs && entry.windowDurationMs > 0) { + const elapsed = now - resetMs; + if (elapsed >= 0) return { exhausted: false }; + } + + return null; +} + function parseDate(value: string): number | null { const ms = new Date(value).getTime(); return Number.isNaN(ms) ? null : ms; @@ -128,14 +156,20 @@ export function isAccountQuotaExhausted(connectionId: string): boolean { if (!entry) return false; if (!entry.exhausted) return false; - // If resetAt has passed, assume available until refresh confirms - if (entry.nextResetAt) { - const resetMs = parseDate(entry.nextResetAt); - if (resetMs !== null && resetMs <= Date.now()) return false; + const now = Date.now(); + + // T08 — Auto window advance: if resetAt is in the past, eagerly treat as not exhausted. + // This prevents stale exhaustion blocking when background refresh hasn't run yet. + const advanced = advancedWindowResetAt(entry, now); + if (advanced) { + // Optimistically clear the exhausted flag so we unblock requests immediately. + // The next background refresh will update with the real quota state. + entry.exhausted = false; + return false; } // Exhausted entries without resetAt expire after fixed TTL - const age = Date.now() - entry.fetchedAt; + const age = now - entry.fetchedAt; if (!entry.nextResetAt && age > EXHAUSTED_TTL_MS) return false; return true; diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 0f93720970..352f602bb1 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1397,7 +1397,9 @@ "editCompatibleTitle": "Edit {type} Compatible", "compatibleBaseUrlHint": "Use the base URL (ending in /v1) for your {type}-compatible API.", "apiKeyForCheck": "API Key (for Check)", - "compatibleProdPlaceholder": "{type} Compatible (Prod)" + "compatibleProdPlaceholder": "{type} Compatible (Prod)", + "tokenRefreshed": "Token refreshed successfully", + "tokenRefreshFailed": "Token refresh failed" }, "settings": { "title": "Settings", diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index b5e7831a25..da62a41d97 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -6,6 +6,7 @@ import { v4 as uuidv4 } from "uuid"; import { getDbInstance, rowToCamel, cleanNulls } from "./core"; import { backupDbFile } from "./backup"; import { encryptConnectionFields, decryptConnectionFields } from "./encryption"; +import { invalidateDbCache } from "./readCache"; type JsonRecord = Record; @@ -200,6 +201,7 @@ export async function createProviderConnection(data: JsonRecord) { _reorderConnections(db, providerId); } backupDbFile("pre-write"); + invalidateDbCache("connections"); // Bust connections read cache return cleanNulls(connection); } @@ -344,6 +346,7 @@ export async function updateProviderConnection(id: string, data: JsonRecord) { const merged = { ...rowToCamel(existing), ...data, updatedAt: new Date().toISOString() }; _updateConnectionRow(db, id, encryptConnectionFields({ ...merged })); backupDbFile("pre-write"); + invalidateDbCache("connections"); // Bust connections read cache if (data.priority !== undefined) { const existingRecord = toRecord(existing); @@ -370,6 +373,7 @@ export async function deleteProviderConnection(id: string) { : String(existingRecord.provider || ""); _reorderConnections(db, providerId); backupDbFile("pre-write"); + invalidateDbCache("connections"); // Bust connections read cache return true; } diff --git a/src/lib/db/readCache.ts b/src/lib/db/readCache.ts new file mode 100644 index 0000000000..762bde0807 --- /dev/null +++ b/src/lib/db/readCache.ts @@ -0,0 +1,118 @@ +/** + * DB Read Cache — In-memory TTL cache for hot read paths. + * + * SQLite reads are already fast since better-sqlite3 is synchronous and + * memory-mapped. However, some functions (getSettings, getPricing, + * getProviderConnections) are called on every request by multiple callers. + * A short TTL cache (5s) eliminates redundant I/O without staling data for + * long enough to matter (settings changes are applied within one cache cycle). + * + * Usage: + * import { dbCache } from '@/lib/db/readCache'; + * const settings = await dbCache.getSettings(); + */ + +type CacheEntry = { + value: T; + expiresAt: number; +}; + +class TTLCache { + private cache = new Map>(); + private readonly ttlMs: number; + + constructor(ttlMs: number) { + this.ttlMs = ttlMs; + } + + get(key: string): T | undefined { + const entry = this.cache.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return undefined; + } + return entry.value; + } + + set(key: string, value: T): void { + this.cache.set(key, { value, expiresAt: Date.now() + this.ttlMs }); + } + + invalidate(key?: string): void { + if (key) { + this.cache.delete(key); + } else { + this.cache.clear(); + } + } +} + +// Cache with 5s TTL — short enough to pick up dashboard changes quickly, +// long enough to serve burst request bursts without hammering SQLite. +const SETTINGS_TTL_MS = 5_000; +const PRICING_TTL_MS = 30_000; +const CONNECTIONS_TTL_MS = 5_000; + +const settingsCache = new TTLCache>(SETTINGS_TTL_MS); +const pricingCache = new TTLCache>(PRICING_TTL_MS); +const connectionsCache = new TTLCache(CONNECTIONS_TTL_MS); + +/** + * Cached wrapper for getSettings. + * Invalidated on every updateSettings() call. + */ +export async function getCachedSettings(): Promise> { + const cached = settingsCache.get("settings"); + if (cached) return cached; + + const { getSettings } = await import("@/lib/db/settings"); + const value = await getSettings(); + settingsCache.set("settings", value); + return value; +} + +/** + * Cached wrapper for getPricing. + * Longer TTL since pricing rarely changes mid-session. + */ +export async function getCachedPricing(): Promise> { + const cached = pricingCache.get("pricing"); + if (cached) return cached as Record; + + const { getPricing } = await import("@/lib/db/settings"); + const value = await getPricing(); + pricingCache.set("pricing", value); + return value; +} + +/** + * Cached wrapper for getProviderConnections. + * Used in request hot-paths (usageStats, callLogs, usageHistory). + */ +export async function getCachedProviderConnections( + filter?: Record +): Promise { + // Only cache the unfiltered "all connections" query (most common) + if (filter && Object.keys(filter).length > 0) { + const { getProviderConnections } = await import("@/lib/db/providers"); + return getProviderConnections(filter); + } + + const cached = connectionsCache.get("all"); + if (cached) return cached; + + const { getProviderConnections } = await import("@/lib/db/providers"); + const value = await getProviderConnections(); + connectionsCache.set("all", value); + return value; +} + +/** + * Invalidate all caches (call after writes to any of: settings, pricing, connections). + */ +export function invalidateDbCache(scope?: "settings" | "pricing" | "connections"): void { + if (!scope || scope === "settings") settingsCache.invalidate(); + if (!scope || scope === "pricing") pricingCache.invalidate(); + if (!scope || scope === "connections") connectionsCache.invalidate(); +} diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index f84b105c23..fca130eb13 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -5,6 +5,7 @@ import { getDbInstance } from "./core"; import { backupDbFile } from "./backup"; import { PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels.ts"; +import { invalidateDbCache } from "./readCache"; type JsonRecord = Record; type PricingModels = Record; @@ -80,6 +81,7 @@ export async function updateSettings(updates: Record) { }); tx(); backupDbFile("pre-write"); + invalidateDbCache("settings"); // Bust the read cache immediately return getSettings(); } @@ -169,7 +171,7 @@ export async function updatePricing(pricingData: PricingByProvider) { }); tx(); backupDbFile("pre-write"); - + invalidateDbCache("pricing"); // Bust the pricing read cache const updated: PricingByProvider = {}; const allRows = db.prepare("SELECT key, value FROM key_value WHERE namespace = 'pricing'").all(); for (const row of allRows) { diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 83aac1d6fc..4d096427ec 100644 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -95,3 +95,11 @@ export { listDbBackups, restoreDbBackup, } from "./db/backup"; + +export { + // Read Cache (cached wrappers for hot read paths) + getCachedSettings, + getCachedPricing, + getCachedProviderConnections, + invalidateDbCache, +} from "./db/readCache"; diff --git a/src/shared/components/ConsoleLogViewer.tsx b/src/shared/components/ConsoleLogViewer.tsx index 2d7d51cd91..eb53c00300 100644 --- a/src/shared/components/ConsoleLogViewer.tsx +++ b/src/shared/components/ConsoleLogViewer.tsx @@ -11,6 +11,7 @@ import { useTranslations } from "next-intl"; */ import { useState, useEffect, useRef, useCallback } from "react"; +import { copyToClipboard } from "@/shared/utils/clipboard"; interface LogEntry { timestamp: string; @@ -89,12 +90,17 @@ export default function ConsoleLogViewer() { } }, [logs, autoScroll]); - const handleCopy = (entry: LogEntry, idx: number) => { + const handleCopy = async (entry: LogEntry, idx: number) => { const text = JSON.stringify(entry, null, 2); - navigator.clipboard.writeText(text).then(() => { - setCopiedIdx(idx); - setTimeout(() => setCopiedIdx(null), 2000); - }); + const success = await copyToClipboard(text); + if (!success) { + setError("Failed to copy log entry"); + return; + } + + setError(null); + setCopiedIdx(idx); + setTimeout(() => setCopiedIdx(null), 2000); }; const formatTime = (ts: string) => { diff --git a/src/shared/components/ModelSelectModal.tsx b/src/shared/components/ModelSelectModal.tsx index 164914a622..95bf930435 100644 --- a/src/shared/components/ModelSelectModal.tsx +++ b/src/shared/components/ModelSelectModal.tsx @@ -27,6 +27,7 @@ export default function ModelSelectModal({ activeProviders = [], title = "Select Model", modelAliases = {}, + addedModelValues = [], }) { const [searchQuery, setSearchQuery] = useState(""); const [combos, setCombos] = useState([]); @@ -330,6 +331,7 @@ export default function ModelSelectModal({
{group.models.map((model) => { const isSelected = selectedModel === model.value; + const isAdded = addedModelValues.includes(model.value); return ( @@ -375,4 +380,5 @@ ModelSelectModal.propTypes = { ), title: PropTypes.string, modelAliases: PropTypes.object, + addedModelValues: PropTypes.arrayOf(PropTypes.string), }; diff --git a/src/shared/components/RequestLoggerV2.tsx b/src/shared/components/RequestLoggerV2.tsx index 41977b5fd6..0b970dc126 100644 --- a/src/shared/components/RequestLoggerV2.tsx +++ b/src/shared/components/RequestLoggerV2.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import Card from "./Card"; import RequestLoggerDetail from "./RequestLoggerDetail"; +import { copyToClipboard } from "@/shared/utils/clipboard"; import { PROTOCOL_COLORS, PROVIDER_COLORS, @@ -230,30 +231,8 @@ export default function RequestLoggerV2() { setDetailData(null); }; - // Copy to clipboard - const copyToClipboard = async (text) => { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - // Fallback for non-HTTPS or older browsers - try { - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.style.position = "fixed"; - textarea.style.left = "-9999px"; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand("copy"); - document.body.removeChild(textarea); - return true; - } catch { - return false; - } - } - }; - // Unique accounts and providers for dropdowns + const uniqueAccounts = [...new Set(logs.map((l) => l.account).filter((a) => a && a !== "-"))]; const uniqueModels = [...new Set(logs.map((l) => l.model).filter(Boolean))].sort(); const uniqueProviders = [ diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index b85e947b10..dd6ca4dcb8 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -349,6 +349,27 @@ export const APIKEY_PROVIDERS = { textIcon: "CF", website: "https://github.com/comfyanonymous/ComfyUI", }, + huggingface: { + id: "huggingface", + alias: "hf", + name: "HuggingFace", + icon: "face", + color: "#FFD21E", + textIcon: "HF", + website: "https://huggingface.co", + hasFree: true, + freeNote: "Free Inference API for thousands of models (Whisper, VITS, SDXL…)", + }, + vertex: { + id: "vertex", + alias: "vertex", + name: "Vertex AI", + icon: "cloud", + color: "#4285F4", + textIcon: "VA", + website: "https://cloud.google.com/vertex-ai", + authHint: "Provide Service Account JSON or OAuth access_token", + }, }; export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-"; diff --git a/src/shared/hooks/useCopyToClipboard.ts b/src/shared/hooks/useCopyToClipboard.ts index 8eec6ae654..a363e7054a 100644 --- a/src/shared/hooks/useCopyToClipboard.ts +++ b/src/shared/hooks/useCopyToClipboard.ts @@ -1,58 +1,35 @@ "use client"; import { useState, useCallback, useRef } from "react"; +import { copyToClipboard } from "@/shared/utils/clipboard"; /** - * Fallback copy using legacy execCommand (works on HTTP) - */ -function fallbackCopy(text) { - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.style.position = "fixed"; - textarea.style.left = "-9999px"; - textarea.style.top = "-9999px"; - textarea.style.opacity = "0"; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - try { - document.execCommand("copy"); - } catch { - // ignore - } - document.body.removeChild(textarea); -} - -/** - * Hook for copy to clipboard with feedback + * Hook for copy to clipboard with feedback. + * Uses shared copyToClipboard utility that works on both HTTP and HTTPS. * @param {number} resetDelay - Time in ms before resetting copied state (default: 2000) - * @returns {{ copied: string|null, copy: (text: string, id?: string) => void }} + * @returns {{ copied: string|null, copy: (text: string, id?: string) => Promise }} */ export function useCopyToClipboard(resetDelay = 2000) { - const [copied, setCopied] = useState(null); - const timeoutRef = useRef(null); + const [copied, setCopied] = useState(null); + const timeoutRef = useRef | null>(null); const copy = useCallback( - async (text, id = "default") => { - try { - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(text); - } else { - fallbackCopy(text); + async (text: string, id = "default"): Promise => { + const success = await copyToClipboard(text); + + if (success) { + setCopied(id); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); } - } catch { - fallbackCopy(text); + + timeoutRef.current = setTimeout(() => { + setCopied(null); + }, resetDelay); } - setCopied(id); - - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = setTimeout(() => { - setCopied(null); - }, resetDelay); + return success; }, [resetDelay] ); diff --git a/src/shared/utils/clipboard.ts b/src/shared/utils/clipboard.ts new file mode 100644 index 0000000000..ee6d014dfb --- /dev/null +++ b/src/shared/utils/clipboard.ts @@ -0,0 +1,52 @@ +/** + * Clipboard utility with HTTP/HTTPS fallback. + * navigator.clipboard.writeText() requires HTTPS (secure context). + * For HTTP deployments, falls back to execCommand('copy'). + */ + +/** + * Copy text to clipboard with automatic HTTPS/HTTP fallback. + * Works in both secure (HTTPS) and non-secure (HTTP) contexts. + * @param text - Text to copy to clipboard + * @returns true if copy succeeded, false otherwise + */ +export async function copyToClipboard(text: string): Promise { + // Method 1: Clipboard API (requires HTTPS / secure context) + if ( + typeof navigator !== "undefined" && + navigator.clipboard && + typeof window !== "undefined" && + window.isSecureContext + ) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fall through to execCommand fallback + } + } + + // Method 2: Legacy execCommand fallback (works on HTTP) + if (typeof document !== "undefined" && document.body) { + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.style.cssText = "position:fixed;top:0;left:-9999px;opacity:0;pointer-events:none;"; + let appended = false; + + try { + document.body.appendChild(textArea); + appended = true; + textArea.focus(); + textArea.select(); + return document.execCommand("copy"); + } catch { + return false; + } finally { + if (appended && document.body.contains(textArea)) { + document.body.removeChild(textArea); + } + } + } + + return false; +} diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 2c1a28f7f4..742bf107de 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -42,6 +42,10 @@ import { generateRequestId } from "../../shared/utils/requestId"; import { recordCost } from "../../domain/costRules"; import { logAuditEvent } from "../../lib/compliance/index"; import { enforceApiKeyPolicy } from "../../shared/utils/apiKeyPolicy"; +import { + applyTaskAwareRouting, + getTaskRoutingConfig, +} from "@omniroute/open-sse/services/taskAwareRouter.ts"; /** * Handle chat completion request @@ -77,6 +81,24 @@ export async function handleChat(request: any, clientRawRequest: any = null) { } telemetry.endPhase(); + // T01 — Accept header negotiation + // If client asks for text/event-stream via the Accept header AND the JSON body + // does not explicitly set stream=false, treat it as stream=true. + // This ensures compatibility with curl/httpx and similar non-OpenAI clients. + // + // FIX #302: OpenAI Python SDK sends Accept: application/json, text/event-stream + // in every request — even when called with stream=False. We must NOT override + // an explicit stream=false body field, as that silently breaks tool_calls and + // structured completions for SDK users who rely on non-streaming mode. + const acceptHeader = request.headers.get("accept") || ""; + if (acceptHeader.includes("text/event-stream") && body.stream === undefined) { + body = { ...body, stream: true }; + log.debug( + "STREAM", + "Accept: text/event-stream header → overriding stream=true (body had no stream field)" + ); + } + // Build clientRawRequest for logging (if not provided) if (!clientRawRequest) { const url = new URL(request.url); @@ -141,9 +163,30 @@ export async function handleChat(request: any, clientRawRequest: any = null) { const apiKeyInfo = policy.apiKeyInfo; telemetry.endPhase(); + // T05 — Task-Aware Smart Routing + // Detect the semantic task type and optionally route to the optimal model + let resolvedModelStr = modelStr; + let taskRouteInfo: { taskType: string; wasRouted: boolean } | null = null; + if (getTaskRoutingConfig().enabled) { + telemetry.startPhase("task-route"); + const tr = applyTaskAwareRouting(modelStr, body); + if (tr.wasRouted) { + resolvedModelStr = tr.model; + body = { ...body, model: tr.model }; + log.info( + "T05", + `Task-Aware: detected="${tr.taskType}" → model override: ${modelStr} → ${tr.model}` + ); + } else if (tr.taskType !== "chat") { + log.debug("T05", `Task-Aware: detected="${tr.taskType}" (no override configured)`); + } + taskRouteInfo = { taskType: tr.taskType, wasRouted: tr.wasRouted }; + telemetry.endPhase(); + } + // Check if model is a combo (has multiple models with fallback) telemetry.startPhase("resolve"); - const combo = await getCombo(modelStr); + const combo = await getCombo(resolvedModelStr); if (combo) { log.info( "CHAT",