diff --git a/CHANGELOG.md b/CHANGELOG.md index d829a062b0..e9f47f54b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ --- +## [3.7.5] — 2026-04-29 + +### ✨ New Features + +- **feat(tunnels):** integrate native ngrok tunnel support with dashboard UI parity (#1753) + +### 🐛 Bug Fixes + +- **fix(models):** apply blocked providers filter to non-chat catalog models (image, embedding, audio, etc.) (#1752) +- **fix(antigravity):** stabilize streaming payload parsing and deduplicate usage/model metadata refreshes (#1748) + +--- + ## [3.7.4] — 2026-04-28 ### ✨ New Features diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ccf1626744..40a309b584 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.7.4 + version: 3.7.5 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/electron/package.json b/electron/package.json index 7a023263f8..cb06672579 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.7.4", + "version": "3.7.5", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/llm.txt b/llm.txt index 99ba91f792..5903d37c52 100644 --- a/llm.txt +++ b/llm.txt @@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo **Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost. -**Current version:** 3.7.4 +**Current version:** 3.7.5 ## Tech Stack @@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo └── .env.example # Environment variable template ``` -## Key Features (v3.7.4) +## Key Features (v3.7.5) ### Core Proxy - **160+ AI providers** with automatic format translation diff --git a/next.config.mjs b/next.config.mjs index b1e835a4ae..79348ec773 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -67,6 +67,7 @@ const nextConfig = { "tls-client-node", "koffi", "tough-cookie", + "@ngrok/ngrok", "child_process", "fs", "path", @@ -100,6 +101,12 @@ const nextConfig = { contextRegExp: /thread-stream/, }) ); + + // Mark @ngrok/ngrok as external to prevent webpack from trying to bundle its .node binaries + config.externals = config.externals || []; + config.externals.push({ + "@ngrok/ngrok": "commonjs @ngrok/ngrok", + }); // ── Turbopack / Next.js 16 module-hash patch (#394, #396, #398) ──────── // // Next.js 16 (with or without Turbopack) compiles the instrumentation hook diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index d68401e0c1..416ebf894d 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -228,7 +228,7 @@ const CHAT_OPENAI_COMPAT_MODELS: Record = { codestral: buildModels(["codestral-2405", "codestral-latest"]), upstage: buildModels(["solar-pro", "solar-mini", "solar-docvision", "solar-embedding-1-large"]), maritalk: buildModels(["sabia-3", "sabia-3-small"]), - "xiaomi-mimo": buildModels(["mimo-v2-pro", "mimo-v2-omni", "mimo-v2-tts"]), + "xiaomi-mimo": buildModels(["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni", "mimo-v2-flash"]), "inference-net": buildModels([ "meta-llama/Llama-3.3-70B-Instruct", "deepseek-ai/DeepSeek-R1", diff --git a/open-sse/config/toolCloaking.ts b/open-sse/config/toolCloaking.ts index 7fa5cd3b35..429a6ca28b 100644 --- a/open-sse/config/toolCloaking.ts +++ b/open-sse/config/toolCloaking.ts @@ -27,7 +27,7 @@ const AG_DEFAULT_TOOL_NAMES = [ const AG_DECOY_TOOL_NAMES = [ ...AG_DEFAULT_TOOL_NAMES, - "mcp_sequential-thinking_sequentialthinking", + "mcp_sequential_thinking_sequentialthinking", ] as const; export const AG_DEFAULT_TOOLS = new Set(AG_DEFAULT_TOOL_NAMES); diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 9cf0cb989d..9a6f23b479 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -28,15 +28,29 @@ const CREDITS_EXHAUSTED_TTL_MS = 5 * 60 * 60 * 1000; // 5 hours const BARE_PRO_IDS = new Set(["gemini-3.1-pro"]); +function cloneAntigravityRequestBody(body: unknown): unknown { + if (!body || typeof body !== "object") { + return body; + } + + try { + return structuredClone(body); + } catch { + return JSON.parse(JSON.stringify(body)); + } +} + function serializeAntigravityRequest( provider: string, headers: Record, body: unknown ): { headers: Record; bodyString: string } { + const serializedBody = cloneAntigravityRequestBody(body); + if (!isCliCompatEnabled(provider)) { - return { headers, bodyString: JSON.stringify(body) }; + return { headers, bodyString: JSON.stringify(serializedBody) }; } - return applyFingerprint(provider, { ...headers }, body); + return applyFingerprint(provider, { ...headers }, serializedBody); } type AntigravityCollectedStream = { @@ -585,7 +599,11 @@ export class AntigravityExecutor extends BaseExecutor { } try { - const serializedRequest = serializeAntigravityRequest(this.provider, headers, transformedBody); + const serializedRequest = serializeAntigravityRequest( + this.provider, + headers, + transformedBody + ); const finalHeaders = serializedRequest.headers; const response = await fetch(url, { diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 42b3049a7f..2e0a99f156 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -12,6 +12,7 @@ import { import { PROVIDERS } from "../config/constants.ts"; import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; +import { getRememberedResponseFunctionCalls } from "../services/responsesToolCallState.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; import { CORS_HEADERS } from "../utils/cors.ts"; import { createRequire } from "module"; @@ -359,16 +360,62 @@ function convertSystemToDeveloperRole(body: Record): void { * 3. Strips the "id" field from any object in input whose id matches a * server-generated prefix (rs_, fc_, resp_, msg_) — so the content is * preserved but the backend won't try to look it up - * 4. Always deletes previous_response_id (endpoint doesn't persist responses) + * 4. Rehydrates missing function_call items for stateful tool-output follow-ups + * using locally remembered response state, then deletes previous_response_id */ function stripStoredItemReferences(body: Record): void { const hasInput = Array.isArray(body.input) && body.input.length > 0; + const inputItems = Array.isArray(body.input) ? body.input : []; + const previousResponseId = + typeof body.previous_response_id === "string" ? body.previous_response_id : ""; + const inputFunctionCallIds = new Set(); + const inputFunctionCallOutputIds = new Set(); - // Always strip previous_response_id IF we have input. - // The /codex/responses endpoint does not persist responses, so any reference - // to a previous response would cause a 404. However, if input is missing (e.g. Cursor - // trying to continue generation), stripping it leaves the payload empty causing a 400 Schema error. - // We leave it intact so Codex returns 404, which correctly triggers Cursor's fallback to resend history. + for (const item of inputItems) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const record = item as Record; + const type = typeof record.type === "string" ? record.type : ""; + const callId = typeof record.call_id === "string" ? record.call_id : ""; + if (!callId) continue; + if (type === "function_call") { + inputFunctionCallIds.add(callId); + continue; + } + if (type === "function_call_output") { + inputFunctionCallOutputIds.add(callId); + } + } + + const missingFunctionCallIds = [...inputFunctionCallOutputIds].filter( + (callId) => !inputFunctionCallIds.has(callId) + ); + + if (hasInput && previousResponseId && missingFunctionCallIds.length > 0) { + const rememberedFunctionCalls = getRememberedResponseFunctionCalls(previousResponseId); + const injectedFunctionCalls = rememberedFunctionCalls + .filter((functionCall) => missingFunctionCallIds.includes(functionCall.call_id)) + .filter((functionCall) => !inputFunctionCallIds.has(functionCall.call_id)) + .map((functionCall) => ({ + type: "function_call", + call_id: functionCall.call_id, + name: functionCall.name, + arguments: functionCall.arguments, + })); + + if (injectedFunctionCalls.length > 0) { + body.input = [...injectedFunctionCalls, ...inputItems]; + for (const functionCall of injectedFunctionCalls) { + inputFunctionCallIds.add(functionCall.call_id); + } + } + } + + // Strip previous_response_id whenever the request already carries input items. + // Codex rejects this field outright, so stateful follow-up turns must be made + // self-contained via the local function_call replay above. + // + // If input is missing entirely (e.g. Cursor trying to continue generation), keep + // previous_response_id so upstream can decide whether to fall back. if (hasInput) { delete body.previous_response_id; } @@ -1133,6 +1180,11 @@ export class CodexExecutor extends BaseExecutor { // whether the request came via native passthrough or translation. delete body.max_tokens; delete body.max_output_tokens; + // VS Code Copilot BYOK Responses requests include `truncation` (for example + // "auto" or "disabled"). The Codex /responses backend currently rejects this + // field entirely with 400 Unsupported parameter: truncation, so strip it for + // both native passthrough and translated requests. + delete body.truncation; delete body.background; // Droid CLI sends this but Codex Responses API rejects it // Inject prompt_cache_key for Codex prompt caching. diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 09740e64be..876ee51db1 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2162,67 +2162,78 @@ export async function handleChatCore({ accountSemaphoreKey && accountSemaphoreMaxConcurrency != null ? await acquireAccountSemaphore(accountSemaphoreKey, { maxConcurrency: accountSemaphoreMaxConcurrency, + signal: streamController.signal, }) : () => {}; try { - const rawResult = await withRateLimit(provider, connectionId, modelToCall, async () => { - let attempts = 0; - const maxAttempts = provider === "qwen" ? 3 : 1; + const rawResult = await withRateLimit( + provider, + connectionId, + modelToCall, + async () => { + let attempts = 0; + const maxAttempts = provider === "qwen" ? 3 : 1; - while (attempts < maxAttempts) { - const res = await executor.execute({ - model: modelToCall, - body: bodyToSend, - stream: upstreamStream, - credentials: executionCredentials, - signal: streamController.signal, - log, - extendedContext, - upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall), - clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent), - onCredentialsRefreshed, - }); + while (attempts < maxAttempts) { + const res = await executor.execute({ + model: modelToCall, + body: bodyToSend, + stream: upstreamStream, + credentials: executionCredentials, + signal: streamController.signal, + log, + extendedContext, + upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall), + clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent), + onCredentialsRefreshed, + }); - // Qwen 429 strict quota backoff (wait 1.5s, 3s and retry) - if (provider === "qwen" && res.response.status === 429 && attempts < maxAttempts - 1) { - const bodyPeek = await res.response - .clone() - .text() - .catch(() => ""); - if (bodyPeek.toLowerCase().includes("exceeded your current quota")) { - const delay = 1500 * (attempts + 1); - log?.warn?.("QWEN_RETRY", `Quota 429 hit. Retrying in ${delay}ms...`); - await new Promise((r) => setTimeout(r, delay)); - attempts++; - continue; - } - } - - // For streaming: release the semaphore when the client drains or cancels the stream. - if (stream) { - const originalBody = res.response.body; - if (!originalBody) { - acquireAccountSemaphoreRelease(); - return res; + // Qwen 429 strict quota backoff (wait 1.5s, 3s and retry) + if ( + provider === "qwen" && + res.response.status === 429 && + attempts < maxAttempts - 1 + ) { + const bodyPeek = await res.response + .clone() + .text() + .catch(() => ""); + if (bodyPeek.toLowerCase().includes("exceeded your current quota")) { + const delay = 1500 * (attempts + 1); + log?.warn?.("QWEN_RETRY", `Quota 429 hit. Retrying in ${delay}ms...`); + await new Promise((r) => setTimeout(r, delay)); + attempts++; + continue; + } } - return { - ...res, - response: new Response( - wrapReadableStreamWithFinalize(originalBody, acquireAccountSemaphoreRelease), - { - status: res.response.status, - statusText: res.response.statusText, - headers: res.response.headers, - } - ), - }; - } + // For streaming: release the semaphore when the client drains or cancels the stream. + if (stream) { + const originalBody = res.response.body; + if (!originalBody) { + acquireAccountSemaphoreRelease(); + return res; + } - return res; - } - }); + return { + ...res, + response: new Response( + wrapReadableStreamWithFinalize(originalBody, acquireAccountSemaphoreRelease), + { + status: res.response.status, + statusText: res.response.statusText, + headers: res.response.headers, + } + ), + }; + } + + return res; + } + }, + streamController.signal + ); if (stream) { return rawResult; @@ -3531,7 +3542,8 @@ export async function handleChatCore({ body, onStreamComplete, apiKeyInfo, - handleStreamFailure + handleStreamFailure, + clientResponseFormat ); } diff --git a/open-sse/mcp-server/__tests__/dbHealthTool.test.ts b/open-sse/mcp-server/__tests__/dbHealthTool.test.ts index 9aa7565ca4..850074a7c5 100644 --- a/open-sse/mcp-server/__tests__/dbHealthTool.test.ts +++ b/open-sse/mcp-server/__tests__/dbHealthTool.test.ts @@ -7,15 +7,30 @@ import { MCP_TOOL_MAP, dbHealthCheckInput } from "../schemas/tools.ts"; const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); +const mockRunManagedDbHealthCheck = vi.hoisted(() => vi.fn()); + vi.mock("../audit.ts", () => ({ logToolCall: vi.fn().mockResolvedValue(undefined), })); +vi.mock("../../../src/lib/db/core.ts", () => ({ + runManagedDbHealthCheck: mockRunManagedDbHealthCheck, +})); + describe("omniroute_db_health_check MCP tool", () => { let client: Client; beforeEach(async () => { mockFetch.mockReset(); + mockRunManagedDbHealthCheck.mockReset(); + mockRunManagedDbHealthCheck.mockReturnValue({ + isHealthy: false, + issues: [{ type: "broken_reference", table: "combos", description: "broken", count: 1 }], + repairedCount: 1, + backupCreated: true, + autoRepair: true, + checkedAt: new Date().toISOString(), + }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const server = createMcpServer(); await server.connect(serverTransport); @@ -38,32 +53,19 @@ describe("omniroute_db_health_check MCP tool", () => { expect(dbHealthCheckInput.safeParse({ autoRepair: "yes" }).success).toBe(false); }); - it("dispatches to /api/v1/db/health using POST when autoRepair=true", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - isHealthy: false, - issues: [{ type: "broken_reference", table: "combos", description: "broken", count: 1 }], - repairedCount: 1, - backupCreated: true, - autoRepair: true, - checkedAt: new Date().toISOString(), - }), - }); - + it("runs the database repair flow directly when autoRepair=true", async () => { const result = await client.callTool({ name: "omniroute_db_health_check", arguments: { autoRepair: true }, }); expect(result.isError).toBeFalsy(); - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining("/api/v1/db/health"), - expect.objectContaining({ method: "POST" }) - ); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockRunManagedDbHealthCheck).toHaveBeenCalledWith({ autoRepair: true }); const content = result.content[0] as { type: string; text: string }; const payload = JSON.parse(content.text); + expect(payload.autoRepair).toBe(true); expect(payload.repairedCount).toBe(1); expect(payload.backupCreated).toBe(true); }); diff --git a/open-sse/mcp-server/schemas/tools.ts b/open-sse/mcp-server/schemas/tools.ts index 85492cde97..047d877f79 100644 --- a/open-sse/mcp-server/schemas/tools.ts +++ b/open-sse/mcp-server/schemas/tools.ts @@ -882,7 +882,7 @@ export const dbHealthCheckTool: McpToolDefinition< scopes: ["read:health", "write:resilience"], auditLevel: "full", phase: 2, - sourceEndpoints: ["/api/v1/db/health"], + sourceEndpoints: ["/api/db/health"], }; // --- Tool 19: omniroute_sync_pricing --- diff --git a/open-sse/mcp-server/tools/advancedTools.ts b/open-sse/mcp-server/tools/advancedTools.ts index e9918f491b..9769244f88 100644 --- a/open-sse/mcp-server/tools/advancedTools.ts +++ b/open-sse/mcp-server/tools/advancedTools.ts @@ -893,11 +893,8 @@ export async function handleDbHealthCheck(args: { autoRepair?: boolean }) { const autoRepair = args.autoRepair === true; try { - const result = toRecord( - await apiFetch("/api/v1/db/health", { - method: autoRepair ? "POST" : "GET", - }) - ); + const { runManagedDbHealthCheck } = await import("../../../src/lib/db/core.ts"); + const result = runManagedDbHealthCheck({ autoRepair }); await logToolCall( "omniroute_db_health_check", diff --git a/open-sse/package.json b/open-sse/package.json index 4d7bf34fe6..b57e8dec92 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -1,6 +1,6 @@ { "name": "@omniroute/open-sse", - "version": "3.7.4", + "version": "3.7.5", "description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration", "type": "module", "main": "index.js", diff --git a/open-sse/services/accountSemaphore.ts b/open-sse/services/accountSemaphore.ts index 0f96dec799..d210dedf06 100644 --- a/open-sse/services/accountSemaphore.ts +++ b/open-sse/services/accountSemaphore.ts @@ -28,6 +28,7 @@ interface AccountGate { export interface AcquireAccountSemaphoreOptions { maxConcurrency?: number | null; timeoutMs?: number; + signal?: AbortSignal | null; } export interface AccountSemaphoreStatsEntry { @@ -168,18 +169,34 @@ function createSemaphoreTimeoutError( return error; } +function makeAbortError(signal: AbortSignal): Error { + const reason = signal.reason; + if (reason instanceof Error) return reason; + const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + return err; +} + /** * Acquire a slot for a provider/model/account tuple. * Returns an idempotent release function that is safe to call in finally blocks. */ export function acquire( semaphoreKey: string, - { maxConcurrency = null, timeoutMs = DEFAULT_TIMEOUT_MS }: AcquireAccountSemaphoreOptions = {} + { + maxConcurrency = null, + timeoutMs = DEFAULT_TIMEOUT_MS, + signal = null, + }: AcquireAccountSemaphoreOptions = {} ): Promise<() => void> { if (isBypassed(maxConcurrency)) { return Promise.resolve(createNoopReleaseFn()); } + if (signal?.aborted) { + return Promise.reject(makeAbortError(signal)); + } + const gate = ensureGate(semaphoreKey, maxConcurrency); clearCleanupTimer(gate); @@ -189,7 +206,16 @@ export function acquire( } return new Promise((resolve, reject) => { + let abortListener: (() => void) | null = null; + + const cleanup = () => { + if (abortListener && signal) { + signal.removeEventListener("abort", abortListener); + } + }; + const timer = setTimeout(() => { + cleanup(); const nextGate = gates.get(semaphoreKey); if (!nextGate) { reject(createSemaphoreTimeoutError(semaphoreKey, timeoutMs)); @@ -209,7 +235,49 @@ export function acquire( }, timeoutMs); timer.unref?.(); - gate.queue.push({ resolve, reject, timer }); + + const queueItem: QueuedAcquire = { + resolve: (release) => { + cleanup(); + resolve(release); + }, + reject: (error) => { + cleanup(); + reject(error); + }, + timer, + }; + + gate.queue.push(queueItem); + + if (signal) { + abortListener = () => { + cleanup(); + clearTimeout(timer); + + const nextGate = gates.get(semaphoreKey); + if (!nextGate) { + reject(makeAbortError(signal)); + return; + } + + const queueIndex = nextGate.queue.findIndex((item) => item.timer === timer); + if (queueIndex !== -1) { + nextGate.queue.splice(queueIndex, 1); + } + + if (nextGate.running === 0 && nextGate.queue.length === 0) { + scheduleCleanup(semaphoreKey); + } + + reject(makeAbortError(signal)); + }; + if (signal.aborted) { + abortListener(); + } else { + signal.addEventListener("abort", abortListener); + } + } }); } diff --git a/open-sse/services/antigravityHeaders.ts b/open-sse/services/antigravityHeaders.ts index 3e6b632337..4b38e90cf2 100644 --- a/open-sse/services/antigravityHeaders.ts +++ b/open-sse/services/antigravityHeaders.ts @@ -20,7 +20,8 @@ export const GEMINI_CLI_VERSION = "0.39.1"; export const GEMINI_SDK_VERSION = "1.30.0"; export const NODE_VERSION = "v22.21.1"; export const ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT = "google-api-nodejs-client/10.3.0"; -export const ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1"; +export const ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT = + "google-cloud-sdk vscode_cloudshelleditor/0.1"; const LOAD_CODE_ASSIST_METADATA = Object.freeze({ ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", @@ -133,6 +134,4 @@ export function googApiClientHeader(): string { return `google-genai-sdk/${GEMINI_SDK_VERSION} gl-node/${NODE_VERSION}`; } -export { - ANTIGRAVITY_VERSION, -}; +export { ANTIGRAVITY_VERSION }; diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 4af4f86306..d0acd2410f 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -274,18 +274,57 @@ function getLimiter(provider, connectionId, model = null) { * @param {string} connectionId - Connection ID * @param {string} model - Model name (optional, for per-model limits) * @param {Function} fn - The async function to execute (e.g., executor.execute) + * @param {AbortSignal} signal - Optional abort signal to cancel waiting * @returns {Promise} Result of fn() */ -export async function withRateLimit(provider, connectionId, model, fn) { +export async function withRateLimit(provider, connectionId, model, fn, signal = null) { if (!enabledConnections.has(connectionId)) { return fn(); } + if (signal?.aborted) { + const reason = signal.reason; + if (reason instanceof Error) throw reason; + const err = new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + throw err; + } + const limiter = getLimiter(provider, connectionId, model); const maxWaitMs = currentRequestQueueSettings.maxWaitMs; const scheduleOpts = maxWaitMs && maxWaitMs > 0 ? { expiration: maxWaitMs } : {}; + try { - return await limiter.schedule(scheduleOpts, fn); + if (signal) { + let abortListener: (() => void) | undefined; + const abortPromise = new Promise((_, reject) => { + const onAbort = () => { + const reason = signal.reason; + const err = + reason instanceof Error + ? reason + : new Error(typeof reason === "string" ? reason : "The operation was aborted"); + err.name = "AbortError"; + reject(err); + }; + if (signal.aborted) { + onAbort(); + return; + } + abortListener = onAbort; + signal.addEventListener("abort", abortListener, { once: true }); + }); + + try { + return await Promise.race([limiter.schedule(scheduleOpts, fn), abortPromise]); + } finally { + if (abortListener) { + signal.removeEventListener("abort", abortListener); + } + } + } else { + return await limiter.schedule(scheduleOpts, fn); + } } catch (err) { // Bottleneck throws when a job exceeds its expiration timeout. // Surface as a clear rate-limit timeout so callers can fallback. diff --git a/open-sse/services/responsesToolCallState.ts b/open-sse/services/responsesToolCallState.ts new file mode 100644 index 0000000000..d440f210b4 --- /dev/null +++ b/open-sse/services/responsesToolCallState.ts @@ -0,0 +1,108 @@ +type JsonRecord = Record; + +type RememberedFunctionCall = { + call_id: string; + name: string; + arguments: string; +}; + +type RememberedResponseToolState = { + functionCalls: RememberedFunctionCall[]; + expiresAt: number; + updatedAt: number; +}; + +const RESPONSE_TOOL_CALL_TTL_MS = 30 * 60 * 1000; +const RESPONSE_TOOL_CALL_CACHE_MAX_ENTRIES = 512; + +const rememberedResponseToolCalls = new Map(); + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function cleanupRememberedResponseToolCalls(now: number = Date.now()) { + for (const [responseId, entry] of rememberedResponseToolCalls.entries()) { + if (entry.expiresAt <= now) { + rememberedResponseToolCalls.delete(responseId); + } + } + + if (rememberedResponseToolCalls.size <= RESPONSE_TOOL_CALL_CACHE_MAX_ENTRIES) { + return; + } + + const oldestEntries = [...rememberedResponseToolCalls.entries()].sort( + (a, b) => a[1].updatedAt - b[1].updatedAt + ); + + while (rememberedResponseToolCalls.size > RESPONSE_TOOL_CALL_CACHE_MAX_ENTRIES) { + const oldest = oldestEntries.shift(); + if (!oldest) break; + rememberedResponseToolCalls.delete(oldest[0]); + } +} + +export function rememberResponseFunctionCalls( + responseId: unknown, + outputItems: readonly unknown[] +) { + const normalizedResponseId = typeof responseId === "string" ? responseId.trim() : ""; + if (!normalizedResponseId || !Array.isArray(outputItems) || outputItems.length === 0) { + return; + } + + const functionCalls: RememberedFunctionCall[] = []; + + for (const item of outputItems) { + const record = toRecord(item); + if (!record || record.type !== "function_call") continue; + + const callId = typeof record.call_id === "string" ? record.call_id.trim() : ""; + const name = typeof record.name === "string" ? record.name.trim() : ""; + const argumentsValue = + typeof record.arguments === "string" + ? record.arguments + : JSON.stringify(record.arguments ?? {}); + + if (!callId || !name) continue; + + functionCalls.push({ + call_id: callId, + name, + arguments: argumentsValue, + }); + } + + if (functionCalls.length === 0) { + return; + } + + cleanupRememberedResponseToolCalls(); + + rememberedResponseToolCalls.set(normalizedResponseId, { + functionCalls, + updatedAt: Date.now(), + expiresAt: Date.now() + RESPONSE_TOOL_CALL_TTL_MS, + }); +} + +export function getRememberedResponseFunctionCalls(responseId: unknown): RememberedFunctionCall[] { + cleanupRememberedResponseToolCalls(); + + const normalizedResponseId = typeof responseId === "string" ? responseId.trim() : ""; + if (!normalizedResponseId) { + return []; + } + + const entry = rememberedResponseToolCalls.get(normalizedResponseId); + if (!entry) { + return []; + } + + return entry.functionCalls.map((functionCall) => ({ ...functionCall })); +} + +export function clearRememberedResponseFunctionCallsForTesting() { + rememberedResponseToolCalls.clear(); +} diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 5147c036dc..e1324acced 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -513,11 +513,7 @@ export async function refreshKiroToken( // AWS SSO OIDC (Builder ID or IDC) // If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified) if (clientId && clientSecret) { - const isIDC = authMethod === "idc"; - const endpoint = - isIDC && region - ? `https://oidc.${region}.amazonaws.com/token` - : "https://oidc.us-east-1.amazonaws.com/token"; + const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`; const response = await runWithProxyContext(proxyConfig, () => fetch(endpoint, { diff --git a/open-sse/translator/helpers/geminiToolsSanitizer.ts b/open-sse/translator/helpers/geminiToolsSanitizer.ts index e35a9df7b1..d76f23cf7c 100644 --- a/open-sse/translator/helpers/geminiToolsSanitizer.ts +++ b/open-sse/translator/helpers/geminiToolsSanitizer.ts @@ -30,12 +30,17 @@ function normalizeGeminiToolName( options: GeminiToolSanitizationOptions = {} ): string { const trimmed = name.trim(); - if (!options.stripNamespace) { - return trimmed; - } + const namespaceStripped = !options.stripNamespace + ? trimmed + : (() => { + const namespaceIndex = trimmed.indexOf(":"); + return namespaceIndex >= 0 ? trimmed.slice(namespaceIndex + 1) : trimmed; + })(); - const namespaceIndex = trimmed.indexOf(":"); - return namespaceIndex >= 0 ? trimmed.slice(namespaceIndex + 1) : trimmed; + return namespaceStripped + .replace(/[^a-zA-Z0-9_]/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); } function buildHashedGeminiToolName( diff --git a/open-sse/translator/request/claude-to-gemini.ts b/open-sse/translator/request/claude-to-gemini.ts index ff5e2a2803..4099dec224 100644 --- a/open-sse/translator/request/claude-to-gemini.ts +++ b/open-sse/translator/request/claude-to-gemini.ts @@ -7,6 +7,7 @@ import { } from "../helpers/geminiHelper.ts"; import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts"; import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; +import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts"; /** * Direct Claude → Gemini request translator. @@ -49,7 +50,7 @@ export function claudeToGeminiRequest(model, body, stream) { result.generationConfig.topK = body.top_k; } if (body.max_tokens !== undefined) { - result.generationConfig.maxOutputTokens = body.max_tokens; + result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, body.max_tokens); } // ── System instruction ───────────────────────────────────────── @@ -62,7 +63,7 @@ export function claudeToGeminiRequest(model, body, stream) { } if (systemText) { result.systemInstruction = { - role: "user", + role: "system", parts: [{ text: systemText }], }; } diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 73c20e5bc8..d4b240a568 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -186,7 +186,7 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName if (systemText) { if (!result.systemInstruction) { result.systemInstruction = { - role: "user", + role: "system", parts: [{ text: systemText }], }; } else { @@ -413,7 +413,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra if (envelope.request.systemInstruction?.parts) { envelope.request.systemInstruction.parts.unshift(defaultPart); } else { - envelope.request.systemInstruction = { role: "user", parts: [defaultPart] }; + envelope.request.systemInstruction = { role: "system", parts: [defaultPart] }; } // Add toolConfig for Antigravity @@ -449,6 +449,28 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu const cleanModel = model.includes("/") ? model.split("/").pop()! : model; + const requestedMaxOutputTokens = + typeof claudeRequest.max_tokens === "number" && Number.isFinite(claudeRequest.max_tokens) + ? claudeRequest.max_tokens + : 4096; + const generationConfig: GeminiGenerationConfig = { + temperature: claudeRequest.temperature || 1, + maxOutputTokens: capMaxOutputTokens(cleanModel, requestedMaxOutputTokens), + }; + const thinkingBudget = + claudeRequest.thinking?.type === "enabled" && + typeof claudeRequest.thinking.budget_tokens === "number" && + Number.isFinite(claudeRequest.thinking.budget_tokens) + ? Math.floor(claudeRequest.thinking.budget_tokens) + : null; + + if (thinkingBudget && thinkingBudget > 0) { + generationConfig.thinkingConfig = { + thinkingBudget: capThinkingBudget(cleanModel, thinkingBudget), + includeThoughts: true, + }; + } + const envelope: CloudCodeEnvelope = { project: projectId, model: cleanModel, @@ -458,10 +480,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu request: { sessionId: generateSessionId(), contents: [], - generationConfig: { - temperature: claudeRequest.temperature || 1, - maxOutputTokens: claudeRequest.max_tokens || 4096, - }, + generationConfig, }, }; @@ -555,7 +574,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu } } - envelope.request.systemInstruction = { role: "user", parts: systemParts }; + envelope.request.systemInstruction = { role: "system", parts: systemParts }; const changedToolNameMap = buildChangedToolNameMap(toolNameMap); if (changedToolNameMap) { diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 7675ef04e3..0f8028069f 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -28,6 +28,7 @@ import { sanitizeStreamingChunk, extractThinkingFromContent, } from "../handlers/responseSanitizer.ts"; +import { rememberResponseFunctionCalls } from "../services/responsesToolCallState.ts"; import { buildErrorBody } from "./error.ts"; /** @@ -80,6 +81,7 @@ type StreamOptions = { mode?: string; targetFormat?: string; sourceFormat?: string; + clientResponseFormat?: string | null; provider?: string | null; reqLogger?: StreamLogger | null; toolNameMap?: unknown; @@ -476,6 +478,7 @@ export function createSSEStream(options: StreamOptions = {}) { mode = STREAM_MODE.TRANSLATE, targetFormat, sourceFormat, + clientResponseFormat = null, provider = null, reqLogger = null, toolNameMap = null, @@ -487,6 +490,11 @@ export function createSSEStream(options: StreamOptions = {}) { onFailure = null, } = options; + const clientExpectsResponsesStream = + (mode === STREAM_MODE.PASSTHROUGH + ? clientResponseFormat === FORMATS.OPENAI_RESPONSES + : sourceFormat === FORMATS.OPENAI_RESPONSES) === true; + let buffer = ""; let usage: UsageTokenRecord | null = null; /** Passthrough (OpenAI CC shape): saw tool_calls in stream before finish_reason */ @@ -516,6 +524,8 @@ export function createSSEStream(options: StreamOptions = {}) { // used to backfill `response.completed.response.output` when upstream returns it // empty (which happens when `store: false` — see backfillResponsesCompletedOutput). const passthroughResponsesOutputItems: unknown[] = []; + let passthroughResponsesId: string | null = null; + const passthroughResponsesReasoningSummarySeen = new Set(); const streamStartedAt = Date.now(); // Guard against duplicate [DONE] events — ensures exactly one per stream @@ -683,6 +693,101 @@ export function createSSEStream(options: StreamOptions = {}) { controller.enqueue(encoder.encode(comment)); }; + const getResponsesReasoningKey = (payload: Record): string | null => { + if (typeof payload.item_id === "string" && payload.item_id) { + return payload.item_id; + } + + const item = + payload.item && typeof payload.item === "object" && !Array.isArray(payload.item) + ? (payload.item as Record) + : null; + if (item && typeof item.id === "string" && item.id) { + return item.id; + } + + const responseId = + typeof payload.response_id === "string" && payload.response_id + ? payload.response_id + : passthroughResponsesId; + const outputIndex = + typeof payload.output_index === "number" && Number.isInteger(payload.output_index) + ? payload.output_index + : null; + + return responseId !== null && outputIndex !== null ? `${responseId}:${outputIndex}` : null; + }; + + const emitSyntheticResponsesReasoningSummary = ( + controller: TransformStreamDefaultController, + payload: Record + ) => { + const item = + payload.item && typeof payload.item === "object" && !Array.isArray(payload.item) + ? (payload.item as Record) + : null; + if (!item || item.type !== "reasoning" || !Array.isArray(item.summary)) { + return; + } + + const summaryText = item.summary + .map((part) => { + if (!part || typeof part !== "object" || Array.isArray(part)) { + return ""; + } + return typeof (part as Record).text === "string" + ? ((part as Record).text as string) + : ""; + }) + .join(""); + + if (!summaryText) { + return; + } + + const reasoningKey = getResponsesReasoningKey(payload); + if (!reasoningKey || passthroughResponsesReasoningSummarySeen.has(reasoningKey)) { + return; + } + passthroughResponsesReasoningSummarySeen.add(reasoningKey); + + const itemId = typeof item.id === "string" && item.id ? item.id : reasoningKey; + const outputIndex = + typeof payload.output_index === "number" && Number.isInteger(payload.output_index) + ? payload.output_index + : 0; + + const syntheticEvents = [ + { + event: "response.reasoning_summary_text.delta", + body: { + type: "response.reasoning_summary_text.delta", + item_id: itemId, + output_index: outputIndex, + summary_index: 0, + delta: summaryText, + }, + }, + { + event: "response.reasoning_summary_part.done", + body: { + type: "response.reasoning_summary_part.done", + item_id: itemId, + output_index: outputIndex, + summary_index: 0, + part: { type: "summary_text", text: summaryText }, + }, + }, + ]; + + for (const syntheticEvent of syntheticEvents) { + clientPayloadCollector.push(syntheticEvent.body); + const output = `event: ${syntheticEvent.event}\ndata: ${JSON.stringify(syntheticEvent.body)}\n\n`; + reqLogger?.appendConvertedChunk?.(output); + controller.enqueue(encoder.encode(output)); + } + }; + return new TransformStream( { start(controller) { @@ -809,6 +914,15 @@ export function createSSEStream(options: StreamOptions = {}) { parsed.type === "error"); if (isResponsesSSE) { + const responseId = + typeof parsed.response?.id === "string" + ? parsed.response.id + : typeof parsed.response_id === "string" + ? parsed.response_id + : null; + if (responseId) { + passthroughResponsesId = responseId; + } // Responses SSE: only extract usage, forward payload as-is const extracted = extractUsage(parsed); if (extracted) { @@ -825,10 +939,21 @@ export function createSSEStream(options: StreamOptions = {}) { if (parsed.type === "response.failed") { failurePayload = normalizeStreamFailurePayload(parsed); } + if ( + parsed.type === "response.reasoning_summary_text.delta" || + parsed.type === "response.reasoning_summary_text.done" || + parsed.type === "response.reasoning_summary_part.done" + ) { + const reasoningKey = getResponsesReasoningKey(parsed); + if (reasoningKey) { + passthroughResponsesReasoningSummarySeen.add(reasoningKey); + } + } // Capture each completed output item so the final // response.completed snapshot can be backfilled when upstream // returns an empty `output` (happens with store: false). if (parsed.type === "response.output_item.done" && parsed.item) { + emitSyntheticResponsesReasoningSummary(controller, parsed); passthroughResponsesOutputItems.push(parsed.item); } // Two transport-level fixes for Responses passthrough: @@ -1288,6 +1413,13 @@ export function createSSEStream(options: StreamOptions = {}) { } clearPendingPassthroughEvent(); + if (passthroughResponsesId && passthroughResponsesOutputItems.length > 0) { + rememberResponseFunctionCalls( + passthroughResponsesId, + passthroughResponsesOutputItems + ); + } + // Estimate usage if provider didn't return valid usage if (!hasValidUsage(usage) && totalContentLength > 0) { usage = estimateUsage(body, totalContentLength, sourceFormat || FORMATS.OPENAI); @@ -1307,10 +1439,12 @@ export function createSSEStream(options: StreamOptions = {}) { if (!doneSent) { await emitFinalSseMetadata(controller, usage); doneSent = true; - clientPayloadCollector.push({ done: true }); - const doneOutput = "data: [DONE]\n\n"; - reqLogger?.appendConvertedChunk?.(doneOutput); - controller.enqueue(encoder.encode(doneOutput)); + if (!clientExpectsResponsesStream) { + clientPayloadCollector.push({ done: true }); + const doneOutput = "data: [DONE]\n\n"; + reqLogger?.appendConvertedChunk?.(doneOutput); + controller.enqueue(encoder.encode(doneOutput)); + } } // Notify caller for call log persistence (include full response body with accumulated content) if (onComplete) { @@ -1499,10 +1633,12 @@ export function createSSEStream(options: StreamOptions = {}) { if (!doneSent) { await emitFinalSseMetadata(controller, state?.usage as Record | null); doneSent = true; - clientPayloadCollector.push({ done: true }); - const doneOutput = "data: [DONE]\n\n"; - reqLogger?.appendConvertedChunk?.(doneOutput); - controller.enqueue(encoder.encode(doneOutput)); + if (!clientExpectsResponsesStream) { + clientPayloadCollector.push({ done: true }); + const doneOutput = "data: [DONE]\n\n"; + reqLogger?.appendConvertedChunk?.(doneOutput); + controller.enqueue(encoder.encode(doneOutput)); + } } // Estimate usage if provider didn't return valid usage (for translate mode) @@ -1632,7 +1768,8 @@ export function createPassthroughStreamWithLogger( body: unknown = null, onComplete: ((payload: StreamCompletePayload) => void) | null = null, apiKeyInfo: unknown = null, - onFailure: ((payload: StreamFailurePayload) => void | Promise) | null = null + onFailure: ((payload: StreamFailurePayload) => void | Promise) | null = null, + clientResponseFormat: string | null = null ) { return createSSEStream({ mode: STREAM_MODE.PASSTHROUGH, @@ -1645,5 +1782,6 @@ export function createPassthroughStreamWithLogger( body, onComplete, onFailure, + clientResponseFormat, }); } diff --git a/package-lock.json b/package-lock.json index dee96e1e29..17ae67cb5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.7.4", + "version": "3.7.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.7.4", + "version": "3.7.5", "hasInstallScript": true, "license": "MIT", "workspaces": [ @@ -16,6 +16,7 @@ "@lobehub/icons": "^5.5.4", "@modelcontextprotocol/sdk": "^1.27.1", "@monaco-editor/react": "^4.7.0", + "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.21", "axios": "^1.15.0", "bcryptjs": "^3.0.3", @@ -2322,6 +2323,235 @@ "node": ">= 10" } }, + "node_modules/@ngrok/ngrok": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-1.7.0.tgz", + "integrity": "sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g==", + "license": "(MIT OR Apache-2.0)", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@ngrok/ngrok-android-arm64": "1.7.0", + "@ngrok/ngrok-darwin-arm64": "1.7.0", + "@ngrok/ngrok-darwin-universal": "1.7.0", + "@ngrok/ngrok-darwin-x64": "1.7.0", + "@ngrok/ngrok-freebsd-x64": "1.7.0", + "@ngrok/ngrok-linux-arm-gnueabihf": "1.7.0", + "@ngrok/ngrok-linux-arm64-gnu": "1.7.0", + "@ngrok/ngrok-linux-arm64-musl": "1.7.0", + "@ngrok/ngrok-linux-x64-gnu": "1.7.0", + "@ngrok/ngrok-linux-x64-musl": "1.7.0", + "@ngrok/ngrok-win32-arm64-msvc": "1.7.0", + "@ngrok/ngrok-win32-ia32-msvc": "1.7.0", + "@ngrok/ngrok-win32-x64-msvc": "1.7.0" + } + }, + "node_modules/@ngrok/ngrok-android-arm64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm64/-/ngrok-android-arm64-1.7.0.tgz", + "integrity": "sha512-8tco3ID6noSaNy+CMS7ewqPoIkIM6XO5COCzsUp3Wv3XEbMSyn65RN6cflX2JdqLfUCHcMyD0ahr9IEiHwqmbQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-arm64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-arm64/-/ngrok-darwin-arm64-1.7.0.tgz", + "integrity": "sha512-+dmJSOzSO+MNDVrPOca2yYDP1W3KfP4qOlAkarIeFRIfqonQwq3QCBmcR7HAlZocLsSqEwyG6KP4RRvAuT0WGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-universal": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-universal/-/ngrok-darwin-universal-1.7.0.tgz", + "integrity": "sha512-fDEfewyE2pWGFBhOSwQZObeHUkc65U1l+3HIgSOe094TMHsqmyJD0KTCgW9KSn0VP4OvDZbAISi1T3nvqgZYhQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-darwin-x64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-x64/-/ngrok-darwin-x64-1.7.0.tgz", + "integrity": "sha512-+fwMi5uHd9G8BS42MMa9ye6exI5lwTcjUO6Ut497Vu0qgLONdVRenRqnEePV+Q3KtQR7NjqkMnomVfkr9MBjtw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-freebsd-x64": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-freebsd-x64/-/ngrok-freebsd-x64-1.7.0.tgz", + "integrity": "sha512-2OGgbrjy3yLRrqAz5N6hlUKIWIXSpR5RjQa2chtZMsSbszQ6c9dI+uVQfOKAeo05tHMUgrYAZ7FocC+ig0dzdQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm-gnueabihf": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm-gnueabihf/-/ngrok-linux-arm-gnueabihf-1.7.0.tgz", + "integrity": "sha512-SN9YIfEQiR9xN90QVNvdgvAemqMLoFVSeTWZs779145hQMhvF9Qd9rnWi6J+2uNNK10OczdV1oc/nq1es7u/3g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-gnu": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-gnu/-/ngrok-linux-arm64-gnu-1.7.0.tgz", + "integrity": "sha512-KDMgzPKFU2kbpVSaA2RZBBia5IPdJEe063YlyVFnSMJmPYWCUnMwdybBsucXfV9u1Lw/ZjKTKotIlbTWGn3HGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-arm64-musl": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-musl/-/ngrok-linux-arm64-musl-1.7.0.tgz", + "integrity": "sha512-e66vUdVrBlQ0lT9ZdamB4U604zt5Gualt8/WVcUGzbu8s5LajWd6g/mzZCUjK4UepjvMpfgmCp1/+rX7Rk8d5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-gnu": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-gnu/-/ngrok-linux-x64-gnu-1.7.0.tgz", + "integrity": "sha512-M6gF0DyOEFqXLfWxObfL3bxYZ4+PnKBHuyLVaqNfFN9Y5utY2mdPOn5422Ppbk4XoIK5/YkuhRqPJl/9FivKEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-linux-x64-musl": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-musl/-/ngrok-linux-x64-musl-1.7.0.tgz", + "integrity": "sha512-4Ijm0dKeoyzZTMaYxR2EiNjtlK81ebflg/WYIO1XtleFrVy4UJEGnxtxEidYoT4BfCqi4uvXiK2Mx216xXKvog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-arm64-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-arm64-msvc/-/ngrok-win32-arm64-msvc-1.7.0.tgz", + "integrity": "sha512-u7qyWIJI2/YG1HTBnHwUR1+Z2tyGfAsUAItJK/+N1G0FeWJhIWQvSIFJHlaPy4oW1Dc8mSDBX9qvVsiQgLaRFg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-ia32-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-ia32-msvc/-/ngrok-win32-ia32-msvc-1.7.0.tgz", + "integrity": "sha512-/UdYUsLNv/Q8j9YJsyIfq/jLCoD8WP+NidouucTUzSoDtmOsXBBT3itLrmPiZTEdEgKiFYLuC1Zon8XQQvbVLA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@ngrok/ngrok-win32-x64-msvc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-x64-msvc/-/ngrok-win32-x64-msvc-1.7.0.tgz", + "integrity": "sha512-UFJg/duEWzZlLkEs61Gz6/5nYhGaKI62I8dvUGdBR3NCtIMagehnFaFxmnXZldyHmCM8U0aCIFNpWRaKcrQkoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -14972,7 +15202,7 @@ }, "open-sse": { "name": "@omniroute/open-sse", - "version": "3.7.4" + "version": "3.7.5" } } } diff --git a/package.json b/package.json index e17f1e9d4e..0a2a1d4300 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.7.4", + "version": "3.7.5", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { @@ -110,6 +110,7 @@ "@lobehub/icons": "^5.5.4", "@modelcontextprotocol/sdk": "^1.27.1", "@monaco-editor/react": "^4.7.0", + "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.21", "axios": "^1.15.0", "bcryptjs": "^3.0.3", diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index 21e75e9865..3d12cb818f 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -63,6 +63,26 @@ type TailscaleTunnelStatus = { pid: number | null; }; +type NgrokTunnelPhase = + | "unsupported" + | "not_installed" + | "stopped" + | "needs_auth" + | "starting" + | "running" + | "error"; + +type NgrokTunnelStatus = { + supported: boolean; + installed: boolean; + running: boolean; + publicUrl: string | null; + apiUrl: string | null; + targetUrl: string; + phase: NgrokTunnelPhase; + lastError: string | null; +}; + type TunnelNotice = { type: "success" | "error" | "info"; message: string; @@ -122,6 +142,11 @@ export default function APIPageClient({ machineId }) { const [tailscalePassword, setTailscalePassword] = useState(""); const [showCloudflaredTunnel, setShowCloudflaredTunnel] = useState(true); const [showTailscaleFunnel, setShowTailscaleFunnel] = useState(true); + const [ngrokStatus, setNgrokStatus] = useState(null); + const [ngrokBusy, setNgrokBusy] = useState(false); + const [ngrokNotice, setNgrokNotice] = useState(null); + const [ngrokToken, setNgrokToken] = useState(""); + const [showNgrokTunnel, setShowNgrokTunnel] = useState(true); const { copied, copy } = useCopyToClipboard(); @@ -218,6 +243,35 @@ export default function APIPageClient({ machineId }) { [translateOrFallback] ); + const fetchNgrokStatus = useCallback( + async (silent = false) => { + try { + const res = await fetch("/api/tunnels/ngrok", { cache: "no-store" }); + const data = await res.json().catch(() => null); + if (!res.ok) { + throw new Error( + data?.error || translateOrFallback("ngrokRequestFailed", "Failed to load ngrok status") + ); + } + + setNgrokStatus(data); + return data as NgrokTunnelStatus; + } catch (error) { + if (!silent) { + setNgrokNotice({ + type: "error", + message: + error instanceof Error + ? error.message + : translateOrFallback("ngrokRequestFailed", "Failed to load ngrok status"), + }); + } + return null; + } + }, + [translateOrFallback] + ); + useEffect(() => { let mounted = true; @@ -237,6 +291,7 @@ export default function APIPageClient({ machineId }) { if (tunnelVisibility.showTailscaleFunnel) { runEndpointBackgroundTask("tailscale-status", () => fetchTailscaleStatus(true)); } + runEndpointBackgroundTask("ngrok-status", () => fetchNgrokStatus(true)); }; void loadPage(); @@ -244,7 +299,7 @@ export default function APIPageClient({ machineId }) { return () => { mounted = false; }; - }, [fetchCloudflaredStatus, fetchTailscaleStatus]); + }, [fetchCloudflaredStatus, fetchTailscaleStatus, fetchNgrokStatus]); const fetchModels = async () => { setModelsLoading(true); @@ -575,6 +630,52 @@ export default function APIPageClient({ machineId }) { } }; + const handleNgrokAction = async (action: "enable" | "disable") => { + setNgrokBusy(true); + setNgrokNotice(null); + + try { + const res = await fetch("/api/tunnels/ngrok", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, authToken: action === "enable" ? ngrokToken : undefined }), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new Error( + data?.error || translateOrFallback("ngrokRequestFailed", "Failed to update ngrok tunnel") + ); + } + + if (data?.status) { + setNgrokStatus(data.status); + } + + setNgrokNotice({ + type: "success", + message: + action === "enable" + ? translateOrFallback("ngrokStarted", "ngrok tunnel started") + : translateOrFallback("ngrokStopped", "ngrok tunnel stopped"), + }); + if (action === "enable") { + setNgrokToken(""); + } + } catch (error) { + setNgrokNotice({ + type: "error", + message: + error instanceof Error + ? error.message + : translateOrFallback("ngrokRequestFailed", "Failed to update ngrok tunnel"), + }); + } finally { + setNgrokBusy(false); + await fetchNgrokStatus(true); + } + }; + const waitForTailscale = useCallback( async ( predicate: (status: TailscaleTunnelStatus) => boolean, @@ -964,6 +1065,42 @@ export default function APIPageClient({ machineId }) { "Uses your Tailscale .ts.net address. Login and Funnel approval may be required on first use." ); + const ngrokPhase = ngrokStatus?.phase || "not_installed"; + const ngrokPhaseMeta: Record = { + running: { + label: translateOrFallback("ngrokRunning", "Running"), + className: "bg-green-500/10 border-green-500/30 text-green-400", + }, + starting: { + label: translateOrFallback("ngrokStarting", "Starting"), + className: "bg-blue-500/10 border-blue-500/30 text-blue-400", + }, + stopped: { + label: translateOrFallback("ngrokStoppedState", "Stopped"), + className: "bg-surface border-border/70 text-text-muted", + }, + needs_auth: { + label: translateOrFallback("ngrokNeedsAuth", "Needs Auth"), + className: "bg-amber-500/10 border-amber-500/30 text-amber-400", + }, + not_installed: { + label: translateOrFallback("ngrokNotInstalled", "Not installed"), + className: "bg-surface border-border/70 text-text-muted", + }, + unsupported: { + label: translateOrFallback("ngrokUnsupported", "Unsupported"), + className: "bg-amber-500/10 border-amber-500/30 text-amber-400", + }, + error: { + label: translateOrFallback("ngrokError", "Error"), + className: "bg-red-500/10 border-red-500/30 text-red-400", + }, + }; + const ngrokActionLabel = ngrokStatus?.running + ? translateOrFallback("ngrokDisable", "Stop Tunnel") + : translateOrFallback("ngrokEnable", "Enable Tunnel"); + const ngrokUrlNotice = translateOrFallback("ngrokUrlNotice", "Creates a public ngrok tunnel."); + return (
{/* Endpoint Card */} @@ -1302,6 +1439,120 @@ export default function APIPageClient({ machineId }) {
)} + + {showNgrokTunnel && ( +
+
+
+
+
+

+ {translateOrFallback("ngrokTitle", "ngrok Tunnel")} +

+ + {ngrokPhaseMeta[ngrokPhase].label} + +
+
+ + {ngrokStatus?.supported !== false && ( + + )} +
+ + {ngrokNotice && ( +
+ + {ngrokNotice.type === "success" + ? "check_circle" + : ngrokNotice.type === "info" + ? "info" + : "error"} + + {ngrokNotice.message} + +
+ )} + +

{ngrokUrlNotice}

+ {ngrokStatus?.phase === "needs_auth" && ( +
+ + setNgrokToken(event.target.value)} + placeholder={translateOrFallback( + "ngrokAuthTokenPlaceholder", + "Enter your ngrok authtoken" + )} + disabled={ngrokBusy} + className="font-mono text-sm" + /> +
+ )} +
+ + +
+ {ngrokStatus?.lastError && ( +

+ {translateOrFallback("ngrokLastError", "Last error: {error}", { + error: ngrokStatus.lastError, + })} +

+ )} +
+
+ )} diff --git a/src/app/(dashboard)/dashboard/health/page.tsx b/src/app/(dashboard)/dashboard/health/page.tsx index 5dad4cd35f..d4af48798e 100644 --- a/src/app/(dashboard)/dashboard/health/page.tsx +++ b/src/app/(dashboard)/dashboard/health/page.tsx @@ -81,7 +81,7 @@ export default function HealthPage() { const fetchDbHealth = useCallback(async () => { try { - const res = await fetch("/api/v1/db/health"); + const res = await fetch("/api/db/health"); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); setDbHealth(json); @@ -138,7 +138,7 @@ export default function HealthPage() { const handleRepairDb = async () => { setRepairingDb(true); try { - const res = await fetch("/api/v1/db/health", { method: "POST" }); + const res = await fetch("/api/db/health", { method: "POST" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); setDbHealth(json); diff --git a/src/app/(dashboard)/dashboard/memory/page.tsx b/src/app/(dashboard)/dashboard/memory/page.tsx index 526cc9debe..885727721a 100644 --- a/src/app/(dashboard)/dashboard/memory/page.tsx +++ b/src/app/(dashboard)/dashboard/memory/page.tsx @@ -205,17 +205,21 @@ export default function MemoryPage() { {health !== null && ( )} {health === null && !checkingHealth && ( )} @@ -321,7 +325,7 @@ export default function MemoryPage() {
- Page {page} of {totalPages} ({total} total) + {t("pageInfo", { page, totalPages, total })}
@@ -356,47 +360,47 @@ export default function MemoryPage() { onClick={() => setAddDialogOpen(false)} disabled={isSubmitting} > - Cancel + {t("cancel")} } >
- +
- + setNewMemory({ ...newMemory, key: e.target.value })} - placeholder="e.g., user_preference_theme" + placeholder={t("keyPlaceholder")} className="w-full" />
- + setNewMemory({ ...newMemory, content: e.target.value })} - placeholder="e.g., Prefers dark mode" + placeholder={t("contentPlaceholder")} className="w-full" />
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index 25ad3c7f7b..5cefea5c4d 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -460,7 +460,7 @@ interface CooldownTimerProps { function getModelSourceBadgeClass(source?: string): string { switch (normalizeModelCatalogSource(source)) { - case "api-sync": + case "imported": return "border-sky-500/30 bg-sky-500/10 text-sky-300"; case "custom": return "border-emerald-500/30 bg-emerald-500/10 text-emerald-300"; @@ -1041,12 +1041,12 @@ export default function ProviderDetailPage() { const isOAuth = providerSupportsOAuth && !providerSupportsPat; const registryModels = getModelsByProviderId(providerId); // Prefer synced API-discovered models when available, then merge built-ins - // and user-managed custom/imported models without duplicating IDs. + // and user-managed custom models without duplicating IDs. const models = useMemo(() => { if (providerId === "gemini") { return syncedAvailableModels.map((model: any) => ({ ...model, - source: model?.source === "api-sync" ? "api-sync" : "api-sync", + source: "imported", })); } @@ -1061,7 +1061,7 @@ export default function ProviderDetailPage() { .map((model: any) => ({ id: model.id, name: model.name || model.id, - source: "api-sync", + source: "imported", })); const knownIds = new Set([...registryIds, ...syncedExtras.map((model: any) => model.id)]); const customExtras = modelMeta.customModels @@ -1069,7 +1069,7 @@ export default function ProviderDetailPage() { .map((cm: any) => ({ id: cm.id, name: cm.name || cm.id, - source: cm.source === "api-sync" ? "api-sync" : "custom", + source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", })); return [...builtInModels, ...syncedExtras, ...customExtras]; }, [providerId, registryModels, syncedAvailableModels, modelMeta.customModels]); @@ -2097,6 +2097,9 @@ export default function ProviderDetailPage() { typeof data.importedChanges?.total === "number" ? data.importedChanges.total : importedCount; + const totalChangedCount = + changedCount + + (typeof data.customModelChanges?.total === "number" ? data.customModelChanges.total : 0); if (importedModels.length === 0) { setImportProgress((prev) => ({ @@ -2113,7 +2116,7 @@ export default function ProviderDetailPage() { ], importedCount, })); - if (changedCount > 0) { + if (totalChangedCount > 0) { setTimeout(() => { window.location.reload(); }, 2000); @@ -2142,7 +2145,7 @@ export default function ProviderDetailPage() { importedCount, })); - if (changedCount > 0) { + if (totalChangedCount > 0) { setTimeout(() => { window.location.reload(); }, 2000); diff --git a/src/app/api/v1/db/health/route.ts b/src/app/api/db/health/route.ts similarity index 100% rename from src/app/api/v1/db/health/route.ts rename to src/app/api/db/health/route.ts diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index cf5dd090eb..0b31439e5c 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -81,6 +81,8 @@ export async function GET( } const authData = generateAuthData(provider, null); + const startUrl = searchParams.get("startUrl"); + const region = searchParams.get("region") || "us-east-1"; // Resolve proxy for this provider (provider-level → global → direct) const proxy = await resolveProxyForProvider(provider); @@ -95,7 +97,24 @@ export async function GET( provider === "kilocode" ) { // GitHub, Kiro/Amazon Q, Kimi Coding, and KiloCode don't use PKCE for device code - deviceData = await runWithProxyContext(proxy, () => (requestDeviceCode as any)(provider)); + if ((provider === "kiro" || provider === "amazon-q") && startUrl) { + const providerOverrideConfig = { + ...providerData.config, + startUrl, + region, + skipIssuerUrlForRegistration: true, + registerClientUrl: `https://oidc.${region}.amazonaws.com/client/register`, + deviceAuthUrl: `https://oidc.${region}.amazonaws.com/device_authorization`, + tokenUrl: `https://oidc.${region}.amazonaws.com/token`, + ssoOidcEndpoint: `https://oidc.${region}.amazonaws.com`, + }; + + deviceData = await runWithProxyContext(proxy, () => + (requestDeviceCode as any)(provider, null, providerOverrideConfig) + ); + } else { + deviceData = await runWithProxyContext(proxy, () => (requestDeviceCode as any)(provider)); + } } else { // Qwen and other providers use PKCE deviceData = await runWithProxyContext(proxy, () => diff --git a/src/app/api/pricing/models/route.ts b/src/app/api/pricing/models/route.ts index 98882c4af1..e25ba2fcc1 100644 --- a/src/app/api/pricing/models/route.ts +++ b/src/app/api/pricing/models/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; -import { getAllCustomModels, getPricing } from "@/lib/localDb"; +import { getAllCustomModels, getAllSyncedAvailableModels, getPricing } from "@/lib/localDb"; function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) @@ -20,8 +20,9 @@ function asModelArray(value: unknown): Array<{ id?: string; name?: string }> { * GET /api/pricing/models * Returns the full model catalog merged from three sources: * 1. providerRegistry (hardcoded) - * 2. customModels (DB — user-added or imported via /models) - * 3. pricing data (DB — models with pricing configured but not in sources 1/2) + * 2. syncedAvailableModels (DB — discovered/imported from provider /models) + * 3. customModels (DB — manually added models) + * 4. pricing data (DB — models with pricing configured but not in sources 1/2/3) */ export async function GET() { try { @@ -46,25 +47,14 @@ export async function GET() { }; } - // ── 2. Custom models (DB) ─────────────────────────────────────── - let customModelsMap: Record = {}; - try { - customModelsMap = asRecord(await getAllCustomModels()); - } catch { - /* DB may not be ready */ - } - - for (const [providerId, rawModels] of Object.entries(customModelsMap)) { - const models = asModelArray(rawModels); - // Resolve alias — check if a registry entry maps this providerId - let alias = providerId; + const resolveAlias = (providerId: string) => { for (const entry of Object.values(REGISTRY)) { - if (entry.id === providerId) { - alias = entry.alias || entry.id; - break; - } + if (entry.id === providerId) return entry.alias || entry.id; } + return providerId; + }; + const ensureCatalogProvider = (providerId: string, alias: string) => { if (!catalog[alias]) { catalog[alias] = { id: providerId, @@ -75,25 +65,52 @@ export async function GET() { models: [], }; } + return catalog[alias]; + }; + + const appendDbModels = (providerId: string, rawModels: unknown) => { + const models = asModelArray(rawModels); + const alias = resolveAlias(providerId); + const providerCatalog = ensureCatalogProvider(providerId, alias); + const existingIds = new Set(providerCatalog.models.map((m) => m.id)); - const existingIds = new Set(catalog[alias].models.map((m) => m.id)); for (const model of models) { const modelId = typeof model.id === "string" ? model.id : null; - if (!modelId || existingIds.has(modelId)) { - continue; - } - if (!existingIds.has(modelId)) { - catalog[alias].models.push({ - id: modelId, - name: typeof model.name === "string" && model.name.trim() ? model.name : modelId, - custom: true, - }); - existingIds.add(modelId); - } + if (!modelId || existingIds.has(modelId)) continue; + providerCatalog.models.push({ + id: modelId, + name: typeof model.name === "string" && model.name.trim() ? model.name : modelId, + custom: true, + }); + existingIds.add(modelId); } + }; + + // ── 2. Synced available models (DB) ───────────────────────────── + let syncedModelsMap: Record = {}; + try { + syncedModelsMap = asRecord(await getAllSyncedAvailableModels()); + } catch { + /* DB may not be ready */ } - // ── 3. Pricing-only models (DB) ───────────────────────────────── + for (const [providerId, rawModels] of Object.entries(syncedModelsMap)) { + appendDbModels(providerId, rawModels); + } + + // ── 3. Custom models (DB) ─────────────────────────────────────── + let customModelsMap: Record = {}; + try { + customModelsMap = asRecord(await getAllCustomModels()); + } catch { + /* DB may not be ready */ + } + + for (const [providerId, rawModels] of Object.entries(customModelsMap)) { + appendDbModels(providerId, rawModels); + } + + // ── 4. Pricing-only models (DB) ───────────────────────────────── let pricingData: Record = {}; try { pricingData = await getPricing(); diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 9844deeb4d..a8b807985b 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getProviderConnectionById } from "@/models"; +import { getSyncedAvailableModelsForConnection } from "@/lib/db/models"; import { importManagedModels, type ManagedModelImportMode, @@ -10,6 +11,7 @@ import { buildModelSyncInternalHeaders, isModelSyncInternalRequest, } from "@/shared/services/modelSyncScheduler"; +import { GET as getProviderModels } from "../models/route"; type JsonRecord = Record; @@ -28,8 +30,8 @@ function normalizeModelForComparison(model: unknown) { const rawSource = toNonEmptyString(record.source)?.toLowerCase(); const source = rawSource === "api-sync" || rawSource === "auto-sync" || rawSource === "imported" - ? "api-sync" - : rawSource || "auto-sync"; + ? "imported" + : rawSource || "manual"; const apiFormat = toNonEmptyString(record.apiFormat) || "chat-completions"; const supportedEndpoints = Array.isArray(record.supportedEndpoints) ? Array.from( @@ -56,6 +58,41 @@ function isManagedSyncedModel(model: unknown) { return source === "api-sync" || source === "auto-sync" || source === "imported"; } +function getErrorMessageFromPayload(payload: JsonRecord): string | null { + const error = payload.error; + if (typeof error === "string" && error.trim().length > 0) { + return error.trim(); + } + + const errorRecord = asRecord(error); + return toNonEmptyString(errorRecord.message) || toNonEmptyString(payload.message); +} + +async function readJsonResponse(response: Response): Promise<{ + data: JsonRecord; + parseError: string | null; +}> { + const body = await response.text(); + if (!body.trim()) { + return { + data: {}, + parseError: "Empty response body from /models", + }; + } + + try { + return { + data: asRecord(JSON.parse(body)), + parseError: null, + }; + } catch { + return { + data: {}, + parseError: "Invalid JSON response from /models", + }; + } +} + function summarizeModelChanges(previousModels: unknown, nextModels: unknown) { const previousList = Array.isArray(previousModels) ? previousModels : []; const nextList = Array.isArray(nextModels) ? nextModels : []; @@ -117,13 +154,54 @@ function getModelSyncChannelLabel(connection: unknown) { ); } +async function fetchProviderModelsForSync(request: Request, connectionId: string) { + // Construct a safe localhost URL from the incoming request's origin. + // The route only accepts authenticated or internal-scheduler requests, + // and the path is hardcoded — no user-controlled URL components reach fetch. + const SAFE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); + const incomingUrl = new URL(request.url); + const safeOrigin = SAFE_HOSTS.has(incomingUrl.hostname) + ? incomingUrl.origin + : `http://127.0.0.1:${process.env.PORT || "20128"}`; + const modelsPath = `/api/providers/${encodeURIComponent(connectionId)}/models?refresh=true`; + const headers = { + cookie: request.headers.get("cookie") || "", + ...buildModelSyncInternalHeaders(), + }; + + try { + return await fetch(new URL(modelsPath, safeOrigin).href, { + method: "GET", + cache: "no-store", + headers, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `[ModelSync] Internal /models self-fetch failed for ${connectionId.slice( + 0, + 8 + )}; falling back to in-process route: ${message}` + ); + + return getProviderModels( + new Request(new URL(modelsPath, "http://localhost").href, { + method: "GET", + headers, + }), + { params: { id: connectionId } } + ); + } +} + /** * POST /api/providers/[id]/sync-models * - * Fetches the model list from a provider's /models endpoint and replaces the - * full custom models list for that provider while refreshing the per-connection - * discovery cache. Successful syncs only write a call log when the fetched - * channel actually changes the stored model list. + * Fetches the model list from a provider's /models endpoint, stores discovered + * models in the per-connection available-model cache, and removes matching + * upstream-discovered rows from the provider's custom model list. Successful + * syncs only write a call log when the fetched channel or custom model cleanup + * changes stored model state. * * Used by: * - modelSyncScheduler (auto-sync on interval) @@ -153,30 +231,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: logProvider = toNonEmptyString(connection.provider) || "unknown"; channelLabel = getModelSyncChannelLabel(connection); + const previousSyncedAvailableModelsForConnection = await getSyncedAvailableModelsForConnection( + logProvider, + id + ); - // Fetch models from the existing /api/providers/[id]/models endpoint. - // Construct a safe localhost URL from the incoming request's origin. - // The route only accepts authenticated or internal-scheduler requests, - // and the path is hardcoded — no user-controlled URL components reach fetch. - const SAFE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]); - const incomingUrl = new URL(request.url); - const safeOrigin = SAFE_HOSTS.has(incomingUrl.hostname) - ? incomingUrl.origin - : `http://127.0.0.1:${process.env.PORT || "20128"}`; - const modelsPath = `/api/providers/${encodeURIComponent(id)}/models?refresh=true`; - const modelsRes = await fetch(new URL(modelsPath, safeOrigin).href, { - method: "GET", - cache: "no-store", - headers: { - cookie: request.headers.get("cookie") || "", - ...buildModelSyncInternalHeaders(), - }, - }); + const modelsRes = await fetchProviderModelsForSync(request, id); const duration = Date.now() - start; - const modelsData = await modelsRes.json(); + const { data: modelsData, parseError } = await readJsonResponse(modelsRes); + const payloadError = getErrorMessageFromPayload(modelsData); - if (!modelsRes.ok) { + if (!modelsRes.ok || parseError) { + const responseStatus = modelsRes.ok ? 502 : modelsRes.status; + const logError = payloadError || parseError || `HTTP ${modelsRes.status}`; + const responseError = payloadError || parseError || "Failed to fetch models"; // Log the failed attempt await saveCallLog({ method: "GET", @@ -187,22 +256,35 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: sourceFormat: "-", connectionId: id, duration, - error: modelsData.error || `HTTP ${modelsRes.status}`, + error: logError, requestType: "model-sync", + ...(parseError + ? { + responseBody: { + upstreamStatus: modelsRes.status, + parseError, + }, + } + : {}), }); return NextResponse.json( - { error: modelsData.error || "Failed to fetch models" }, - { status: modelsRes.status } + { + error: responseError, + ...(parseError ? { upstreamStatus: modelsRes.status } : {}), + }, + { status: responseStatus } ); } const fetchedModels = modelsData.models || []; const { previousModels, + previousSyncedAvailableModels, persistedModels, importedModels, discoveredModels, + syncedAvailableModels, syncedAliases, importedChanges, } = await importManagedModels({ @@ -210,22 +292,30 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: connectionId: id, fetchedModels, mode, + previousSyncedAvailableModels: previousSyncedAvailableModelsForConnection, }); - const modelChanges = summarizeModelChanges(previousModels, persistedModels); + const effectiveAvailableModels = + discoveredModels.length > 0 ? discoveredModels : syncedAvailableModels; + const modelChanges = summarizeModelChanges( + previousSyncedAvailableModels, + effectiveAvailableModels + ); + const customModelChanges = summarizeModelChanges(previousModels, persistedModels); const syncedModelsCount = - discoveredModels.length > 0 - ? discoveredModels.length + effectiveAvailableModels.length > 0 + ? effectiveAvailableModels.length : persistedModels.filter((model) => isManagedSyncedModel(model)).length; const availableModelsCount = new Set( - [...persistedModels, ...discoveredModels] + [...persistedModels, ...effectiveAvailableModels] .map((model) => toNonEmptyString(asRecord(model).id)) .filter((modelId): modelId is string => Boolean(modelId)) ).size; const importedCount = importedChanges.added; const updatedCount = importedChanges.updated; + const shouldLog = modelChanges.total > 0 || customModelChanges.total > 0; - if (modelChanges.total > 0) { + if (shouldLog) { await saveCallLog({ method: "GET", path: `/api/providers/${id}/models`, @@ -243,6 +333,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: provider: logProvider, channel: channelLabel, modelChanges, + customModelChanges, importedCount, updatedCount, mode, @@ -258,10 +349,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: availableModelsCount, syncedAliases, modelChanges, + customModelChanges, importedCount, updatedCount, importedChanges, - logged: modelChanges.total > 0, + logged: shouldLog, models: persistedModels, importedModels, }); diff --git a/src/app/api/tunnels/ngrok/route.ts b/src/app/api/tunnels/ngrok/route.ts new file mode 100644 index 0000000000..5fad841674 --- /dev/null +++ b/src/app/api/tunnels/ngrok/route.ts @@ -0,0 +1,74 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { isAuthenticated } from "@/shared/utils/apiAuth"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { getNgrokTunnelStatus, startNgrokTunnel, stopNgrokTunnel } from "@/lib/ngrokTunnel"; + +export const dynamic = "force-dynamic"; + +const actionSchema = z.object({ + action: z.enum(["enable", "disable"]), + authToken: z.string().optional(), +}); + +function unauthorized() { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); +} + +export async function GET(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return unauthorized(); + } + + try { + const status = await getNgrokTunnelStatus(); + return NextResponse.json(status); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to load ngrok tunnel status", + }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + if (!(await isAuthenticated(request))) { + return unauthorized(); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const validation = validateBody(actionSchema, rawBody); + if (isValidationFailure(validation)) { + return validation.response; + } + + const parsed = validation.data; + + try { + const status = + parsed.action === "enable" + ? await startNgrokTunnel(parsed.authToken) + : await stopNgrokTunnel(); + + return NextResponse.json({ + success: true, + action: parsed.action, + status, + }); + } catch (error) { + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to update ngrok tunnel", + }, + { status: 500 } + ); + } +} diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index e291961e60..962ca76e07 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -478,6 +478,17 @@ export async function getUnifiedModelsResponse( const isProviderActive = (provider: string) => { if (activeAliases.size === 0) return false; // No active connections = show nothing const alias = providerIdToAlias[provider] || provider; + const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || provider; + + // FIX #1752: Ensure blocked providers are not returned for non-chat models + if ( + blockedProviders.has(alias) || + blockedProviders.has(canonicalProviderId) || + blockedProviders.has(provider) + ) { + return false; + } + return activeAliases.has(alias) || activeAliases.has(provider); }; diff --git a/src/app/api/v1beta/models/route.ts b/src/app/api/v1beta/models/route.ts index 37390e3f18..5d5aec0a8f 100644 --- a/src/app/api/v1beta/models/route.ts +++ b/src/app/api/v1beta/models/route.ts @@ -1,5 +1,9 @@ import { PROVIDER_MODELS } from "@/shared/constants/models"; -import { getAllCustomModels, getSyncedAvailableModels } from "@/lib/db/models"; +import { + getAllCustomModels, + getAllSyncedAvailableModels, + getSyncedAvailableModels, +} from "@/lib/db/models"; import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; import { getSyncedCapabilities } from "@/lib/modelsDevSync"; @@ -67,6 +71,46 @@ export async function GET() { console.error("[v1beta/models] Error fetching synced Gemini models:", err); } + const existingNames = new Set(models.map((model) => (model as any).name)); + + // Synced/imported models for non-Gemini providers + try { + const syncedModelsMap = await getAllSyncedAvailableModels(); + for (const [providerId, syncedModels] of Object.entries(syncedModelsMap)) { + if (providerId === "gemini") continue; + if (!Array.isArray(syncedModels)) continue; + for (const m of syncedModels) { + if (!m || typeof m.id !== "string") continue; + const name = `models/${providerId}/${m.id}`; + if (existingNames.has(name)) continue; + const resolved = getResolvedModelCapabilities({ + provider: providerId, + model: m.id, + }); + models.push({ + name, + displayName: m.name || m.id, + ...(typeof m.description === "string" ? { description: m.description } : {}), + supportedGenerationMethods: ["generateContent"], + inputTokenLimit: + typeof m.inputTokenLimit === "number" + ? m.inputTokenLimit + : resolved.maxInputTokens || resolved.contextWindow || 128000, + outputTokenLimit: + typeof m.outputTokenLimit === "number" + ? m.outputTokenLimit + : resolved.maxOutputTokens || 8192, + ...(m.supportsThinking === true || resolved.supportsThinking === true + ? { thinking: true } + : {}), + }); + existingNames.add(name); + } + } + } catch { + // Synced models are optional — skip on error + } + // Custom models (use stored metadata from provider APIs) try { const customModelsMap = (await getAllCustomModels()) as Record; @@ -83,8 +127,10 @@ export async function GET() { provider: providerId, model: String(m.id), }); + const name = `models/${providerId}/${m.id}`; + if (existingNames.has(name)) continue; models.push({ - name: `models/${providerId}/${m.id}`, + name, displayName: m.name || m.id, ...(typeof m.description === "string" ? { description: m.description } : {}), supportedGenerationMethods: ["generateContent"], @@ -100,6 +146,7 @@ export async function GET() { ? { thinking: true } : {}), }); + existingNames.add(name); } } } catch { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 37d18947c6..8f7028f435 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1981,7 +1981,24 @@ "tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.", "tailscaleSudoPlaceholder": "Optional sudo password", "tailscaleInstalling": "Installing", - "tailscaleSudoLabel": "Sudo Password (required on macOS/Linux)" + "tailscaleSudoLabel": "Sudo Password (required on macOS/Linux)", + "ngrokTitle": "ngrok Tunnel", + "ngrokRunning": "Running", + "ngrokStarting": "Starting", + "ngrokStoppedState": "Stopped", + "ngrokNeedsAuth": "Needs Auth", + "ngrokNotInstalled": "Not installed", + "ngrokUnsupported": "Unsupported", + "ngrokError": "Error", + "ngrokEnable": "Enable Tunnel", + "ngrokDisable": "Stop Tunnel", + "ngrokUrlNotice": "Creates a public ngrok tunnel.", + "ngrokAuthTokenLabel": "Authtoken (Required if NGROK_AUTHTOKEN not set)", + "ngrokAuthTokenPlaceholder": "Enter your ngrok authtoken", + "ngrokLastError": "Last error: {error}", + "ngrokStarted": "ngrok tunnel started", + "ngrokStopped": "ngrok tunnel stopped", + "ngrokRequestFailed": "Failed to update ngrok tunnel" }, "endpoints": { "tabProxy": "Endpoint Proxy", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 38a2c5748a..f796253b88 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -1903,7 +1903,24 @@ "tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.", "tailscaleSudoPlaceholder": "Optional sudo password", "tailscaleInstalling": "Installing", - "tailscaleSudoLabel": "Senha Sudo (obrigatória em macOS/Linux)" + "tailscaleSudoLabel": "Senha Sudo (obrigatória em macOS/Linux)", + "ngrokTitle": "Túnel ngrok", + "ngrokRunning": "Rodando", + "ngrokStarting": "Iniciando", + "ngrokStoppedState": "Parado", + "ngrokNeedsAuth": "Requer Autenticação", + "ngrokNotInstalled": "Não instalado", + "ngrokUnsupported": "Não suportado", + "ngrokError": "Erro", + "ngrokEnable": "Habilitar Túnel", + "ngrokDisable": "Parar Túnel", + "ngrokUrlNotice": "Cria um túnel público ngrok.", + "ngrokAuthTokenLabel": "Authtoken (Necessário caso NGROK_AUTHTOKEN não esteja definido)", + "ngrokAuthTokenPlaceholder": "Digite seu authtoken do ngrok", + "ngrokLastError": "Último erro: {error}", + "ngrokStarted": "Túnel ngrok iniciado", + "ngrokStopped": "Túnel ngrok parado", + "ngrokRequestFailed": "Falha ao atualizar túnel ngrok" }, "endpoints": { "tabProxy": "Proxy de terminal", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 22f68a3e9c..e27f865647 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1875,7 +1875,24 @@ "tailscaleInstallIntro": "Installs Tailscale on this machine and prepares OmniRoute to enable Funnel.", "tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.", "tailscaleSudoPlaceholder": "Optional sudo password", - "tailscaleInstalling": "Installing" + "tailscaleInstalling": "Installing", + "ngrokTitle": "Túnel ngrok", + "ngrokRunning": "Rodando", + "ngrokStarting": "Iniciando", + "ngrokStoppedState": "Parado", + "ngrokNeedsAuth": "Requer Autenticação", + "ngrokNotInstalled": "Não instalado", + "ngrokUnsupported": "Não suportado", + "ngrokError": "Erro", + "ngrokEnable": "Habilitar Túnel", + "ngrokDisable": "Parar Túnel", + "ngrokUrlNotice": "Cria um túnel público ngrok.", + "ngrokAuthTokenLabel": "Authtoken (Necessário caso NGROK_AUTHTOKEN não esteja definido)", + "ngrokAuthTokenPlaceholder": "Digite seu authtoken do ngrok", + "ngrokLastError": "Último erro: {error}", + "ngrokStarted": "Túnel ngrok iniciado", + "ngrokStopped": "Túnel ngrok parado", + "ngrokRequestFailed": "Falha ao atualizar túnel ngrok" }, "endpoints": { "tabProxy": "Endpoint Proxy", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 095288da03..429d83ecac 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1335,13 +1335,17 @@ } } }, - "autoConfiguredTab": "Auto Configured Tab", - "toolCategoriesDesc": "Tool Categories Desc", - "allToolsTab": "All Tools Tab", - "guidedClientsTab": "Guided Clients Tab", - "mitmClientsTab": "Mitm Clients Tab", - "toolCategories": "Tool Categories", - "visibleToolsCount": "Visible Tools Count" + "autoConfiguredTab": "自动配置", + "toolCategoriesDesc": "配置 AI 编程助手通过 OmniRoute 路由", + "allToolsTab": "所有工具", + "guidedClientsTab": "引导客户端", + "mitmClientsTab": "MITM 客户端", + "customCliTab": "自定义 CLI", + "toolCategories": "工具分类", + "visibleToolsCount": "{count} 个工具可用", + "installationGuide": "安装指南", + "whenToUseLabel": "何时使用", + "openToolDocs": "打开工具文档" }, "combos": { "title": "组合", @@ -4325,7 +4329,9 @@ "addMemory": "添加记忆", "type": "类型", "key": "键", + "keyPlaceholder": "例如:user_preference_theme", "content": "内容", + "contentPlaceholder": "例如:偏好深色模式", "created": "创建时间", "actions": "操作", "delete": "删除", @@ -4333,7 +4339,16 @@ "episodic": "情景型", "procedural": "程序型", "semantic": "语义型", - "a": "A" + "previous": "上一页", + "next": "下一页", + "pageInfo": "第 {page} 页,共 {totalPages} 页(共 {total} 条)", + "checkingHealth": "检查中...", + "checkHealth": "检查健康", + "pipelineOk": "Pipeline 正常 ({latencyMs}ms)", + "pipelineError": "Pipeline 错误", + "healthUnknown": "健康状态未知", + "cancel": "取消", + "save": "保存" }, "skills": { "title": "技能", diff --git a/src/lib/combos/builderOptions.ts b/src/lib/combos/builderOptions.ts index 0aea042b83..6bb17a8a71 100644 --- a/src/lib/combos/builderOptions.ts +++ b/src/lib/combos/builderOptions.ts @@ -21,7 +21,7 @@ import type { RegistryModel } from "@omniroute/open-sse/config/providerRegistry. type JsonRecord = Record; -type BuilderModelSource = "api-sync" | "system" | "custom" | "fallback"; +type BuilderModelSource = "imported" | "system" | "custom" | "fallback"; type BuilderConnectionStatus = "active" | "inactive" | "rate-limited" | "error"; type ProviderVisual = { icon: string; color: string; source: "system" | "provider-node" }; @@ -159,7 +159,7 @@ function isChatCapable(supportedEndpoints: string[] | undefined): boolean { function getSourcePriority(source: BuilderModelSource): number { switch (source) { - case "api-sync": + case "imported": return 0; case "system": return 1; @@ -412,7 +412,7 @@ export async function getComboBuilderOptions(): Promise 0 ? value.trim() : null; +} + function getKeyValue(row: unknown): { key: string | null; value: string | null } { const record = asRecord(row); return { @@ -378,7 +382,7 @@ export async function addCustomModel( } /** - * Replace the entire custom models list for a provider (used by auto-sync). + * Replace the entire custom models list for a provider. * Preserves per-model compatibility overrides for models that still exist. */ export async function replaceCustomModels( @@ -397,7 +401,7 @@ export async function replaceCustomModels( { allowEmpty = false }: { allowEmpty?: boolean } = {} ) { // Guard: skip destructive clear when the caller hasn't explicitly opted in. - // This prevents auto-sync from wiping manually-imported models when the + // This prevents callers from wiping manually added models when the // upstream /models endpoint fails, times out, or returns an empty list. if (models.length === 0 && !allowEmpty) { const existing = await getCustomModels(providerId); @@ -520,7 +524,7 @@ export async function removeCustomModel(providerId: string, modelId: string) { export interface SyncedAvailableModel { id: string; name: string; - source: "api-sync"; + source: "imported"; supportedEndpoints?: string[]; inputTokenLimit?: number; outputTokenLimit?: number; @@ -528,6 +532,57 @@ export interface SyncedAvailableModel { supportsThinking?: boolean; } +type SyncedAvailableModelInput = Omit & { + source?: string; +}; + +function normalizeSyncedAvailableModel(model: unknown): SyncedAvailableModel | null { + const record = asRecord(model); + const id = + toNonEmptyString(record.id) || toNonEmptyString(record.name) || toNonEmptyString(record.model); + if (!id) return null; + + const name = + toNonEmptyString(record.name) || + toNonEmptyString(record.displayName) || + toNonEmptyString(record.model) || + id; + const supportedEndpoints = Array.isArray(record.supportedEndpoints) + ? Array.from( + new Set( + record.supportedEndpoints + .map((endpoint) => toNonEmptyString(endpoint)) + .filter((endpoint): endpoint is string => Boolean(endpoint)) + ) + ).sort() + : undefined; + + return { + id, + name, + source: "imported", + ...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}), + ...(typeof record.inputTokenLimit === "number" + ? { inputTokenLimit: record.inputTokenLimit } + : {}), + ...(typeof record.outputTokenLimit === "number" + ? { outputTokenLimit: record.outputTokenLimit } + : {}), + ...(typeof record.description === "string" ? { description: record.description } : {}), + ...(record.supportsThinking === true ? { supportsThinking: true } : {}), + }; +} + +function normalizeSyncedAvailableModels(models: unknown): SyncedAvailableModel[] { + if (!Array.isArray(models)) return []; + const deduped = new Map(); + for (const model of models) { + const normalized = normalizeSyncedAvailableModel(model); + if (normalized) deduped.set(normalized.id, normalized); + } + return Array.from(deduped.values()); +} + /** * Get synced available models for a specific provider connection. */ @@ -544,7 +599,7 @@ export async function getSyncedAvailableModelsForConnection( if (!value) return []; try { const models = JSON.parse(value); - return Array.isArray(models) ? models : []; + return normalizeSyncedAvailableModels(models); } catch { return []; } @@ -566,7 +621,7 @@ export async function getSyncedAvailableModels( for (const row of rows) { const { key, value } = getKeyValue(row); if (!key || value === null) continue; - const models: SyncedAvailableModel[] = JSON.parse(value); + const models = normalizeSyncedAvailableModels(JSON.parse(value)); for (const m of models) { if (m.id) map.set(m.id, m); } @@ -591,7 +646,7 @@ export async function getAllSyncedAvailableModels(): Promise< if (!key || value === null) continue; const providerId = key.split(":")[0]; if (!byProvider.has(providerId)) byProvider.set(providerId, new Map()); - const models: SyncedAvailableModel[] = JSON.parse(value); + const models = normalizeSyncedAvailableModels(JSON.parse(value)); const map = byProvider.get(providerId)!; for (const m of models) { if (m.id) map.set(m.id, m); @@ -611,18 +666,19 @@ export async function getAllSyncedAvailableModels(): Promise< export async function replaceSyncedAvailableModelsForConnection( providerId: string, connectionId: string, - models: SyncedAvailableModel[] + models: SyncedAvailableModelInput[] ): Promise { const db = getDbInstance(); const key = `${providerId}:${connectionId}`; - if (models.length === 0) { + const normalizedModels = normalizeSyncedAvailableModels(models); + if (normalizedModels.length === 0) { db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run( key ); } else { db.prepare( "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)" - ).run(key, JSON.stringify(models)); + ).run(key, JSON.stringify(normalizedModels)); } backupDbFile("pre-write"); // Return the full unioned list for the provider diff --git a/src/lib/memory/store.ts b/src/lib/memory/store.ts index df9f554f55..e724a0f2cf 100644 --- a/src/lib/memory/store.ts +++ b/src/lib/memory/store.ts @@ -77,15 +77,78 @@ function rowToMemory(row: MemoryRow): Memory { } /** - * Create a new memory entry + * Find existing memory by apiKeyId and key (for UPSERT logic) + */ +function findExistingMemory( + db: ReturnType, + apiKeyId: string, + key: string +): MemoryRow | undefined { + if (!key) return undefined; + const stmt = db.prepare( + "SELECT * FROM memories WHERE api_key_id = ? AND key = ? ORDER BY created_at DESC LIMIT 1" + ); + return stmt.get(apiKeyId, key) as MemoryRow | undefined; +} + +/** + * Create a new memory entry (UPSERT: updates existing if same apiKeyId + key) */ export async function createMemory( memory: Omit ): Promise { const db = getDbInstance(); - const id = crypto.randomUUID(); const now = new Date().toISOString(); + // Check for existing memory with same apiKeyId + key (UPSERT logic) + const existing = memory.key ? findExistingMemory(db, memory.apiKeyId, memory.key) : undefined; + + if (existing) { + // UPDATE existing record + const updatedMetadata = { ...parseJSON(existing.metadata), ...memory.metadata }; + const stmt = db.prepare( + "UPDATE memories SET content = ?, metadata = ?, updated_at = ?, session_id = ?, type = ?, expires_at = ? WHERE id = ?" + ); + stmt.run( + memory.content, + JSON.stringify(updatedMetadata), + now, + memory.sessionId, + memory.type, + memory.expiresAt ?? null, + existing.id + ); + + const updatedMemory: Memory = { + id: String(existing.id), + apiKeyId: memory.apiKeyId, + sessionId: memory.sessionId, + type: memory.type, + key: memory.key, + content: memory.content, + metadata: updatedMetadata, + createdAt: new Date(String(existing.created_at)), + updatedAt: new Date(now), + expiresAt: memory.expiresAt ?? null, + }; + + // Invalidate and update cache + invalidateMemoryCache(existing.id); + evictIfNeeded(_memoryCache); + _memoryCache.set(existing.id, { value: updatedMemory, timestamp: Date.now() }); + + log.info("memory.updated", { + apiKeyId: memory.apiKeyId, + type: memory.type, + id: existing.id, + key: memory.key, + }); + + return updatedMemory; + } + + // INSERT new record if not exists + const id = crypto.randomUUID(); const stmt = db.prepare( "INSERT INTO memories (id, api_key_id, session_id, type, key, content, metadata, created_at, updated_at, expires_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" diff --git a/src/lib/ngrokTunnel.ts b/src/lib/ngrokTunnel.ts new file mode 100644 index 0000000000..369c3a8929 --- /dev/null +++ b/src/lib/ngrokTunnel.ts @@ -0,0 +1,142 @@ +import { getRuntimePorts } from "@/lib/runtime/ports"; + +export type TunnelPhase = + | "unsupported" + | "not_installed" + | "stopped" + | "needs_auth" + | "starting" + | "running" + | "error"; + +export type NgrokTunnelStatus = { + supported: boolean; + installed: boolean; + running: boolean; + publicUrl: string | null; + apiUrl: string | null; + targetUrl: string; + phase: TunnelPhase; + lastError: string | null; +}; + +// Next.js hot-reloading safe global storage for the listener +const globalForNgrok = globalThis as unknown as { + __ngrokListener: any; +}; + +let startPromise: Promise | null = null; + +function getLocalTargetUrl() { + const { apiPort } = getRuntimePorts(); + return `http://127.0.0.1:${apiPort}`; +} + +function getTunnelApiUrl(publicUrl: string | null) { + return publicUrl ? `${publicUrl.replace(/\/$/, "")}/v1` : null; +} + +export async function getNgrokTunnelStatus(): Promise { + const targetUrl = getLocalTargetUrl(); + const tokenAvailable = !!( + process.env.NGROK_AUTHTOKEN && process.env.NGROK_AUTHTOKEN.trim() !== "" + ); + const listener = globalForNgrok.__ngrokListener; + let currentUrl = null; + + if (listener) { + try { + currentUrl = typeof listener.url === "function" ? listener.url() : listener.url; + } catch { + // Ignored + } + } + + return { + supported: true, + installed: true, + running: currentUrl !== null, + publicUrl: currentUrl || null, + apiUrl: currentUrl ? getTunnelApiUrl(currentUrl) : null, + targetUrl, + phase: currentUrl === null ? (tokenAvailable ? "stopped" : "needs_auth") : "running", + lastError: null, + }; +} + +export async function startNgrokTunnel(inputAuthToken?: string): Promise { + const current = await getNgrokTunnelStatus(); + if (current.running) return current; + if (startPromise) return startPromise; + + startPromise = (async () => { + try { + const authToken = + inputAuthToken && inputAuthToken.trim() !== "" + ? inputAuthToken.trim() + : process.env.NGROK_AUTHTOKEN; + + if (!authToken) { + return { + ...(await getNgrokTunnelStatus()), + phase: "needs_auth", + lastError: "An ngrok authtoken is required.", + }; + } + + // Dynamically import ngrok so it doesn't break environments where native build fails if not used + const ngrok = await import("@ngrok/ngrok"); + + const targetUrl = getLocalTargetUrl(); + const listenerOptions: any = { addr: targetUrl }; + + if (!inputAuthToken && process.env.NGROK_AUTHTOKEN) { + listenerOptions.authtoken_from_env = true; + } else { + listenerOptions.authtoken = authToken; + } + + const listener = await ngrok.forward(listenerOptions); + globalForNgrok.__ngrokListener = listener; + + let url = null; + try { + url = typeof listener.url === "function" ? listener.url() : listener.url; + } catch { + // Ignored + } + + if (!url) { + await stopNgrokTunnel(); + throw new Error("ngrok did not return a public URL."); + } + + return await getNgrokTunnelStatus(); + } catch (error) { + return { + ...(await getNgrokTunnelStatus()), + phase: "error", + lastError: error instanceof Error ? error.message : String(error), + }; + } finally { + startPromise = null; + } + })(); + + return startPromise; +} + +export async function stopNgrokTunnel(): Promise { + const listener = globalForNgrok.__ngrokListener; + if (listener) { + try { + if (typeof listener.close === "function") { + await listener.close(); + } + } catch (e) { + // Ignore close errors + } + globalForNgrok.__ngrokListener = undefined; + } + return await getNgrokTunnelStatus(); +} diff --git a/src/lib/oauth/providers.ts b/src/lib/oauth/providers.ts index e00cd994ec..5709042630 100644 --- a/src/lib/oauth/providers.ts +++ b/src/lib/oauth/providers.ts @@ -81,12 +81,12 @@ export async function exchangeTokens(providerName, code, redirectUri, codeVerifi /** * Request device code (for device_code flow) */ -export async function requestDeviceCode(providerName, codeChallenge) { +export async function requestDeviceCode(providerName, codeChallenge, configOverride = null) { const provider = getProvider(providerName); if (provider.flowType !== "device_code") { throw new Error(`Provider ${providerName} does not support device code flow`); } - return await provider.requestDeviceCode(provider.config, codeChallenge); + return await provider.requestDeviceCode(configOverride || provider.config, codeChallenge); } /** diff --git a/src/lib/oauth/providers/kiro.ts b/src/lib/oauth/providers/kiro.ts index 945c75cd74..80c03a7d81 100644 --- a/src/lib/oauth/providers/kiro.ts +++ b/src/lib/oauth/providers/kiro.ts @@ -4,6 +4,27 @@ export const kiro = { config: KIRO_CONFIG, flowType: "device_code", requestDeviceCode: async (config) => { + const regionMatch = String(config.tokenUrl || "").match(/oidc\.([a-z0-9-]+)\.amazonaws\.com/i); + const resolvedRegion = regionMatch?.[1] || "us-east-1"; + const registerPayload: { + clientName: string; + clientType: string; + scopes: string[]; + grantTypes: string[]; + issuerUrl?: string; + } = { + clientName: config.clientName, + clientType: config.clientType, + scopes: config.scopes, + grantTypes: config.grantTypes, + }; + + // For enterprise IDC custom startUrl flows, issuerUrl can differ per tenant. + // Sending a fixed issuerUrl often causes invalid_request during device auth. + if (config.issuerUrl && !config.skipIssuerUrlForRegistration) { + registerPayload.issuerUrl = config.issuerUrl; + } + // Step 1: Register client with AWS SSO OIDC const registerRes = await fetch(config.registerClientUrl, { method: "POST", @@ -11,13 +32,7 @@ export const kiro = { "Content-Type": "application/json", Accept: "application/json", }, - body: JSON.stringify({ - clientName: config.clientName, - clientType: config.clientType, - scopes: config.scopes, - grantTypes: config.grantTypes, - issuerUrl: config.issuerUrl, - }), + body: JSON.stringify(registerPayload), }); if (!registerRes.ok) { @@ -57,10 +72,14 @@ export const kiro = { interval: deviceData.interval || 5, _clientId: clientInfo.clientId, _clientSecret: clientInfo.clientSecret, + _region: resolvedRegion, }; }, pollToken: async (config, deviceCode, codeVerifier, extraData) => { - const response = await fetch(config.tokenUrl, { + const tokenRegion = extraData?._region || "us-east-1"; + const tokenUrl = `https://oidc.${tokenRegion}.amazonaws.com/token`; + + const response = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/json", @@ -91,6 +110,7 @@ export const kiro = { expires_in: data.expiresIn, _clientId: extraData?._clientId, _clientSecret: extraData?._clientSecret, + _region: tokenRegion, }, }; } @@ -110,6 +130,7 @@ export const kiro = { providerSpecificData: { clientId: tokens._clientId, clientSecret: tokens._clientSecret, + region: tokens._region, }, }), }; diff --git a/src/lib/providerModels/managedModelImport.ts b/src/lib/providerModels/managedModelImport.ts index 41a2555cec..227d96f9ae 100644 --- a/src/lib/providerModels/managedModelImport.ts +++ b/src/lib/providerModels/managedModelImport.ts @@ -1,7 +1,10 @@ import { getCustomModels, + getSyncedAvailableModelsForConnection, + mergeModelCompatOverride, replaceCustomModels, replaceSyncedAvailableModelsForConnection, + type ModelCompatPatch, type SyncedAvailableModel, } from "@/lib/db/models"; import { @@ -9,7 +12,6 @@ import { usesManagedAvailableModels, } from "@/lib/providerModels/managedAvailableModels"; import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery"; -import { getModelsByProviderId } from "@/shared/constants/models"; type JsonRecord = Record; @@ -18,7 +20,7 @@ export type ManagedModelImportMode = "merge" | "sync"; export type ManagedImportedModel = { id: string; name: string; - source: "api-sync"; + source: "imported"; apiFormat: "chat-completions"; supportedEndpoints?: string[]; inputTokenLimit?: number; @@ -34,42 +36,39 @@ function toNonEmptyString(value: unknown): string | null { function normalizeManagedSource(source: unknown): string { const normalized = toNonEmptyString(source)?.toLowerCase(); if (normalized === "api-sync" || normalized === "auto-sync" || normalized === "imported") { - return "api-sync"; + return "imported"; } return normalized || "manual"; } -function normalizeImportedModels( - providerId: string, - fetchedModels: unknown -): ManagedImportedModel[] { +function normalizeImportedModels(fetchedModels: unknown): ManagedImportedModel[] { const discovered = normalizeDiscoveredModels(fetchedModels); - const registryIds = new Set(getModelsByProviderId(providerId).map((model: any) => model.id)); - return discovered - .filter((model) => !registryIds.has(model.id)) - .map((model) => ({ - id: model.id, - name: model.name || model.id, - source: "api-sync", - apiFormat: "chat-completions", - ...(Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0 - ? { supportedEndpoints: model.supportedEndpoints } - : {}), - ...(typeof model.inputTokenLimit === "number" - ? { inputTokenLimit: model.inputTokenLimit } - : {}), - ...(typeof model.outputTokenLimit === "number" - ? { outputTokenLimit: model.outputTokenLimit } - : {}), - ...(typeof model.description === "string" ? { description: model.description } : {}), - ...(model.supportsThinking === true ? { supportsThinking: true } : {}), - })); + return discovered.map((model) => ({ + id: model.id, + name: model.name || model.id, + source: "imported", + apiFormat: "chat-completions", + ...(Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0 + ? { supportedEndpoints: model.supportedEndpoints } + : {}), + ...(typeof model.inputTokenLimit === "number" + ? { inputTokenLimit: model.inputTokenLimit } + : {}), + ...(typeof model.outputTokenLimit === "number" + ? { outputTokenLimit: model.outputTokenLimit } + : {}), + ...(typeof model.description === "string" ? { description: model.description } : {}), + ...(model.supportsThinking === true ? { supportsThinking: true } : {}), + })); } -function isManagedDiscoveredSource(source: unknown): boolean { - const normalized = toNonEmptyString(source)?.toLowerCase(); - return normalized === "api-sync" || normalized === "auto-sync" || normalized === "imported"; +function isImportedSource(source: unknown): boolean { + return normalizeManagedSource(source) === "imported"; +} + +function getModelId(model: JsonRecord): string | null { + return toNonEmptyString(model.id); } function summarizeImportedChanges( @@ -86,9 +85,30 @@ function summarizeImportedChanges( const toComparable = (model: JsonRecord | undefined) => { if (!model) return null; + const id = toNonEmptyString(model.id) || ""; + const supportedEndpoints = Array.isArray(model.supportedEndpoints) + ? Array.from( + new Set( + model.supportedEndpoints + .map((endpoint) => toNonEmptyString(endpoint)) + .filter((endpoint): endpoint is string => Boolean(endpoint)) + ) + ).sort() + : ["chat"]; return { - ...model, + id, + name: toNonEmptyString(model.name) || id, source: normalizeManagedSource(model.source), + apiFormat: toNonEmptyString(model.apiFormat) || "chat-completions", + supportedEndpoints, + ...(typeof model.inputTokenLimit === "number" + ? { inputTokenLimit: model.inputTokenLimit } + : {}), + ...(typeof model.outputTokenLimit === "number" + ? { outputTokenLimit: model.outputTokenLimit } + : {}), + ...(typeof model.description === "string" ? { description: model.description } : {}), + ...(model.supportsThinking === true ? { supportsThinking: true } : {}), }; }; @@ -125,37 +145,71 @@ function collectAddedImportedModels( return importedModels.filter((model) => !previousIds.has(model.id)); } +function getCompatPatchFromCustomModel(model: JsonRecord): ModelCompatPatch | null { + const patch: ModelCompatPatch = {}; + + if (typeof model.normalizeToolCallId === "boolean") { + patch.normalizeToolCallId = model.normalizeToolCallId; + } + if (typeof model.preserveOpenAIDeveloperRole === "boolean") { + patch.preserveOpenAIDeveloperRole = model.preserveOpenAIDeveloperRole; + } + if (typeof model.isHidden === "boolean") { + patch.isHidden = model.isHidden; + } + if (model.compatByProtocol && typeof model.compatByProtocol === "object") { + patch.compatByProtocol = model.compatByProtocol as ModelCompatPatch["compatByProtocol"]; + } + if (model.upstreamHeaders && typeof model.upstreamHeaders === "object") { + patch.upstreamHeaders = model.upstreamHeaders as Record; + } + + return Object.keys(patch).length > 0 ? patch : null; +} + +function preserveRemovedCustomModelCompat(providerId: string, removedModels: JsonRecord[]) { + for (const model of removedModels) { + const modelId = getModelId(model); + if (!modelId) continue; + const patch = getCompatPatchFromCustomModel(model); + if (!patch) continue; + mergeModelCompatOverride(providerId, modelId, patch); + } +} + export async function importManagedModels({ providerId, connectionId, fetchedModels, mode, + previousSyncedAvailableModels: previousSyncedAvailableModelsInput, }: { providerId: string; connectionId: string; fetchedModels: unknown; mode: ManagedModelImportMode; + previousSyncedAvailableModels?: SyncedAvailableModel[]; }) { const previousModels = (await getCustomModels(providerId)) as JsonRecord[]; - const candidateImportedModels = normalizeImportedModels(providerId, fetchedModels); + const previousSyncedAvailableModels = + previousSyncedAvailableModelsInput ?? + (await getSyncedAvailableModelsForConnection(providerId, connectionId)); + const discoveredModels = normalizeDiscoveredModels(fetchedModels); + const candidateImportedModels = normalizeImportedModels(fetchedModels); const importedIds = new Set(candidateImportedModels.map((model) => model.id)); + const discoveredIds = new Set(discoveredModels.map((model) => model.id)); const nextModelsMap = new Map(); + const removedCustomModels: JsonRecord[] = []; - if (mode === "merge") { - for (const model of previousModels) { - if (model?.id) nextModelsMap.set(String(model.id), model); + for (const model of previousModels) { + const modelId = getModelId(model); + if (!modelId) continue; + if (isImportedSource(model.source) || discoveredIds.has(modelId)) { + removedCustomModels.push(model); + continue; } - } else { - for (const model of previousModels) { - if (!model?.id) continue; - if (isManagedDiscoveredSource(model.source)) continue; - nextModelsMap.set(String(model.id), model); - } - } - - for (const model of candidateImportedModels) { - nextModelsMap.set(model.id, model); + nextModelsMap.set(modelId, model); } const persistedModels = (await replaceCustomModels( @@ -170,11 +224,12 @@ export async function importManagedModels({ outputTokenLimit?: number; description?: string; supportsThinking?: boolean; - }> + }>, + { allowEmpty: true } )) as JsonRecord[]; + preserveRemovedCustomModelCompat(providerId, removedCustomModels); - const discoveredModels = normalizeDiscoveredModels(fetchedModels); - let syncedAvailableModels: SyncedAvailableModel[] = []; + let syncedAvailableModels: SyncedAvailableModel[] = previousSyncedAvailableModels; if (discoveredModels.length > 0) { syncedAvailableModels = await replaceSyncedAvailableModelsForConnection( providerId, @@ -184,24 +239,28 @@ export async function importManagedModels({ } let syncedAliases = 0; - if (usesManagedAvailableModels(providerId)) { + if (usesManagedAvailableModels(providerId) && (mode === "merge" || discoveredModels.length > 0)) { const aliasSync = await syncManagedAvailableModelAliases( providerId, - mode === "sync" - ? persistedModels - .map((model) => toNonEmptyString(model.id)) - .filter((modelId): modelId is string => Boolean(modelId)) - : candidateImportedModels.map((model) => model.id), + discoveredModels.map((model) => model.id), { pruneMissing: mode === "sync" } ); syncedAliases = aliasSync.assignedAliases.length; } - const importedChanges = summarizeImportedChanges(previousModels, persistedModels, importedIds); - const importedModels = collectAddedImportedModels(previousModels, candidateImportedModels); + const importedChanges = summarizeImportedChanges( + previousSyncedAvailableModels as JsonRecord[], + discoveredModels as JsonRecord[], + importedIds + ); + const importedModels = collectAddedImportedModels( + previousSyncedAvailableModels as JsonRecord[], + candidateImportedModels + ); return { previousModels, + previousSyncedAvailableModels, persistedModels, importedModels, discoveredModels, diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts index 0ed9dbcca5..89235b566a 100644 --- a/src/lib/providerModels/modelDiscovery.ts +++ b/src/lib/providerModels/modelDiscovery.ts @@ -48,7 +48,7 @@ export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel deduped.set(id, { id, name, - source: "api-sync", + source: "imported", ...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}), ...(typeof record.inputTokenLimit === "number" ? { inputTokenLimit: record.inputTokenLimit } diff --git a/src/shared/components/KiroAuthModal.tsx b/src/shared/components/KiroAuthModal.tsx index 5a49d0d5bf..588771f40f 100644 --- a/src/shared/components/KiroAuthModal.tsx +++ b/src/shared/components/KiroAuthModal.tsx @@ -138,17 +138,19 @@ export default function KiroAuthModal({
- {/* AWS IAM Identity Center (IDC) - HIDDEN */} + {/* AWS IAM Identity Center (IDC) */}