diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 0bbe2ffbc0..8618d5033c 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -500,6 +500,12 @@ export const REGISTRY: Record = { clientVersion: "1.1.3", models: [ { id: "default", name: "Auto (Server Picks)" }, + { id: "claude-4.6-opus-high-thinking", name: "Claude 4.6 Opus High Thinking" }, + { id: "claude-4.6-opus-high", name: "Claude 4.6 Opus High" }, + { id: "claude-4.6-sonnet-high-thinking", name: "Claude 4.6 Sonnet High Thinking" }, + { id: "claude-4.6-sonnet-high", name: "Claude 4.6 Sonnet High" }, + { id: "claude-4.6-haiku", name: "Claude 4.6 Haiku" }, + { id: "claude-4.6-opus", name: "Claude 4.6 Opus" }, { id: "claude-4.5-opus-high-thinking", name: "Claude 4.5 Opus High Thinking" }, { id: "claude-4.5-opus-high", name: "Claude 4.5 Opus High" }, { id: "claude-4.5-sonnet-thinking", name: "Claude 4.5 Sonnet Thinking" }, diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index c9bf705d0f..a9ce1eeeec 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -45,6 +45,80 @@ const DEFAULT_MODEL_P95_MS = { }; const MIN_HISTORY_SAMPLES = 10; +/** + * Validate that a successful (HTTP 200) non-streaming response actually contains + * meaningful content. Returns { valid: true } or { valid: false, reason }. + * + * Only inspects non-streaming JSON responses — streaming responses are passed through + * because buffering the full stream would defeat the purpose of streaming. + * + * Checks: + * 1. Body is valid JSON + * 2. Has at least one choice with non-empty content or tool_calls + */ +async function validateResponseQuality( + response: Response, + isStreaming: boolean, + log: { warn?: (...args: any[]) => void } +): Promise<{ valid: boolean; reason?: string; clonedResponse?: Response }> { + if (isStreaming) return { valid: true }; + + const contentType = response.headers.get("content-type") || ""; + if (!contentType.includes("application/json") && !contentType.includes("text/")) { + return { valid: true }; + } + + let cloned: Response; + try { + cloned = response.clone(); + } catch { + return { valid: true }; + } + + let text: string; + try { + text = await cloned.text(); + } catch { + return { valid: true }; + } + + if (!text || text.trim().length === 0) { + return { valid: false, reason: "empty response body" }; + } + + let json: any; + try { + json = JSON.parse(text); + } catch { + if (text.startsWith("data:")) return { valid: true }; + return { valid: false, reason: "response is not valid JSON" }; + } + + const choices = json?.choices; + if (!Array.isArray(choices) || choices.length === 0) { + if (json?.output || json?.result || json?.data || json?.response) return { valid: true }; + if (json?.error) return { valid: false, reason: `upstream error in 200 body: ${json.error?.message || JSON.stringify(json.error).substring(0, 200)}` }; + return { valid: true }; + } + + const firstChoice = choices[0]; + const message = firstChoice?.message || firstChoice?.delta; + if (!message) { + return { valid: false, reason: "choice has no message object" }; + } + + const content = message.content; + const toolCalls = message.tool_calls; + const hasContent = content !== null && content !== undefined && content !== ""; + const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0; + + if (!hasContent && !hasToolCalls) { + return { valid: false, reason: "empty content and no tool_calls in response" }; + } + + return { valid: true }; +} + // In-memory atomic counter per combo for round-robin distribution // Resets on server restart (by design — no stale state) const rrCounters = new Map(); @@ -872,14 +946,31 @@ export async function handleComboChat({ const result = await handleSingleModelWrapped(body, modelStr); - // Success — return response + // Success — validate response quality before returning if (result.ok) { + const quality = await validateResponseQuality(result, !!body.stream, log); + if (!quality.valid) { + log.warn( + "COMBO", + `Model ${modelStr} returned 200 but failed quality check: ${quality.reason}` + ); + breaker._onFailure(); + recordComboRequest(combo.name, modelStr, { + success: false, + latencyMs: Date.now() - startTime, + fallbackCount, + strategy, + }); + if (i > 0) fallbackCount++; + break; // move to next model + } resolvedByModel = modelStr; const latencyMs = Date.now() - startTime; log.info( "COMBO", `Model ${modelStr} succeeded (${latencyMs}ms, ${fallbackCount} fallbacks)` ); + breaker._onSuccess(); recordComboRequest(combo.name, modelStr, { success: true, latencyMs, @@ -1139,13 +1230,30 @@ async function handleRoundRobinCombo({ const result = await handleSingleModel(body, modelStr); - // Success + // Success — validate response quality before returning if (result.ok) { + const quality = await validateResponseQuality(result, !!body.stream, log); + if (!quality.valid) { + log.warn( + "COMBO-RR", + `${modelStr} returned 200 but failed quality check: ${quality.reason}` + ); + breaker._onFailure(); + recordComboRequest(combo.name, modelStr, { + success: false, + latencyMs: Date.now() - startTime, + fallbackCount, + strategy: "round-robin", + }); + if (offset > 0) fallbackCount++; + break; // move to next model + } const latencyMs = Date.now() - startTime; log.info( "COMBO-RR", `${modelStr} succeeded (${latencyMs}ms, ${fallbackCount} fallbacks)` ); + breaker._onSuccess(); recordComboRequest(combo.name, modelStr, { success: true, latencyMs, diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 5a5c71d3bf..952b50276e 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -186,6 +186,9 @@ const COMBO_TEMPLATE_FALLBACK = { freeStackTitle: "Free Stack ($0)", freeStackDesc: "Round-robin across all free providers: Kiro, iFlow, Qwen, Gemini CLI. Zero cost, never stops.", + paidPremiumTitle: "Paid Premium", + paidPremiumDesc: + "Round-robin across paid subscriptions: Cursor, Antigravity. Top-tier models, distributed load.", }; const COMBO_TEMPLATES = [ @@ -250,6 +253,21 @@ const COMBO_TEMPLATES = [ healthCheckEnabled: true, }, }, + { + id: "paid-premium", + icon: "workspace_premium", + titleKey: "templatePaidPremium", + descKey: "templatePaidPremiumDesc", + fallbackTitle: COMBO_TEMPLATE_FALLBACK.paidPremiumTitle, + fallbackDesc: COMBO_TEMPLATE_FALLBACK.paidPremiumDesc, + strategy: "round-robin", + suggestedName: "paid-premium", + config: { + maxRetries: 2, + retryDelayMs: 1000, + healthCheckEnabled: true, + }, + }, ]; function getStrategyMeta(strategy) { @@ -1425,18 +1443,27 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { { model: "kr/claude-sonnet-4.5", weight: 0 }, { model: "if/kimi-k2-thinking", weight: 0 }, { model: "if/qwen3-coder-plus", weight: 0 }, - { model: "qw/qwen3-coder-plus", weight: 0 }, + { model: "if/deepseek-v3.2", weight: 0 }, { model: "nvidia/llama-3.3-70b-instruct", weight: 0 }, { model: "groq/llama-3.3-70b-versatile", weight: 0 }, ]; + const PAID_PREMIUM_PRESET_MODELS = [ + { model: "cu/claude-4.6-opus-high", weight: 0 }, + { model: "ag/claude-sonnet-4-6", weight: 0 }, + { model: "cu/claude-4.6-sonnet-high", weight: 0 }, + { model: "ag/gpt-5", weight: 0 }, + { model: "ag/gemini-3.1-pro-preview", weight: 0 }, + ]; + const applyTemplate = (template) => { setStrategy(template.strategy); setConfig((prev) => ({ ...prev, ...template.config })); if (!name.trim()) setName(template.suggestedName); - // Pre-fill Free Stack with 7 real free provider models if (template.id === "free-stack") { setModels(FREE_STACK_PRESET_MODELS); + } else if (template.id === "paid-premium") { + setModels(PAID_PREMIUM_PRESET_MODELS); } };