diff --git a/next.config.mjs b/next.config.mjs index 3853b4b0..e71a530a 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -28,6 +28,8 @@ const nextConfig = { experimental: { // #1529/#1572: LLM clients can send long context or base64 image payloads through /v1 rewrites. proxyClientMaxBodySize, + // Cache fetch responses across HMR refreshes for faster dev reloads. + serverComponentsHmrCache: true, }, webpack: (config, { isServer }) => { // Ignore fs/path modules in browser bundle diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index fe9fcbb5..5f04ec23 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -158,7 +158,8 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*deepseek-v4*", caps: { reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 } }, { pattern: "*reasoner*", caps: { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 128000 } }, { pattern: "*deepseek-r*", caps: { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 128000 } }, - { pattern: "*deepseek*", caps: { contextWindow: 128000 } }, + { pattern: "*deepseek-chat*", caps: { contextWindow: 128000 } }, + { pattern: "*deepseek*", caps: { reasoning: true, thinkingFormat: "deepseek", contextWindow: 128000 } }, // ── MiniMax (M3 = adaptive; M2.x cannot disable) ───────────────── { pattern: "*minimax*image*", caps: { imageOutput: true } }, diff --git a/open-sse/providers/registry/gemini-cli.js b/open-sse/providers/registry/gemini-cli.js index 8af51690..3e4a94c2 100644 --- a/open-sse/providers/registry/gemini-cli.js +++ b/open-sse/providers/registry/gemini-cli.js @@ -31,8 +31,13 @@ export default { clientSecret: "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", }, models: [ - { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, + { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, { id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" }, + { id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" }, + { id: "gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" }, + { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" }, + { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" }, ], oauth: { authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", diff --git a/open-sse/providers/registry/github.js b/open-sse/providers/registry/github.js index 9251c5c2..95169eb3 100644 --- a/open-sse/providers/registry/github.js +++ b/open-sse/providers/registry/github.js @@ -41,12 +41,6 @@ export default { }, }, models: [ - { id: "gpt-3.5-turbo", name: "GPT-3.5 Turbo" }, - { id: "gpt-4", name: "GPT-4" }, - { id: "gpt-4o", name: "GPT-4o" }, - { id: "gpt-4o-mini", name: "GPT-4o mini" }, - { id: "gpt-4.1", name: "GPT-4.1" }, - { id: "gpt-5-mini", name: "GPT-5 Mini" }, { id: "gpt-5.2", name: "GPT-5.2" }, { id: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, @@ -54,7 +48,6 @@ export default { { id: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, - { id: "claude-sonnet-4", name: "Claude Sonnet 4" }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, { id: "claude-opus-4.6", name: "Claude Opus 4.6" }, diff --git a/open-sse/providers/registry/glm-cn.js b/open-sse/providers/registry/glm-cn.js index c19b4fed..90a71b4c 100644 --- a/open-sse/providers/registry/glm-cn.js +++ b/open-sse/providers/registry/glm-cn.js @@ -21,6 +21,7 @@ export default { }, }, models: [ + { id: "glm-5.2", name: "GLM 5.2" }, { id: "glm-5.1", name: "GLM 5.1" }, { id: "glm-5", name: "GLM 5" }, { id: "glm-4.7", name: "GLM-4.7" }, diff --git a/open-sse/providers/registry/glm.js b/open-sse/providers/registry/glm.js index fa003ccf..ceeb8796 100644 --- a/open-sse/providers/registry/glm.js +++ b/open-sse/providers/registry/glm.js @@ -33,6 +33,7 @@ export default { }, }, models: [ + { id: "glm-5.2", name: "GLM 5.2" }, { id: "glm-5.1", name: "GLM 5.1" }, { id: "glm-5", name: "GLM 5" }, { id: "glm-4.7", name: "GLM 4.7" }, diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index deaa0dc1..2719d7c2 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -39,15 +39,16 @@ export function reorderByCapabilities(models, required) { */ const comboRotationState = new Map(); -// Last array item whose role is "user" (current turn), or the last item when no -// role is present. History media (older turns) must not pin the combo to a vision -// model — those get stripped + placeholdered downstream instead. -function lastUserItem(arr) { - if (!Array.isArray(arr) || arr.length === 0) return null; - for (let i = arr.length - 1; i >= 0; i--) { - if (!arr[i]?.role || arr[i].role === "user") return arr[i]; - } - return arr[arr.length - 1]; +// Trailing run of items after the last assistant/model turn = the current user +// turn. It may span several messages (e.g. text + image split across blocks), +// so we return all of them. History media (older turns) must not pin the combo +// to a vision model — those get stripped + placeholdered downstream instead. +function trailingUserItems(arr) { + if (!Array.isArray(arr) || arr.length === 0) return []; + const isAssistant = (r) => r === "assistant" || r === "model"; + let i = arr.length - 1; + while (i >= 0 && !isAssistant(arr[i]?.role)) i--; + return arr.slice(i + 1); } // Detect which capabilities a request needs. Modalities (vision/pdf) are scanned @@ -72,14 +73,11 @@ export function detectRequiredCapabilities(body) { if (Array.isArray(content)) for (const b of content) scanBlock(b); }; - // Modalities: current user turn only (last item across each known shape). - const lastMsg = lastUserItem(body.messages); // openai / claude - if (lastMsg) scanContent(lastMsg.content); - const lastInput = lastUserItem(body.input); // responses - if (lastInput) scanContent(lastInput.content); - const contents = body.contents || body.request?.contents; // gemini / antigravity - const lastContent = lastUserItem(contents); - if (lastContent) scanContent(lastContent.parts); + // Modalities: current user turn only (trailing user run across each known shape). + for (const m of trailingUserItems(body.messages)) scanContent(m.content); // openai / claude + for (const it of trailingUserItems(body.input)) scanContent(it.content); // responses + const contents = body.contents || body.request?.contents; // gemini / antigravity + for (const c of trailingUserItems(contents)) scanContent(c.parts); // search: temporarily disabled in auto-switch (feature not wired yet). diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js index 1f110a2f..0ed27608 100644 --- a/open-sse/translator/concerns/thinkingUnified.js +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -40,6 +40,15 @@ export function parseSuffix(model) { export function extractThinking(body) { if (!body || typeof body !== "object") return null; + // Claude output_config.effort (explicit) — priority over adaptive thinking + const oc = body.output_config?.effort; + if (typeof oc === "string" && oc) { + const e = oc.toLowerCase(); + if (e === "none" || e === "off") return { mode: "none" }; + if (e === "auto") return { mode: "auto" }; + return { mode: "level", level: e }; + } + // Claude shape const t = body.thinking; if (t && typeof t === "object") { @@ -154,7 +163,7 @@ function applyFormat(fmt, body, cfg, caps) { case "openai": { if (none && canDisable) { body.reasoning_effort = "none"; break; } const level = toLevel(eff); - if (level) body.reasoning_effort = level; + if (level) body.reasoning_effort = level === "xhigh" || level === "max" ? "high" : level; break; } case "claude-adaptive": { diff --git a/open-sse/translator/formats/claude.js b/open-sse/translator/formats/claude.js index 24c6a5fe..1dac5c33 100644 --- a/open-sse/translator/formats/claude.js +++ b/open-sse/translator/formats/claude.js @@ -5,6 +5,8 @@ import { adjustMaxTokens } from "./maxTokens.js"; import { applyCloaking } from "../../utils/claudeCloaking.js"; import { resolveSessionId } from "../../utils/sessionManager.js"; import { PROVIDERS } from "../../providers/index.js"; +import { getCapabilitiesForModel } from "../../providers/capabilities.js"; +import { DEFAULT_MAX_TOKENS } from "../../config/runtimeConfig.js"; // Check if message has valid non-empty content export function hasValidContent(msg) { @@ -130,12 +132,18 @@ export function normalizeClaudePassthrough(body, model = "") { // - Add thinking block for Anthropic endpoint (provider === "claude") // - Fix tool_use/tool_result ordering // - Apply cloaking (billing header + fake user ID) for OAuth tokens -export function prepareClaudeRequest(body, provider = null, apiKey = null, connectionId = null) { +export function prepareClaudeRequest(body, provider = null, apiKey = null, connectionId = null, rawHeaders = null, sessionId = null) { // quirk: MiniMax's Claude-compatible endpoint rejects Anthropic's output_config (400 invalid params) if (PROVIDERS[provider]?.quirks?.dropOutputConfig) { delete body.output_config; } + // Clamp max_tokens to the model output ceiling (never above DEFAULT_MAX_TOKENS) + if (body.max_tokens) { + const ceiling = Math.min(getCapabilitiesForModel(provider, body.model).maxOutput, DEFAULT_MAX_TOKENS); + if (body.max_tokens > ceiling) body.max_tokens = ceiling; + } + // 1. System: remove all cache_control, add only to last block with ttl 1h if (body.system && Array.isArray(body.system)) { body.system = body.system.map((block, i) => { @@ -252,8 +260,8 @@ export function prepareClaudeRequest(body, provider = null, apiKey = null, conne // Apply cloaking for OAuth tokens (billing header + fake user ID) // session_id in user_id must match X-Claude-Code-Session-Id for fingerprint consistency if ((provider === "claude" || provider?.startsWith("anthropic-compatible")) && apiKey) { - const sessionId = resolveSessionId({ body, connectionId, scope: "claude" }); - body = applyCloaking(body, apiKey, sessionId); + const sid = sessionId || resolveSessionId({ headers: rawHeaders, body, connectionId, scope: "claude" }); + body = applyCloaking(body, apiKey, sid); } return body; diff --git a/open-sse/translator/formats/maxTokens.js b/open-sse/translator/formats/maxTokens.js index 294d9c55..0e5b36f2 100644 --- a/open-sse/translator/formats/maxTokens.js +++ b/open-sse/translator/formats/maxTokens.js @@ -8,7 +8,7 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/runtimeConf export function adjustMaxTokens(body) { let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS; - // Auto-increase for tool calling to prevent truncated arguments + // Auto-increase for tool calling to prevent truncated arguments (min never above max) if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) { if (maxTokens < DEFAULT_MIN_TOKENS) { maxTokens = DEFAULT_MIN_TOKENS; @@ -22,6 +22,9 @@ export function adjustMaxTokens(body) { maxTokens = body.thinking.budget_tokens + 1024; } + // Never exceed the global ceiling + if (maxTokens > DEFAULT_MAX_TOKENS) maxTokens = DEFAULT_MAX_TOKENS; + return maxTokens; } diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index fa42b139..5c9065d2 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -5,6 +5,7 @@ import { cloakClaudeTools } from "../utils/claudeCloaking.js"; import { filterToOpenAIFormat } from "./formats/openai.js"; import { normalizeThinkingConfig } from "../services/provider.js"; import { applyThinking, captureThinking } from "./concerns/thinkingUnified.js"; +import { captureSessionId } from "../utils/sessionManager.js"; import { AntigravityExecutor } from "../executors/antigravity.js"; import { PROVIDERS } from "../providers/index.js"; @@ -68,6 +69,11 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream // format conversion strips/renames the fields. Applied after translation. const thinkingIntent = captureThinking(result); + // Capture session id from the original body (envelope still intact, e.g. antigravity request.sessionId) + const clientSessionId = captureSessionId(result, credentials, connectionId, targetFormat); + // Expose to downstream translators (gemini-cli/antigravity envelopes) that run after envelope is stripped + if (credentials) credentials._clientSessionId = clientSessionId; + // If same format, skip translation steps if (sourceFormat !== targetFormat) { // Step 1: source -> openai (if source is not openai) @@ -101,7 +107,7 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream // Final step: prepare request for Claude format endpoints if (targetFormat === FORMATS.CLAUDE) { const apiKey = credentials?.accessToken || credentials?.apiKey || null; - result = prepareClaudeRequest(result, provider, apiKey, connectionId); + result = prepareClaudeRequest(result, provider, apiKey, connectionId, credentials?.rawHeaders, clientSessionId); } // Claude cloaking: rename client tools with _cc suffix (anti-ban) diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js index 90db3afe..fe181631 100644 --- a/open-sse/translator/request/openai-to-gemini.js +++ b/open-sse/translator/request/openai-to-gemini.js @@ -17,7 +17,7 @@ import { generateProjectId, cleanJSONSchemaForAntigravity } from "../formats/gemini.js"; -import { deriveSessionId } from "../../utils/sessionManager.js"; +import { deriveSessionId, toNumericSessionId } from "../../utils/sessionManager.js"; import { ROLE, GEMINI_ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js"; // Sanitize function names for Gemini API. @@ -259,7 +259,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra userAgent: isAntigravity ? "antigravity" : "gemini-cli", requestId: isAntigravity ? `agent-${generateUUID()}` : generateRequestId(), request: { - sessionId: isAntigravity ? deriveSessionId(credentials?.email || credentials?.connectionId) : generateSessionId(), + sessionId: toNumericSessionId(credentials?._clientSessionId) || (isAntigravity ? deriveSessionId(credentials?.email || credentials?.connectionId) : generateSessionId()), contents: geminiCLI.contents, systemInstruction: geminiCLI.systemInstruction, generationConfig: geminiCLI.generationConfig, @@ -309,7 +309,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu requestId: `agent-${generateUUID()}`, requestType: "agent", request: { - sessionId: deriveSessionId(credentials?.email || credentials?.connectionId), + sessionId: toNumericSessionId(credentials?._clientSessionId) || deriveSessionId(credentials?.email || credentials?.connectionId), contents: [], generationConfig: { temperature: claudeRequest.temperature || 1, diff --git a/open-sse/utils/claudeCloaking.js b/open-sse/utils/claudeCloaking.js index 204a7cfa..46a44e4f 100644 --- a/open-sse/utils/claudeCloaking.js +++ b/open-sse/utils/claudeCloaking.js @@ -13,11 +13,18 @@ function generateBillingHeader(payload) { return `x-anthropic-billing-header: cc_version=${CLAUDE_VERSION}.${buildHash}; cc_entrypoint=${CC_ENTRYPOINT}; cch=${cch};`; } +// Derive a deterministic UUID-v4-shaped string from a seed (stable per account) +function deriveUuid(seed) { + const h = createHash("sha256").update(seed).digest("hex"); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${((parseInt(h[16], 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}-${h.slice(20, 32)}`; +} + // Generate fake user ID in Claude Code 2.1.92+ JSON format: // {"device_id":"<64hex>","account_uuid":"","session_id":""} -function generateFakeUserID(sessionId) { - const deviceId = randomBytes(32).toString("hex"); - const accountUuid = randomUUID(); +// device_id/account_uuid derive from apiKey (stable per account), session_id per-conversation +function generateFakeUserID(sessionId, apiKey) { + const deviceId = apiKey ? createHash("sha256").update(`device:${apiKey}`).digest("hex") : randomBytes(32).toString("hex"); + const accountUuid = apiKey ? deriveUuid(`account:${apiKey}`) : randomUUID(); const sessionUuid = sessionId || randomUUID(); return `{"device_id":"${deviceId}","account_uuid":"${accountUuid}","session_id":"${sessionUuid}"}`; } @@ -151,7 +158,7 @@ export function applyCloaking(body, apiKey, sessionId) { // Inject fake user ID into metadata (session_id must match X-Claude-Code-Session-Id) const existingUserId = result.metadata?.user_id; if (!existingUserId) { - result.metadata = { ...result.metadata, user_id: generateFakeUserID(sessionId) }; + result.metadata = { ...result.metadata, user_id: generateFakeUserID(sessionId, apiKey) }; } return result; diff --git a/open-sse/utils/sessionManager.js b/open-sse/utils/sessionManager.js index d8663bf5..73af1a6b 100644 --- a/open-sse/utils/sessionManager.js +++ b/open-sse/utils/sessionManager.js @@ -89,7 +89,7 @@ const ASSISTANT_CAP_LEN = 200; const MAX_ASSISTANT_SESSIONS = 5000; // Client headers/body fields that carry an upstream session id (priority order) -const SESSION_HEADER_KEYS = ["x-session-id", "session_id", "x-amp-thread-id", "x-client-request-id"]; +const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id", "x-client-request-id"]; const CLAUDE_CODE_SESSION_RE = /_session_([a-f0-9-]+)$/; function sha16(text) { @@ -122,9 +122,20 @@ function headerValue(headers, key) { } // Read client-provided session id from headers/body (no generation) +// Antigravity envelope carries session in request.sessionId; requestId embeds conversation uuid +const ANTIGRAVITY_CONV_RE = /^[a-z]+\/([0-9a-f-]{36})\//i; +function extractAntigravitySession(body) { + const sid = body?.request?.sessionId; + if (sid != null && sid !== "") return normalizeSessionId(String(sid)); + const m = typeof body?.requestId === "string" ? body.requestId.match(ANTIGRAVITY_CONV_RE) : null; + return m ? normalizeSessionId(m[1]) : null; +} + function extractClientSessionId(headers, body) { const claude = extractClaudeCodeSession(body?.metadata?.user_id); if (claude) return `claude:${claude}`; + const antigravity = extractAntigravitySession(body); + if (antigravity) return `antigravity:${antigravity}`; for (const key of SESSION_HEADER_KEYS) { const v = headerValue(headers, key); if (v) return v; @@ -194,6 +205,22 @@ export function resolveSessionId({ headers, body, connectionId, workspaceId, sco return deriveSessionId(connectionId); } +// Capture session id from request body + credentials (envelope still intact here) +export function captureSessionId(body, credentials, connectionId, scope = "") { + return resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId, scope }); +} + +// Convert any session id to Antigravity numeric format "-" (matches real AG / CLIProxyAPI). +// Already-numeric ids (native AG sessionId) pass through unchanged. +export function toNumericSessionId(sessionId) { + const v = normalizeSessionId(sessionId); + if (!v) return null; + if (/^-?\d+$/.test(v)) return v; + const h = crypto.createHash("sha256").update(v).digest(); + const n = h.readBigUInt64BE(0) & 0x7fffffffffffffffn; + return `-${n.toString()}`; +} + // Cleanup expired assistant-session entries const assistantCleanup = setInterval(() => { const now = Date.now(); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index c06fbaa5..ac1e808b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -48,7 +48,7 @@ export default function ProviderDetailPage() { const [headerImgError, setHeaderImgError] = useState(false); const [modelTestResults, setModelTestResults] = useState({}); const [modelsTestError, setModelsTestError] = useState(""); - const [testingModelId, setTestingModelId] = useState(null); + const [testingModelIds, setTestingModelIds] = useState(() => new Set()); const [showAddCustomModel, setShowAddCustomModel] = useState(false); const [selectedConnectionIds, setSelectedConnectionIds] = useState([]); const [bulkProxyPoolId, setBulkProxyPoolId] = useState("__none__"); @@ -876,8 +876,8 @@ export default function ProviderDetailPage() { ); const handleTestModel = async (modelId) => { - if (testingModelId) return; - setTestingModelId(modelId); + if (testingModelIds.has(modelId)) return; + setTestingModelIds((prev) => new Set(prev).add(modelId)); try { const res = await fetch("/api/models/test", { method: "POST", @@ -891,7 +891,7 @@ export default function ProviderDetailPage() { setModelTestResults((prev) => ({ ...prev, [modelId]: "error" })); setModelsTestError("Network error"); } finally { - setTestingModelId(null); + setTestingModelIds((prev) => { const n = new Set(prev); n.delete(modelId); return n; }); } }; @@ -952,7 +952,7 @@ export default function ProviderDetailPage() { onDeleteAlias={() => handleDeleteAlias(model.alias)} testStatus={modelTestResults[model.id]} onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined} - isTesting={testingModelId === model.id} + isTesting={testingModelIds.has(model.id)} isCustom isFree={false} caps={getCaps(`${providerId}/${model.id}`)} @@ -977,7 +977,7 @@ export default function ProviderDetailPage() { onDeleteAlias={() => handleDeleteAlias(existingAlias)} testStatus={modelTestResults[model.id]} onTest={connections.length > 0 || isFreeNoAuth ? () => handleTestModel(model.id) : undefined} - isTesting={testingModelId === model.id} + isTesting={testingModelIds.has(model.id)} isFree={model.isFree} onDisable={() => handleDisableModel(model.id)} caps={getCaps(`${providerId}/${model.id}`)} diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 53581ccc..e911ac90 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -281,15 +281,18 @@ export default function ProvidersPage() { Object.entries(OAUTH_PROVIDERS).filter(([, info]) => !info.hidden && matchSearch(info.name)), "oauth", ); - const freeEntries = Object.entries(FREE_PROVIDERS).filter( - ([, info]) => !info.hidden && matchSearch(info.name), - ); + const freeEntries = Object.entries(FREE_PROVIDERS) + .filter(([, info]) => !info.hidden && matchSearch(info.name)) + .sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); const freeTierEntries = sortByPriority( Object.entries(FREE_TIER_PROVIDERS).filter( - ([, info]) => !info.hidden && matchSearch(info.name), + ([, info]) => + !info.hidden && + matchSearch(info.name) && + (info.serviceKinds ?? ["llm"]).includes("llm"), ), "freeTier", - ); + ).sort(([, a], [, b]) => (b.noAuth ? 1 : 0) - (a.noAuth ? 1 : 0)); // API Key: connected providers first, then alphabetical by name const apikeyEntries = Object.entries(APIKEY_PROVIDERS) .filter( diff --git a/src/shared/hooks/useModelCaps.js b/src/shared/hooks/useModelCaps.js index db16bdda..3dbdcdd4 100644 --- a/src/shared/hooks/useModelCaps.js +++ b/src/shared/hooks/useModelCaps.js @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js"; // Fetch model capabilities once and expose a lookup by fullModel ("provider/model") or bare model id. export function useModelCaps() { @@ -32,7 +33,11 @@ export function useModelCaps() { if (!key) return null; if (byFull[key]) return byFull[key]; const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key; - return byId[bare] || null; + if (byId[bare]) return byId[bare]; + // Fallback: compute caps for dynamic models (passthrough/custom/suggested) not in static list + const provider = key.includes("/") ? key.slice(0, key.indexOf("/")) : null; + const c = getCapabilitiesForModel(provider, bare); + return { vision: c.vision, search: c.search, reasoning: c.reasoning }; }; return { getCaps };