From 1f628e83faae3a09bbb2bbecf017fd568685060d Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 24 May 2026 20:40:48 -0300 Subject: [PATCH 01/77] chore: bump version to 3.8.4 --- docs/SUBMIT_PR.md | 127 ++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- 3 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 docs/SUBMIT_PR.md diff --git a/docs/SUBMIT_PR.md b/docs/SUBMIT_PR.md new file mode 100644 index 000000000000..fbda682bb678 --- /dev/null +++ b/docs/SUBMIT_PR.md @@ -0,0 +1,127 @@ +# Submitting a Pull Request + +Step-by-step for contributors who already have a fork and a working fix. + +--- + +## 1 — Set up your fork + +```bash +# Clone your fork +git clone https://github.com//OmniRoute.git +cd OmniRoute + +# Add the upstream repo so you can sync +git remote add upstream https://github.com/diegosouzapw/OmniRoute.git + +# Install dependencies (.env is auto-created from .env.example) +npm install +``` + +--- + +## 2 — Sync with the current release branch + +PRs go to **`release/v3.8.3`**, not `main`. + +```bash +git fetch upstream +git checkout -b fix/your-description upstream/release/v3.8.3 +``` + +If you already made your changes on another branch, rebase on top of it: + +```bash +git fetch upstream +git rebase upstream/release/v3.8.3 +``` + +--- + +## 3 — Branch naming + +| Prefix | Use for | +| ----------- | --------------------------------------- | +| `feat/` | new feature | +| `fix/` | bug fix | +| `refactor/` | code restructuring (no behavior change) | +| `docs/` | documentation only | +| `test/` | tests only | +| `chore/` | tooling, deps, CI | + +Examples: `fix/codex-token-refresh`, `feat/provider-xyz`, `docs/update-readme` + +--- + +## 4 — Validate before committing + +```bash +npm run lint # must pass (0 errors) +npm run typecheck:core # must pass +npm run test:unit # must pass +npm run test:coverage # coverage gate: 75/75/75/70 +``` + +If you changed production code in `src/`, `open-sse/`, `electron/`, or `bin/`, include or update tests in the same PR. + +--- + +## 5 — Commit + +Follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(dashboard): add provider search filter +fix(combo): resolve pending request leak on timeout +docs(readme): update installation steps +test(auth): add JWT expiry edge case +``` + +Common scopes: `api`, `dashboard`, `db`, `sse`, `oauth`, `providers`, `combo`, `mcp`, `cli`, `i18n` + +--- + +## 6 — Push and open the PR + +```bash +git push -u origin fix/your-description +``` + +Then open a PR on GitHub targeting **`diegosouzapw/OmniRoute`** → **`release/v3.8.3`**. + +PR description checklist: + +- [ ] What the change does (1–3 bullets) +- [ ] How to test it +- [ ] Test files added or updated (if production code changed) + +--- + +## 7 — After opening the PR + +- CI runs lint + typecheck + tests automatically. +- Address review comments with new commits (do not force-push after review starts). +- If the base branch advances, sync with: + +```bash +git fetch upstream +git rebase upstream/release/v3.8.3 +git push --force-with-lease +``` + +--- + +## Quick reference + +```bash +# Full validation in one command +npm run lint && npm run typecheck:core && npm run test:coverage + +# Run a single test file +node --import tsx/esm --test tests/unit/your-file.test.ts + +# Start dev server +npm run dev # http://localhost:20128 +``` + +For the full contributor guide see [CONTRIBUTING.md](../CONTRIBUTING.md). diff --git a/package-lock.json b/package-lock.json index 55d42541b97b..aad6b8239a07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "3.8.3", + "version": "3.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "3.8.3", + "version": "3.8.4", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index f67d93a416bc..351add4b6c59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "3.8.3", + "version": "3.8.4", "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { From 4df424498b644074720d5d5654f4380fbb336746 Mon Sep 17 00:00:00 2001 From: Ronaldo Davi Date: Sun, 24 May 2026 20:41:07 -0300 Subject: [PATCH 02/77] feat(providers): enhance Google Gemini, CLI, and Antigravity resilience and features (#2676) Integrated into release/v3.8.4 --- open-sse/config/providerRegistry.ts | 40 +++- open-sse/executors/gemini-cli.ts | 106 +++++++++ open-sse/services/geminiCliHeaders.ts | 6 +- .../translator/request/openai-to-gemini.ts | 214 +++++++++++------- .../translator/response/gemini-to-openai.ts | 34 +++ .../unit/translator-openai-to-gemini.test.ts | 31 +++ .../translator-resp-gemini-to-openai.test.ts | 33 ++- 7 files changed, 367 insertions(+), 97 deletions(-) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 8ca8d2ffc2a1..0a0dd6628df9 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -697,9 +697,22 @@ export const REGISTRY: Record = { clientSecretEnv: "GEMINI_OAUTH_CLIENT_SECRET", clientSecretDefault: resolvePublicCred("gemini_alt"), }, - models: [], - // Models are populated from Google's API via sync-models (per API key). - // No hardcoded fallback — show nothing until a key is added. + models: [ + { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash", toolCalling: true, supportsVision: true }, + { + id: "gemini-2.0-flash-thinking-exp-01-21", + name: "Gemini 2.0 Flash Thinking", + supportsReasoning: true, + }, + { + id: "gemini-2.0-pro-exp-02-05", + name: "Gemini 2.0 Pro Experimental", + toolCalling: true, + supportsVision: true, + }, + { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro", toolCalling: true, supportsVision: true }, + { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash", toolCalling: true, supportsVision: true }, + ], }, "gemini-cli": { @@ -722,6 +735,9 @@ export const REGISTRY: Record = { clientSecretDefault: resolvePublicCred("gemini_alt"), }, models: [ + { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" }, + { id: "gemini-2.0-flash-thinking", name: "Gemini 2.0 Flash Thinking" }, + { id: "gemini-2.0-pro-exp-02-05", name: "Gemini 2.0 Pro Experimental" }, { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro" }, { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash" }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, @@ -3072,6 +3088,24 @@ export const REGISTRY: Record = { ], }, + "vertex-partner": { + id: "vertex-partner", + alias: "vp", + format: "gemini", + executor: "vertex", + baseUrl: "https://us-central1-aiplatform.googleapis.com/v1/projects", + authType: "apikey", + authHeader: "bearer", + models: [ + { id: "DeepSeek-V4-Flash", name: "DeepSeek V4 Flash" }, + { id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" }, + { id: "Qwen3.6-35B-A3B", name: "Qwen 3.6 35B A3B" }, + { id: "GLM-5.1-FP8", name: "GLM 5.1" }, + { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + ], + }, + alibaba: { id: "alibaba", alias: "ali", diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts index b1ad8f0bb6f0..6c59fd8a553d 100644 --- a/open-sse/executors/gemini-cli.ts +++ b/open-sse/executors/gemini-cli.ts @@ -372,6 +372,95 @@ export class GeminiCLIExecutor extends BaseExecutor { return envelope; } + async execute({ + model, + body, + stream, + credentials, + signal, + log, + upstreamExtraHeaders, + }: ExecuteInput) { + const fallbackCount = this.getFallbackCount(); + let lastError = null; + let lastStatus = 0; + const MAX_AUTO_RETRIES = 3; + const retryAttemptsByUrl: Record = {}; + + for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { + const url = this.buildUrl(model, stream, urlIndex); + const headers = this.buildHeaders(credentials, stream, null, model); + mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders); + + const transformed = await this.transformRequest(model, body, stream, credentials); + if (transformed instanceof Response) { + return { response: transformed, url, headers, transformedBody: body }; + } + const transformedBody = transformed; + + if (!retryAttemptsByUrl[urlIndex]) { + retryAttemptsByUrl[urlIndex] = 0; + } + + try { + log?.debug?.( + "TELEMETRY", + `[Gemini CLI] Execute - URL: ${url}, Model: ${model}, Retry: ${retryAttemptsByUrl[urlIndex]}` + ); + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal, + }); + + if (!response.ok) { + log?.warn?.( + "TELEMETRY", + `[Gemini CLI] Error Response - URL: ${url}, Status: ${response.status}` + ); + + let retryMs: number | null = null; + if (response.status === 429 || response.status === 503) { + try { + const errorBody = await response.clone().text(); + retryMs = this.parseRetryFromErrorMessage(errorBody); + } catch { + /* ignore parse error */ + } + + if ((!retryMs || retryMs <= 60000) && retryAttemptsByUrl[urlIndex] < MAX_AUTO_RETRIES) { + retryAttemptsByUrl[urlIndex]++; + const backoffMs = + retryMs || Math.min(1000 * 2 ** retryAttemptsByUrl[urlIndex], 30000); + log?.debug?.( + "RETRY", + `Gemini CLI 429 retry ${retryAttemptsByUrl[urlIndex]} after ${backoffMs}ms` + ); + await sleep(backoffMs); + urlIndex--; + continue; + } + } + } + + if (this.shouldRetry(response.status, urlIndex)) { + lastStatus = response.status; + continue; + } + + return { response, url, headers, transformedBody }; + } catch (error) { + lastError = error; + if (urlIndex + 1 < fallbackCount) continue; + throw error; + } + } + + throw lastError || new Error(`All ${fallbackCount} URLs failed with status ${lastStatus}`); + } + async refreshCredentials(credentials, log) { if (!credentials.refreshToken) return null; @@ -400,12 +489,29 @@ export class GeminiCLIExecutor extends BaseExecutor { refreshToken: tokens.refresh_token || credentials.refreshToken, expiresIn: tokens.expires_in, projectId: credentials.projectId, + providerSpecificData: credentials.providerSpecificData, }; } catch (error) { log?.error?.("TOKEN", `Gemini CLI refresh error: ${error.message}`); return null; } } + + // Parse retry time from Gemini error message body + // Format: "Your quota will reset after 2h7m23s" + parseRetryFromErrorMessage(errorMessage: unknown): number | null { + if (!errorMessage || typeof errorMessage !== "string") return null; + + const match = errorMessage.match(/reset (?:after|in) (\d+h)?(\d+m)?(\d+s)?/i); + if (!match) return null; + + let totalMs = 0; + if (match[1]) totalMs += parseInt(match[1]) * 3600 * 1000; + if (match[2]) totalMs += parseInt(match[2]) * 60 * 1000; + if (match[3]) totalMs += parseInt(match[3]) * 1000; + + return totalMs || 2_000; + } } export default GeminiCLIExecutor; diff --git a/open-sse/services/geminiCliHeaders.ts b/open-sse/services/geminiCliHeaders.ts index 7c08bb281e6d..cd7d10a1312c 100644 --- a/open-sse/services/geminiCliHeaders.ts +++ b/open-sse/services/geminiCliHeaders.ts @@ -4,11 +4,11 @@ import { normalizeCloudCodePlatform, } from "./cloudCodeHeaders.ts"; -export const GEMINI_CLI_VERSION = "0.41.2"; -export const GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION = "9.15.1"; +export const GEMINI_CLI_VERSION = "0.42.0"; +export const GEMINI_CLI_GOOGLE_API_NODE_CLIENT_VERSION = "10.3.0"; const GEMINI_CLI_LOAD_CODE_ASSIST_METADATA = Object.freeze({ - ideType: "IDE_UNSPECIFIED", + ideType: "TERMINAL", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI", }); diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 66a226bf9514..1413ef9d202b 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -184,7 +184,12 @@ function applyAntigravityGenerationDefaults(generationConfig: GeminiGenerationCo } // Core: Convert OpenAI request to Gemini format (base for all variants) -function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolNameOptions = {}) { +function openaiToGeminiBase( + model: string, + body: Record, + stream: boolean, + toolNameOptions: GeminiToolNameOptions = {} +) { const result: GeminiRequest = { model: model, contents: [], @@ -200,7 +205,7 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName // Preserve cachedContent if provided by client (for explicit Gemini caching) if (body.cachedContent) { - result.cachedContent = body.cachedContent; + result.cachedContent = body.cachedContent as string; } // Generation config @@ -216,21 +221,50 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName if (body.stop !== undefined) { result.generationConfig.stopSequences = Array.isArray(body.stop) ? body.stop : [body.stop]; } - const requestedMaxOutputTokens = body.max_tokens ?? body.max_completion_tokens; + const requestedMaxOutputTokens = (body.max_tokens ?? body.max_completion_tokens) as + | number + | undefined; if (requestedMaxOutputTokens !== undefined) { result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, requestedMaxOutputTokens); } else { result.generationConfig.maxOutputTokens = capMaxOutputTokens(model); } + // Thinking / Reasoning support (Google Gemini 2.0+ Thinking models) + // 1. OpenAI format: reasoning_effort (low/medium/high) + if (body.reasoning_effort) { + const budgetMap: Record = { + low: 1024, + medium: getDefaultThinkingBudget(model) || 8192, + high: capThinkingBudget(model, 32768), + }; + const budget = + budgetMap[body.reasoning_effort as string] || getDefaultThinkingBudget(model) || 8192; + result.generationConfig.thinkingConfig = { + thinkingBudget: budget, + includeThoughts: true, + }; + } + // 2. Claude format: thinking (type: enabled, budget_tokens) + const thinking = body.thinking as { type?: string; budget_tokens?: number } | undefined; + if (thinking?.type === "enabled" && thinking.budget_tokens) { + result.generationConfig.thinkingConfig = { + thinkingBudget: thinking.budget_tokens, + includeThoughts: true, + }; + } + // Build tool_call_id -> name map - const tcID2Name = {}; - if (body.messages && Array.isArray(body.messages)) { - for (const msg of body.messages) { - if (msg.role === "assistant" && msg.tool_calls) { - for (const tc of msg.tool_calls) { - if (tc.type === "function" && tc.id && tc.function?.name) { - tcID2Name[tc.id] = tc.function.name; + const tcID2Name: Record = {}; + const messages = body.messages as Array> | undefined; + if (messages && Array.isArray(messages)) { + for (const msg of messages) { + const toolCalls = msg.tool_calls as Array> | undefined; + if (msg.role === "assistant" && toolCalls) { + for (const tc of toolCalls) { + const fn = tc.function as { name?: string } | undefined; + if (tc.type === "function" && tc.id && fn?.name) { + tcID2Name[tc.id as string] = fn.name; } } } @@ -238,23 +272,23 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName } // Build tool responses cache - const toolResponses = {}; - if (body.messages && Array.isArray(body.messages)) { - for (const msg of body.messages) { + const toolResponses: Record = {}; + if (messages && Array.isArray(messages)) { + for (const msg of messages) { if (msg.role === "tool" && msg.tool_call_id) { - toolResponses[msg.tool_call_id] = msg.content; + toolResponses[msg.tool_call_id as string] = msg.content; } } } // Convert messages - if (body.messages && Array.isArray(body.messages)) { - for (let i = 0; i < body.messages.length; i++) { - const msg = body.messages[i]; + if (messages && Array.isArray(messages)) { + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; const role = msg.role; const content = msg.content; - if (role === "system" && body.messages.length > 1) { + if (role === "system" && messages.length > 1) { const systemText = typeof content === "string" ? content : extractTextContent(content); if (systemText) { if (!result.systemInstruction) { @@ -266,19 +300,19 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName result.systemInstruction.parts.push({ text: systemText }); } } - } else if (role === "user" || (role === "system" && body.messages.length === 1)) { + } else if (role === "user" || (role === "system" && messages.length === 1)) { const parts = convertOpenAIContentToParts(content); if (parts.length > 0) { result.contents.push({ role: "user", parts }); } } else if (role === "assistant") { - const parts = []; + const parts: GeminiPart[] = []; // Thinking/reasoning → thought part with signature if (msg.reasoning_content) { parts.push({ thought: true, - text: msg.reasoning_content, + text: msg.reasoning_content as string, }); } @@ -289,17 +323,19 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName } } - if (msg.tool_calls && Array.isArray(msg.tool_calls)) { - const toolCallIds = []; - const resolvedSignatures = new Map(); + const toolCalls = msg.tool_calls as Array> | undefined; + if (toolCalls && Array.isArray(toolCalls)) { + const toolCallIds: string[] = []; + const resolvedSignatures = new Map(); let firstPersistedSignature: string | undefined; - for (const tc of msg.tool_calls) { + for (const tc of toolCalls) { + const id = tc.id as string; const resolved = resolveGeminiThoughtSignature( - buildGeminiThoughtSignatureKey(toolNameOptions.signatureNamespace, tc.id), + buildGeminiThoughtSignatureKey(toolNameOptions.signatureNamespace, id), extractClientThoughtSignature(tc) ); if (typeof resolved === "string" && resolved.length > 0) { - resolvedSignatures.set(tc.id, resolved); + resolvedSignatures.set(id, resolved); firstPersistedSignature ??= resolved; } } @@ -308,19 +344,23 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName const stringifySignaturelessToolCalls = toolNameOptions.signaturelessToolCallMode === "text"; - for (const tc of msg.tool_calls) { + for (const tc of toolCalls) { if (tc.type !== "function") continue; - const signatureForToolCall = resolvedSignatures.get(tc.id); + const id = tc.id as string; + const fn = tc.function as { name: string; arguments?: string } | undefined; + if (!fn) continue; + + const signatureForToolCall = resolvedSignatures.get(id); if (!signatureForToolCall && stringifySignaturelessToolCalls) { - const args = tc.function?.arguments || "{}"; + const args = fn.arguments || "{}"; parts.push({ - text: `[Tool call: ${tc.function?.name || "unknown"}]\nArguments: ${args}`, + text: `[Tool call: ${fn.name || "unknown"}]\nArguments: ${args}`, }); continue; } - const args = tryParseJSON(tc.function?.arguments || "{}"); + const args = tryParseJSON(fn.arguments || "{}"); const embeddedThoughtSignature = shouldUseEmbeddedSignature ? firstPersistedSignature || signatureForToolCall : undefined; @@ -333,13 +373,13 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName parts.push({ ...(embeddedThoughtSignature ? { thoughtSignature: embeddedThoughtSignature } : {}), functionCall: { - id: tc.id, - name: sanitizeToolName(tc.function.name), + id: id, + name: sanitizeToolName(fn.name), args: args, }, }); - toolCallIds.push(tc.id); + toolCallIds.push(id); } if (parts.length > 0) { @@ -349,15 +389,15 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName // Check if there are actual tool responses in the next messages const hasSignaturelessTextResponses = stringifySignaturelessToolCalls && - msg.tool_calls.some( - (tc) => - tc.type === "function" && !resolvedSignatures.has(tc.id) && toolResponses[tc.id] - ); + toolCalls.some((tc) => { + const id = tc.id as string; + return tc.type === "function" && !resolvedSignatures.has(id) && toolResponses[id]; + }); const hasActualResponses = toolCallIds.some((fid) => toolResponses[fid]) || hasSignaturelessTextResponses; if (hasActualResponses) { - const toolParts = []; + const toolParts: GeminiPart[] = []; for (const fid of toolCallIds) { if (!toolResponses[fid]) continue; @@ -372,8 +412,8 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName } name = sanitizeToolName(name); - let resp = toolResponses[fid]; - let parsedResp = tryParseJSON(resp); + const resp = toolResponses[fid]; + let parsedResp = tryParseJSON(resp as string); if (parsedResp === null) { parsedResp = { result: resp }; } else if (typeof parsedResp !== "object") { @@ -396,11 +436,13 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName // Signature-less historical tool responses are represented as text // so strict Gemini/Antigravity endpoints don't reject them as native // functionResponse parts missing a matching thoughtSignature. - for (const tc of msg.tool_calls) { - if (tc.type !== "function" || !tc.id) continue; - if (!resolvedSignatures.has(tc.id) && toolResponses[tc.id]) { - const name = tcID2Name[tc.id] || tc.function?.name || "unknown"; - const resp = toolResponses[tc.id]; + for (const tc of toolCalls) { + const id = tc.id as string; + if (tc.type !== "function" || !id) continue; + if (!resolvedSignatures.has(id) && toolResponses[id]) { + const fn = tc.function as { name?: string } | undefined; + const name = tcID2Name[id] || fn?.name || "unknown"; + const resp = toolResponses[id]; toolParts.push({ text: `[Tool response: ${name}]\nResult: ${resp}`, }); @@ -420,27 +462,48 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName } // Convert tools - const geminiTools = buildGeminiTools(body.tools, { + const bodyTools = body.tools as Array> | undefined; + const geminiTools = buildGeminiTools(bodyTools, { ...toolNameOptions, toolNameMap, }); + + // Support for Google Search grounding if requested via 'google_search' tool + const hasGoogleSearch = bodyTools?.some((t) => { + const fn = t.function as { name?: string } | undefined; + return t.type === "function" && (fn?.name === "google_search" || fn?.name === "googleSearch"); + }); + + type ToolEntry = NonNullable[number]; + if (geminiTools && geminiTools.length > 0) { result.tools = geminiTools; + if (hasGoogleSearch) { + result.tools.push({ googleSearch: {} } as ToolEntry); + } result.toolConfig = { functionCallingConfig: { mode: "VALIDATED" } }; + } else if (hasGoogleSearch) { + result.tools = [{ googleSearch: {} } as ToolEntry]; } // Convert response_format to Gemini's responseMimeType/responseSchema - if (body.response_format) { - if (body.response_format.type === "json_schema" && body.response_format.json_schema) { + const responseFormat = body.response_format as + | { + type?: string; + json_schema?: { schema?: unknown; [key: string]: unknown }; + } + | undefined; + if (responseFormat) { + if (responseFormat.type === "json_schema" && responseFormat.json_schema) { result.generationConfig.responseMimeType = "application/json"; // Extract the schema (may be nested under .schema key) - const schema = body.response_format.json_schema.schema || body.response_format.json_schema; + const schema = responseFormat.json_schema.schema || responseFormat.json_schema; if (schema && typeof schema === "object") { result.generationConfig.responseSchema = cleanJSONSchemaForAntigravity(schema); } - } else if (body.response_format.type === "json_object") { + } else if (responseFormat.type === "json_object") { result.generationConfig.responseMimeType = "application/json"; - } else if (body.response_format.type === "text") { + } else if (responseFormat.type === "text") { result.generationConfig.responseMimeType = "text/plain"; } } @@ -456,61 +519,40 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName } // OpenAI -> Gemini (standard API) -export function openaiToGeminiRequest(model, body, stream, credentials = null) { +export function openaiToGeminiRequest( + model: string, + body: Record, + stream: boolean, + credentials: Record | null = null +) { // Thread the signature namespace so a thinking model's thoughtSignature (cached on the // response turn under `:`) is found and re-attached to the // functionCall on the follow-up request. Without this the streaming lookup key didn't // match and Gemini rejected tool calls with 400 "missing thought_signature" (#2504). const signatureNamespace = - credentials && - typeof credentials === "object" && - typeof (credentials as Record)._signatureNamespace === "string" - ? ((credentials as Record)._signatureNamespace as string) + credentials && typeof credentials._signatureNamespace === "string" + ? credentials._signatureNamespace : null; return openaiToGeminiBase(model, body, stream, { signatureNamespace }); } // OpenAI -> Gemini CLI (Cloud Code Assist) export function openaiToGeminiCLIRequest( - model, - body, - stream, + model: string, + body: Record, + stream: boolean, options: { functionResponseShape?: "result" | "output"; signatureNamespace?: string | null; signaturelessToolCallMode?: "native" | "text"; } = {} ) { - const gemini = openaiToGeminiBase(model, body, stream, { + return openaiToGeminiBase(model, body, stream, { stripNamespace: true, functionResponseShape: options.functionResponseShape, signatureNamespace: options.signatureNamespace, signaturelessToolCallMode: options.signaturelessToolCallMode, }); - - // Add thinking config for CLI - if (body.reasoning_effort) { - const budgetMap = { - low: 1024, - medium: getDefaultThinkingBudget(model) || 8192, - high: capThinkingBudget(model, 32768), - }; - const budget = budgetMap[body.reasoning_effort] || getDefaultThinkingBudget(model) || 8192; - gemini.generationConfig.thinkingConfig = { - thinkingBudget: budget, - includeThoughts: true, - }; - } - - // Thinking config from Claude format - if (body.thinking?.type === "enabled" && body.thinking.budget_tokens) { - gemini.generationConfig.thinkingConfig = { - thinkingBudget: body.thinking.budget_tokens, - includeThoughts: true, - }; - } - - return gemini; } // Wrap Gemini CLI format in Cloud Code wrapper diff --git a/open-sse/translator/response/gemini-to-openai.ts b/open-sse/translator/response/gemini-to-openai.ts index e6e8681a4f41..1fc5090756f1 100644 --- a/open-sse/translator/response/gemini-to-openai.ts +++ b/open-sse/translator/response/gemini-to-openai.ts @@ -241,6 +241,40 @@ export function geminiToOpenAIResponse(chunk, state) { } } + // Grounding Metadata (Google Search) + const grounding = candidate.groundingMetadata || candidate.grounding_metadata; + if (grounding && !state.groundingProcessed) { + const citations = []; + if (grounding.groundingChunks || grounding.grounding_chunks) { + const chunks = grounding.groundingChunks || grounding.grounding_chunks; + for (const chunk of chunks) { + if (chunk.web) { + citations.push({ + title: chunk.web.title, + url: chunk.web.uri, + }); + } + } + } + + if (citations.length > 0) { + results.push({ + id: `chatcmpl-${state.messageId}`, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: state.model, + choices: [ + { + index: 0, + delta: { citations }, + finish_reason: null, + }, + ], + }); + state.groundingProcessed = true; + } + } + // Usage metadata - extract before finish reason so we can include it const usageMeta = response.usageMetadata || chunk.usageMetadata; if (usageMeta && typeof usageMeta === "object") { diff --git a/tests/unit/translator-openai-to-gemini.test.ts b/tests/unit/translator-openai-to-gemini.test.ts index 579eca181151..8e85c5d8afc5 100644 --- a/tests/unit/translator-openai-to-gemini.test.ts +++ b/tests/unit/translator-openai-to-gemini.test.ts @@ -969,3 +969,34 @@ test("openaiToGeminiRequest re-attaches cached thoughtSignature for FORMATS.GEMI "cached thoughtSignature must be re-attached to the functionCall" ); }); +test("OpenAI -> Gemini request maps reasoning_effort to thinkingConfig", () => { + const result = openaiToGeminiRequest( + "gemini-2.0-flash-thinking", + { + messages: [{ role: "user", content: "Solve this complex puzzle" }], + reasoning_effort: "high", + }, + false + ); + + assert.ok((result as any).generationConfig.thinkingConfig, "expected thinkingConfig"); + assert.equal((result as any).generationConfig.thinkingConfig.includeThoughts, true); + assert.equal((result as any).generationConfig.thinkingConfig.thinkingBudget, 32768); +}); + +test("OpenAI -> Gemini request maps google_search tool", () => { + const result = openaiToGeminiRequest( + "gemini-2.0-flash", + { + messages: [{ role: "user", content: "What happened today?" }], + tools: [{ type: "function", function: { name: "google_search" } }], + }, + false + ); + + assert.ok(Array.isArray((result as any).tools), "expected tools array"); + assert.ok( + (result as any).tools.some((t: any) => t.googleSearch), + "expected googleSearch tool" + ); +}); diff --git a/tests/unit/translator-resp-gemini-to-openai.test.ts b/tests/unit/translator-resp-gemini-to-openai.test.ts index 42f52df891de..184e9fd51c94 100644 --- a/tests/unit/translator-resp-gemini-to-openai.test.ts +++ b/tests/unit/translator-resp-gemini-to-openai.test.ts @@ -64,7 +64,9 @@ test("Gemini non-stream: multiple candidates keep multimodal content, reasoning { thought: true, text: "Plan first." }, { text: "Answer:" }, { inlineData: { mimeType: "image/png", data: "abc123" } }, - { functionCall: { id: "native-read-1", name: "read_file", args: { path: "/tmp/a" } } }, + { + functionCall: { id: "native-read-1", name: "read_file", args: { path: "/tmp/a" } }, + }, ], }, finishReason: "STOP", @@ -283,10 +285,7 @@ test("Gemini stream: reasoning, tool call, image and MAX_TOKENS finish are conve ); assert.equal(result[1].choices[0].delta.reasoning_content, "Need a plan."); - assert.equal( - result[2].choices[0].delta.tool_calls[0].id, - "native-call-1" - ); + assert.equal(result[2].choices[0].delta.tool_calls[0].id, "native-call-1"); assert.equal( result[2].choices[0].delta.tool_calls[0].function.name, "mcp__filesystem__read_multiple_files_with_validation_and_metadata_bundle_v2" @@ -350,6 +349,30 @@ test("Gemini stream: safety block without candidates emits role chunk then conte assert.equal(result[1].choices[0].finish_reason, "content_filter"); }); +test("Gemini stream: grounding metadata (citations) are extracted", () => { + const state = createStreamingState(); + const result = geminiToOpenAIResponse( + { + responseId: "resp-grounding", + modelVersion: "gemini-2.0-flash", + candidates: [ + { + content: { parts: [{ text: "Today is sunny." }] }, + groundingMetadata: { + groundingChunks: [{ web: { title: "Weather Today", uri: "https://weather.com" } }], + }, + }, + ], + }, + state + ); + + assert.equal(result[1].choices[0].delta.content, "Today is sunny."); + assert.deepEqual(result[2].choices[0].delta.citations, [ + { title: "Weather Today", url: "https://weather.com" }, + ]); +}); + test("Gemini stream: null chunk is ignored", () => { assert.equal(geminiToOpenAIResponse(null, createStreamingState()), null); }); From 7a4905edbec023937dd56fd45ac1eb508d49d6ac Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 24 May 2026 20:42:07 -0300 Subject: [PATCH 03/77] docs: add PR #2676 to changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18a85b083845..4857b398172f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- From 3c512a7bbae4d4aa069f1c2c2e16df305382005a Mon Sep 17 00:00:00 2001 From: Automation Date: Mon, 25 May 2026 18:25:19 +0200 Subject: [PATCH 04/77] fix(vision-bridge): process images when vision-capable model has combo mapping When a model-combo mapping routes a vision-capable model through a combo where some targets may NOT support vision, the vision bridge must process images so combo targets can describe them. Before: if body.model supports vision, the vision bridge skipped image processing entirely. Non-vision combo targets would receive raw images they can't handle. After: before skipping, check if the model has a model-combo mapping. If it does, process images through the vision bridge regardless of body.model's native vision support. - Add checkModelHasComboMapping() helper (dynamic import, failsafe) - Add checkModelHasComboMapping dep to VisionBridgeDependencies (testable) - Guardrail preCall: check combo mapping before early-return on vision support - Add VB-S11 / VB-S11b tests --- src/lib/guardrails/visionBridge.ts | 36 +++++++++++- tests/unit/guardrails/visionBridge.test.ts | 67 ++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/src/lib/guardrails/visionBridge.ts b/src/lib/guardrails/visionBridge.ts index 460d342471ef..402cf4856019 100644 --- a/src/lib/guardrails/visionBridge.ts +++ b/src/lib/guardrails/visionBridge.ts @@ -18,6 +18,28 @@ import { isVisionBridgeForcedModel, } from "@/shared/constants/visionBridgeDefaults"; +/// Check if a model name has a model-combo mapping. +/// When a user sends `model: "gpt-4o"` with a model-combo mapping, +/// the actual execution model(s) might differ. Non-vision combo +/// targets would fail with images they can't handle, so the +/// vision bridge must process images even if body.model supports vision. +async function checkModelHasComboMapping(model: string): Promise { + try { + // 1. Check for exact combo name match + const { getComboByName } = await import("@/lib/localDb"); + const exactCombo = await getComboByName(model); + if (exactCombo) return true; + + // 2. Check for model-combo mapping (glob pattern match) + const { resolveComboForModel } = await import("@/lib/db/modelComboMappings"); + const mapping = await resolveComboForModel(model); + return mapping !== null; + } catch { + // Tables may not exist (pre-migration), or DB not initialized + return false; + } +} + export interface VisionBridgeDependencies { getSettings?: () => Promise>; callVisionModel?: ( @@ -25,6 +47,8 @@ export interface VisionBridgeDependencies { config: import("./visionBridgeHelpers").VisionModelConfig, apiKey?: string ) => Promise; + /** Skip real DB lookup — return true to test combo-mapping path, false for normal path. */ + checkModelHasComboMapping?: (model: string) => Promise; } export class VisionBridgeGuardrail extends BaseGuardrail { @@ -61,7 +85,17 @@ export class VisionBridgeGuardrail extends BaseGuardrail { // 4. Check if model supports vision const capabilities = getResolvedModelCapabilities(model); if (capabilities.supportsVision === true && !forceVisionBridge) { - return { block: false }; + // The request model supports vision natively, but check if a + // model-combo mapping routes this model through a combo where + // some targets may NOT support vision. In that case, the vision + // bridge must process images so combo targets can describe them. + const hasMapping = this.deps.checkModelHasComboMapping + ? await this.deps.checkModelHasComboMapping(model) + : await checkModelHasComboMapping(model); + if (!hasMapping) { + return { block: false }; + } + // Combo mapping found — fall through to process images } // 5. Get body and check for messages diff --git a/tests/unit/guardrails/visionBridge.test.ts b/tests/unit/guardrails/visionBridge.test.ts index 0925bbe8bc20..c49c2d270edf 100644 --- a/tests/unit/guardrails/visionBridge.test.ts +++ b/tests/unit/guardrails/visionBridge.test.ts @@ -38,6 +38,7 @@ function createGuardrail(options?: Parameters[0]) } return mockVisionResponse; }, + ...(options?.deps ?? {}), }, }); } @@ -501,3 +502,69 @@ test("VB-S10: returns meta with imagesProcessed count", async () => { assert.strictEqual(typeof meta.processingTimeMs, "number"); assert.strictEqual(meta.visionModel, "openai/gpt-4o-mini"); }); + +// ── VB-S11: Combo mapping forces vision processing despite vision-capable model ── + +test("VB-S11: processes images when vision-capable model has combo mapping", async () => { + mockVisionResponse = "A description from combo-mapped vision bridge"; + const guardrail = createGuardrail({ + deps: { + checkModelHasComboMapping: async (_model: string) => true, + }, + }); + + const payload = createPayload({ + model: "openai/gpt-4o", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { + type: "image_url", + image_url: { url: "https://example.com/image.png" }, + }, + ], + }, + ], + }); + + const startCallCount = visionCallCount; + const result = await guardrail.preCall(payload, createContext({ model: "openai/gpt-4o" })); + + // Vision bridge should have processed the image + assert.strictEqual(result.block, false); + assert.ok(visionCallCount > startCallCount, "Expected vision model to be called"); + assert.ok(result.modifiedPayload !== undefined, "Expected modifiedPayload when combo mapping forces vision bridge"); +}); + +test("VB-S11b: passthroughs when vision-capable model has NO combo mapping", async () => { + const guardrail = createGuardrail({ + deps: { + checkModelHasComboMapping: async (_model: string) => false, + }, + }); + + const payload = createPayload({ + model: "openai/gpt-4o", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { + type: "image_url", + image_url: { url: "https://example.com/image.png" }, + }, + ], + }, + ], + }); + + const result = await guardrail.preCall(payload, createContext({ model: "openai/gpt-4o" })); + + // Vision bridge should skip (passthrough) since model supports vision and no combo mapping + assert.strictEqual(result.block, false); + assert.strictEqual(result.modifiedPayload, undefined); + assert.strictEqual(visionCallCount, 0); +}); From f24a4c1ac60e97f04b902375bead43186b0cad00 Mon Sep 17 00:00:00 2001 From: Automation Date: Mon, 25 May 2026 21:46:06 +0200 Subject: [PATCH 05/77] fix(vision-bridge): only process images when some combo targets lack native vision Optimization per code review: instead of always processing images when a combo mapping exists, resolve the combo targets and check each target model's native vision support. Only invoke the vision bridge when at least one target model does not support vision. - Replace checkModelHasComboMapping() with shouldProcessImagesForComboModel() - When combo has ComboRefStep targets, conservatively process images - When all targets are model steps with native vision, skip processing - On errors, process images (conservative fail-safe) --- src/lib/guardrails/visionBridge.ts | 70 ++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 17 deletions(-) diff --git a/src/lib/guardrails/visionBridge.ts b/src/lib/guardrails/visionBridge.ts index 402cf4856019..b1fea0c11165 100644 --- a/src/lib/guardrails/visionBridge.ts +++ b/src/lib/guardrails/visionBridge.ts @@ -18,25 +18,61 @@ import { isVisionBridgeForcedModel, } from "@/shared/constants/visionBridgeDefaults"; -/// Check if a model name has a model-combo mapping. -/// When a user sends `model: "gpt-4o"` with a model-combo mapping, -/// the actual execution model(s) might differ. Non-vision combo -/// targets would fail with images they can't handle, so the -/// vision bridge must process images even if body.model supports vision. -async function checkModelHasComboMapping(model: string): Promise { +/// Check if a model with a combo mapping should trigger vision bridge processing. +/// Resolves the combo targets and only returns true if at least one target model +/// does NOT support vision natively. This avoids unnecessary vision model calls +/// when all combo targets can handle images directly. +async function shouldProcessImagesForComboModel(model: string): Promise { try { - // 1. Check for exact combo name match const { getComboByName } = await import("@/lib/localDb"); - const exactCombo = await getComboByName(model); - if (exactCombo) return true; - - // 2. Check for model-combo mapping (glob pattern match) const { resolveComboForModel } = await import("@/lib/db/modelComboMappings"); - const mapping = await resolveComboForModel(model); - return mapping !== null; - } catch { - // Tables may not exist (pre-migration), or DB not initialized + + // 1. Try to find combo by exact name match + let combo = await getComboByName(model); + + // 2. If no exact match, try model-combo mapping + if (!combo) { + const mapping = await resolveComboForModel(model); + if (!mapping) return false; + const comboName = mapping.comboName ?? mapping.name ?? null; + if (!comboName) return false; + combo = await getComboByName(comboName); + } + + if (!combo) return false; + + // 3. Get the combo's models (target steps) + const rawModels = (combo as Record).models; + if (!Array.isArray(rawModels)) return false; + + // 4. Check each target for vision support + // combo-ref → conservative (process images) + // model step with no native vision → process images + // all model steps with native vision → safe to skip + let hasModelStep = false; + for (const step of rawModels) { + const s = step as Record; + if (s.kind === "combo-ref") return true; + if (s.kind === "model") { + hasModelStep = true; + const targetModel = s.model; + if (typeof targetModel === "string") { + const caps = getResolvedModelCapabilities(targetModel); + if (caps.supportsVision !== true) { + return true; + } + } + } + } + + // All model steps support vision — safe to skip + if (hasModelStep) return false; + + // No recognizable steps — don't force bridge return false; + } catch { + // On error, try to process images (conservative) + return true; } } @@ -47,7 +83,7 @@ export interface VisionBridgeDependencies { config: import("./visionBridgeHelpers").VisionModelConfig, apiKey?: string ) => Promise; - /** Skip real DB lookup — return true to test combo-mapping path, false for normal path. */ + /** Override combo-target vision check — return true to force processing, false to skip. */ checkModelHasComboMapping?: (model: string) => Promise; } @@ -91,7 +127,7 @@ export class VisionBridgeGuardrail extends BaseGuardrail { // bridge must process images so combo targets can describe them. const hasMapping = this.deps.checkModelHasComboMapping ? await this.deps.checkModelHasComboMapping(model) - : await checkModelHasComboMapping(model); + : await shouldProcessImagesForComboModel(model); if (!hasMapping) { return { block: false }; } From 69dc6d02490a32e56fc34405a782dce5eaf4bede Mon Sep 17 00:00:00 2001 From: Automation Date: Mon, 25 May 2026 22:13:23 +0200 Subject: [PATCH 06/77] fix(combos): repair context handoff ordering and add per-model timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: recordSessionModelUsage was called BEFORE getLastSessionModel, so prevModel always matched the current modelStr — handoff summaries were never generated when auto-routing switched models. Fix: call getLastSessionModel first (captures actual previous model), generate handoff on mismatch, then record the new model for next time. Also: - ORDER BY id DESC in session_model_history query (deterministic vs used_at which has second-precision ties) - 30s per-model timeout for combo routing (default FETCH_TIMEOUT_MS is 600s, too long for combo fallback scenarios) --- open-sse/services/combo.ts | 55 ++++++++++++++++++++++++++--------- src/lib/db/contextHandoffs.ts | 2 +- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index a30999006e80..58c1dd331ea7 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -92,6 +92,7 @@ function isAllAccountsRateLimitedResponse( const MAX_COMBO_DEPTH = 3; const MAX_FALLBACK_WAIT_MS = 5000; const MAX_GLOBAL_ATTEMPTS = 30; +const COMBO_MODEL_TIMEOUT_MS = 30_000; // 30s per model attempt within a combo (default FETCH_TIMEOUT_MS=600s) function resolveDelayMs(value: unknown, fallback: number): number { const numericValue = Number(value); @@ -1645,13 +1646,39 @@ export async function handleComboChat({ : handleSingleModel; // ───────────────────────────────────────────────────────────────────────── + // ── Per-model timeout wrapper ──────────────────────────────────────────── + // Default FETCH_TIMEOUT_MS is 600s per model. For combos, we use a shorter + // per-model timeout so slow/hanging models don't block fallback. + const handleSingleModelWithTimeout = async (b, modelStr, target?) => { + let timeoutId; + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + log.warn("COMBO", `Model ${modelStr} exceeded ${COMBO_MODEL_TIMEOUT_MS}ms timeout — falling back`); + resolve(new Response( + JSON.stringify({ error: { message: `Model ${modelStr} timed out` } }), + { status: 524, headers: { 'Content-Type': 'application/json' } } + )); + }, COMBO_MODEL_TIMEOUT_MS); + }); + try { + return await Promise.race([ + handleSingleModelWrapped(b, modelStr, target).catch((err) => { + return new Response(JSON.stringify({ error: { message: err.message } }), { status: 502, headers: { "Content-Type": "application/json" } }); + }), + timeoutPromise, + ]); + } finally { + clearTimeout(timeoutId); + } + }; + // Route to pinned model if context caching specifies one (Fix #679) if (pinnedModel) { log.info( "COMBO", `Bypassing strategy — routing directly to pinned context model: ${pinnedModel}` ); - return handleSingleModelWrapped(body, pinnedModel); + return handleSingleModelWithTimeout(body, pinnedModel); } // Route to round-robin handler if strategy matches @@ -1659,7 +1686,7 @@ export async function handleComboChat({ return handleRoundRobinCombo({ body, combo, - handleSingleModel: handleSingleModelWrapped, + handleSingleModel: handleSingleModelWithTimeout, isModelAvailable, log, settings, @@ -1703,7 +1730,7 @@ export async function handleComboChat({ const pipelineRaw = await handlePipelineCombo({ body, combo, - handleChatCore: handleSingleModel, + handleChatCore: handleSingleModelWithTimeout, log, settings, signal, @@ -2155,7 +2182,7 @@ export async function handleComboChat({ ); } } - const result = await handleSingleModelWrapped(body, modelStr, { + const result = await handleSingleModelWithTimeout(body, modelStr, { ...target, failoverBeforeRetry: config.failoverBeforeRetry, }); @@ -2218,14 +2245,6 @@ export async function handleComboChat({ relayOptions?.sessionId && !(body as Record)?.[SKIP_UNIVERSAL_HANDOFF_FLAG] ) { - recordSessionModelUsage( - relayOptions.sessionId, - combo.name, - modelStr, - provider, - target.connectionId - ); - const prevModel = getLastSessionModel(relayOptions.sessionId, combo.name); if (prevModel && prevModel !== modelStr) { const handoffSourceMessages = @@ -2242,9 +2261,17 @@ export async function handleComboChat({ prevModel, currModel: modelStr, universalConfig: universalHandoffConfig, - handleSingleModel: handleSingleModelWrapped, + handleSingleModel: handleSingleModelWithTimeout, }); } + + recordSessionModelUsage( + relayOptions.sessionId, + combo.name, + modelStr, + provider, + target.connectionId + ); } // Context-relay intentionally splits responsibilities: // combo.ts decides whether a successful turn should generate a handoff, @@ -2279,7 +2306,7 @@ export async function handleComboChat({ model: modelStr, expiresAt: resetCandidates[0] || null, config: relayConfig, - handleSingleModel: handleSingleModelWrapped, + handleSingleModel: handleSingleModelWithTimeout, }); } } diff --git a/src/lib/db/contextHandoffs.ts b/src/lib/db/contextHandoffs.ts index 6ef426c9642a..71aaccd13c09 100644 --- a/src/lib/db/contextHandoffs.ts +++ b/src/lib/db/contextHandoffs.ts @@ -209,7 +209,7 @@ export function getLastSessionModel(sessionId: string, comboName: string): strin `SELECT model_str FROM session_model_history WHERE session_id = ? AND combo_name = ? - ORDER BY used_at DESC + ORDER BY id DESC LIMIT 1` ) .get(sessionId, comboName) as { model_str: string } | undefined; From 8b152fd4f71b8839aeec87ca9dc54fd6bb0a668e Mon Sep 17 00:00:00 2001 From: Automation Date: Mon, 25 May 2026 22:33:31 +0200 Subject: [PATCH 07/77] Revert "fix(combos): repair context handoff ordering and add per-model timeout" This reverts commit 69dc6d02490a32e56fc34405a782dce5eaf4bede. --- open-sse/services/combo.ts | 55 +++++++++-------------------------- src/lib/db/contextHandoffs.ts | 2 +- 2 files changed, 15 insertions(+), 42 deletions(-) diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 58c1dd331ea7..a30999006e80 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -92,7 +92,6 @@ function isAllAccountsRateLimitedResponse( const MAX_COMBO_DEPTH = 3; const MAX_FALLBACK_WAIT_MS = 5000; const MAX_GLOBAL_ATTEMPTS = 30; -const COMBO_MODEL_TIMEOUT_MS = 30_000; // 30s per model attempt within a combo (default FETCH_TIMEOUT_MS=600s) function resolveDelayMs(value: unknown, fallback: number): number { const numericValue = Number(value); @@ -1646,39 +1645,13 @@ export async function handleComboChat({ : handleSingleModel; // ───────────────────────────────────────────────────────────────────────── - // ── Per-model timeout wrapper ──────────────────────────────────────────── - // Default FETCH_TIMEOUT_MS is 600s per model. For combos, we use a shorter - // per-model timeout so slow/hanging models don't block fallback. - const handleSingleModelWithTimeout = async (b, modelStr, target?) => { - let timeoutId; - const timeoutPromise = new Promise((resolve) => { - timeoutId = setTimeout(() => { - log.warn("COMBO", `Model ${modelStr} exceeded ${COMBO_MODEL_TIMEOUT_MS}ms timeout — falling back`); - resolve(new Response( - JSON.stringify({ error: { message: `Model ${modelStr} timed out` } }), - { status: 524, headers: { 'Content-Type': 'application/json' } } - )); - }, COMBO_MODEL_TIMEOUT_MS); - }); - try { - return await Promise.race([ - handleSingleModelWrapped(b, modelStr, target).catch((err) => { - return new Response(JSON.stringify({ error: { message: err.message } }), { status: 502, headers: { "Content-Type": "application/json" } }); - }), - timeoutPromise, - ]); - } finally { - clearTimeout(timeoutId); - } - }; - // Route to pinned model if context caching specifies one (Fix #679) if (pinnedModel) { log.info( "COMBO", `Bypassing strategy — routing directly to pinned context model: ${pinnedModel}` ); - return handleSingleModelWithTimeout(body, pinnedModel); + return handleSingleModelWrapped(body, pinnedModel); } // Route to round-robin handler if strategy matches @@ -1686,7 +1659,7 @@ export async function handleComboChat({ return handleRoundRobinCombo({ body, combo, - handleSingleModel: handleSingleModelWithTimeout, + handleSingleModel: handleSingleModelWrapped, isModelAvailable, log, settings, @@ -1730,7 +1703,7 @@ export async function handleComboChat({ const pipelineRaw = await handlePipelineCombo({ body, combo, - handleChatCore: handleSingleModelWithTimeout, + handleChatCore: handleSingleModel, log, settings, signal, @@ -2182,7 +2155,7 @@ export async function handleComboChat({ ); } } - const result = await handleSingleModelWithTimeout(body, modelStr, { + const result = await handleSingleModelWrapped(body, modelStr, { ...target, failoverBeforeRetry: config.failoverBeforeRetry, }); @@ -2245,6 +2218,14 @@ export async function handleComboChat({ relayOptions?.sessionId && !(body as Record)?.[SKIP_UNIVERSAL_HANDOFF_FLAG] ) { + recordSessionModelUsage( + relayOptions.sessionId, + combo.name, + modelStr, + provider, + target.connectionId + ); + const prevModel = getLastSessionModel(relayOptions.sessionId, combo.name); if (prevModel && prevModel !== modelStr) { const handoffSourceMessages = @@ -2261,17 +2242,9 @@ export async function handleComboChat({ prevModel, currModel: modelStr, universalConfig: universalHandoffConfig, - handleSingleModel: handleSingleModelWithTimeout, + handleSingleModel: handleSingleModelWrapped, }); } - - recordSessionModelUsage( - relayOptions.sessionId, - combo.name, - modelStr, - provider, - target.connectionId - ); } // Context-relay intentionally splits responsibilities: // combo.ts decides whether a successful turn should generate a handoff, @@ -2306,7 +2279,7 @@ export async function handleComboChat({ model: modelStr, expiresAt: resetCandidates[0] || null, config: relayConfig, - handleSingleModel: handleSingleModelWithTimeout, + handleSingleModel: handleSingleModelWrapped, }); } } diff --git a/src/lib/db/contextHandoffs.ts b/src/lib/db/contextHandoffs.ts index 71aaccd13c09..6ef426c9642a 100644 --- a/src/lib/db/contextHandoffs.ts +++ b/src/lib/db/contextHandoffs.ts @@ -209,7 +209,7 @@ export function getLastSessionModel(sessionId: string, comboName: string): strin `SELECT model_str FROM session_model_history WHERE session_id = ? AND combo_name = ? - ORDER BY id DESC + ORDER BY used_at DESC LIMIT 1` ) .get(sessionId, comboName) as { model_str: string } | undefined; From 8457d76a0b4a292a4b960a68edf283464360907e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 25 May 2026 21:40:57 -0300 Subject: [PATCH 08/77] fix(docker): use node:24 base image to match engines range Dockerfile was pinned to node:26.2.0-trixie-slim, which is outside the project's engines range (>=20.20.2 <21 || >=22.22.2 <23 || >=24 <25). keytar 7.9.0 / node-gyp could not compile against the Node 26 ABI, breaking every Docker build of v3.8.3 and leaving :latest stale. (cherry picked from commit f1d35915ff0b1e057d140f9473274e69c2c6ac03) --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1d589820a070..3ffa72af5c58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:26.2.0-trixie-slim AS builder +FROM node:24-trixie-slim AS builder WORKDIR /app RUN apt-get update \ @@ -19,7 +19,7 @@ RUN if [ -f package-lock.json ]; then \ COPY . ./ RUN mkdir -p /app/data && npm run build -- --webpack -FROM node:26.2.0-trixie-slim AS runner-base +FROM node:24-trixie-slim AS runner-base WORKDIR /app LABEL org.opencontainers.image.title="omniroute" \ From 92c51104d432fb681ae81d3070b90ddb49d15b70 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 25 May 2026 21:42:02 -0300 Subject: [PATCH 09/77] fix(ci): semver-aware release publish guards (npm + docker) Prevents the v3.8.3 incident from recurring, where re-publishing old releases (v2.5.8/v2.6.4/v3.2.8/v3.3.3) clobbered both Docker Hub :latest and the npm latest dist-tag with the 3.2.8 build. docker-publish.yml: - release.types: published -> released (does not fire on edits) - new step computes promote_latest only when VERSION equals the highest semver tag in the repo; pre-release identifiers (-rc/alpha/beta/pre/ next) never claim :latest - push to main now tags :main only (never :latest) - skip-if-exists via docker manifest inspect avoids accidental rebuilds - workflow_dispatch input promote_latest is opt-in for back-fill builds - all github/inputs context moved into env: to remove script-injection risk flagged by semgrep npm-publish.yml: - release.types: published -> released - dist-tag resolved by semver compare: only the highest stable tag becomes latest; older releases fall back to a historic dist-tag - skip-if-already-published actually works now: dropped the --silent flag from npm view that suppressed stdout and broke the grep, which is why 3.2.8 re-published and stole @latest - npm publish always runs with explicit --tag (no implicit @latest promotion) - secrets/inputs moved into env: for the same injection hardening (cherry picked from commit dedeac451770a36b59f4de87ee03a1c2ad35d0f4) --- .github/workflows/docker-publish.yml | 132 ++++++++++++++++++++---- .github/workflows/npm-publish.yml | 144 +++++++++++++++++---------- 2 files changed, 208 insertions(+), 68 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 74aac42e7df5..454fc1f9007e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -8,14 +8,22 @@ on: - "v*" paths-ignore: - ".github/workflows/**" + # Use 'released' instead of 'published' so editing/re-publishing old releases + # does NOT re-trigger this workflow. 'released' fires only on the initial + # release publication (and pre-release → release transition). release: - types: [published] + types: [released] workflow_dispatch: inputs: version: - description: "Version tag to build (e.g. 2.6.0)" + description: "Version tag to build (e.g. 3.8.4)" required: true type: string + promote_latest: + description: "Also tag :latest (only if this is the highest semver)" + required: false + type: boolean + default: false permissions: contents: read @@ -32,50 +40,134 @@ jobs: uses: actions/checkout@v6 with: ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }} + # Need full tag history for semver comparison when deciding :latest. + fetch-depth: 0 + + - name: Resolve version, latest-promotion, and skip flag + id: version + env: + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + REF_TYPE: ${{ github.ref_type }} + INPUT_VERSION: ${{ inputs.version }} + PROMOTE_INPUT: ${{ inputs.promote_latest }} + run: | + set -euo pipefail + + # 1) Resolve version string from the trigger (all inputs come via env). + case "$EVENT_NAME" in + workflow_dispatch) + VERSION="${INPUT_VERSION#v}" + ;; + push) + if [ "$REF_TYPE" = "tag" ]; then + VERSION="${REF_NAME#v}" + else + # Push to main → build & tag as `main` only. Never touch :latest. + VERSION="main" + fi + ;; + release) + VERSION="${REF_NAME#v}" + ;; + *) + VERSION="${REF_NAME#v}" + ;; + esac + # Sanity-check: only allow [A-Za-z0-9._-] in VERSION (defense in depth). + if ! printf '%s' "$VERSION" | grep -qE '^[A-Za-z0-9._-]+$'; then + echo "Refusing to use unsafe VERSION value: $VERSION" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # 2) Decide whether to promote :latest. + PROMOTE="false" + if [ "$VERSION" = "main" ]; then + PROMOTE="false" + elif printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then + echo "Pre-release identifier detected — skipping :latest." + PROMOTE="false" + elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then + PROMOTE="${PROMOTE_INPUT:-false}" + else + git fetch --tags --quiet || true + HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "") + if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then + PROMOTE="true" + else + echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-}). Not promoting :latest." + fi + fi + echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT" + + # 3) Skip if this exact version is already published in Docker Hub. + # `main` is always rebuilt (mutable floating tag). + SKIP="false" + if [ "$VERSION" != "main" ]; then + if docker manifest inspect "diegosouzapw/omniroute:${VERSION}" >/dev/null 2>&1; then + echo "Image diegosouzapw/omniroute:${VERSION} already exists on Docker Hub — skipping rebuild." + SKIP="true" + fi + fi + echo "skip=$SKIP" >> "$GITHUB_OUTPUT" + + echo "Publishing diegosouzapw/omniroute:$VERSION (promote_latest=$PROMOTE, skip=$SKIP)" - name: Set up QEMU (for multi-arch builds) + if: steps.version.outputs.skip != 'true' uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx + if: steps.version.outputs.skip != 'true' uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub + if: steps.version.outputs.skip != 'true' uses: docker/login-action@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry + if: steps.version.outputs.skip != 'true' uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract version from release tag or input - id: version + - name: Compute image tags + id: tags + if: steps.version.outputs.skip != 'true' + env: + VERSION: ${{ steps.version.outputs.version }} + PROMOTE_LATEST: ${{ steps.version.outputs.promote_latest }} run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - VERSION="${{ inputs.version }}" - else - VERSION="${GITHUB_REF_NAME}" - VERSION="${VERSION#v}" + set -euo pipefail + TAGS="${IMAGE_NAME}:${VERSION}" + TAGS="${TAGS}"$'\n'"ghcr.io/diegosouzapw/omniroute:${VERSION}" + if [ "$PROMOTE_LATEST" = "true" ]; then + TAGS="${TAGS}"$'\n'"${IMAGE_NAME}:latest" + TAGS="${TAGS}"$'\n'"ghcr.io/diegosouzapw/omniroute:latest" fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Publishing Docker image: $IMAGE_NAME:$VERSION" + { + echo "tags<> "$GITHUB_OUTPUT" + echo "Tags to push:" + echo "$TAGS" - name: Build and push multi-arch image + if: steps.version.outputs.skip != 'true' uses: docker/build-push-action@v7 with: context: . target: runner-base platforms: linux/amd64,linux/arm64 push: true - tags: | - ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} - ${{ env.IMAGE_NAME }}:latest - ghcr.io/diegosouzapw/omniroute:${{ steps.version.outputs.version }} - ghcr.io/diegosouzapw/omniroute:latest + tags: ${{ steps.tags.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max no-cache: false @@ -83,10 +175,16 @@ jobs: DOCKER_BUILDKIT_INLINE_CACHE: 1 - name: Inspect image + if: steps.version.outputs.skip != 'true' && steps.version.outputs.version != 'main' + env: + VERSION: ${{ steps.version.outputs.version }} run: | - docker buildx imagetools inspect "${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}" + docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}" - name: Update Docker Hub description + # Only refresh README/description when we actually promote :latest + # (avoids overwriting from main pushes or back-fill builds). + if: steps.version.outputs.skip != 'true' && steps.version.outputs.promote_latest == 'true' uses: peter-evans/dockerhub-description@v5 with: username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index e8db5a630079..dbd9ddcb8b89 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -1,8 +1,11 @@ name: Publish to npm on: + # 'released' (not 'published') so editing/re-publishing old releases does NOT + # re-trigger this workflow. Pairs with the semver guard below as defense in + # depth against accidental dist-tag clobbering by old releases. release: - types: [published] + types: [released] workflow_dispatch: inputs: version: @@ -10,13 +13,15 @@ on: required: true type: string tag: - description: "npm dist-tag (latest / next)" + description: "npm dist-tag (auto / latest / next / historic)" required: false - default: "latest" + default: "auto" type: choice options: + - auto - latest - next + - historic workflow_call: inputs: version: @@ -24,9 +29,9 @@ on: required: true type: string tag: - description: "npm dist-tag (latest / next)" + description: "npm dist-tag (auto / latest / next / historic)" required: false - default: "latest" + default: "auto" type: string secrets: NPM_TOKEN: @@ -46,6 +51,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 + with: + # Need full tag history to compare against highest semver when + # deciding whether this release should claim dist-tag `latest`. + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v6 @@ -56,80 +65,110 @@ jobs: - name: Install dependencies (skip scripts to avoid heavy build) run: npm install --ignore-scripts --no-audit --no-fund - - name: Resolve version and dist-tag + - name: Resolve version, dist-tag and skip flag id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_TAG: ${{ inputs.tag }} run: | - VERSION="${{ inputs.version }}" - TAG="${{ inputs.tag }}" + set -euo pipefail - if [ -z "$VERSION" ]; then - if [ "${{ github.event_name }}" = "release" ]; then - VERSION="${GITHUB_REF_NAME}" - fi + # 1) Resolve VERSION from the trigger (all inputs come via env). + VERSION="${INPUT_VERSION:-}" + if [ -z "$VERSION" ] && [ "$EVENT_NAME" = "release" ]; then + VERSION="$REF_NAME" fi - - # Strip v prefix if present VERSION="${VERSION#v}" + if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$'; then + echo "Refusing to publish unsafe VERSION value: $VERSION" >&2 + exit 1 + fi - # Default dist-tag logic - if [ -z "$TAG" ]; then - if [[ "$VERSION" == *-* ]]; then + # 2) Resolve dist-tag. + # - explicit 'latest'/'next'/'historic' is honored + # - 'auto' (or empty): pre-release identifiers → 'next'; + # stable versions → 'latest' only if VERSION is the highest + # stable semver among `v*` tags (otherwise → 'historic'). + REQUESTED_TAG="${INPUT_TAG:-auto}" + TAG="$REQUESTED_TAG" + if [ "$TAG" = "auto" ] || [ -z "$TAG" ]; then + if printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then TAG="next" else - TAG="latest" + git fetch --tags --quiet || true + HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "") + if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then + TAG="latest" + else + echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-}). Using dist-tag 'historic' to avoid clobbering @latest." + TAG="historic" + fi fi fi - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "tag=$TAG" >> $GITHUB_OUTPUT - echo "📦 Publishing omniroute@$VERSION with tag=$TAG" + + # 3) Skip-if-already-published. NOTE: do NOT pass `--silent` to + # `npm view` — it suppresses stdout and breaks the grep, which + # caused old releases (3.2.8) to be re-published and steal + # dist-tag `latest`. See incident notes in CHANGELOG. + PUBLISHED="$(npm view "omniroute@${VERSION}" version 2>/dev/null || true)" + SKIP="false" + if [ "$PUBLISHED" = "$VERSION" ]; then + echo "⚠️ omniroute@${VERSION} is already on npm — skipping publish." + SKIP="true" + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "skip=$SKIP" >> "$GITHUB_OUTPUT" + echo "📦 Resolved omniroute@$VERSION dist-tag=$TAG skip=$SKIP" - name: Sync package.json version + if: steps.resolve.outputs.skip != 'true' + env: + VERSION: ${{ steps.resolve.outputs.version }} run: | - npm version "${{ steps.resolve.outputs.version }}" --no-git-tag-version --allow-same-version + npm version "$VERSION" --no-git-tag-version --allow-same-version - name: Build CLI bundle (standalone app) + if: steps.resolve.outputs.skip != 'true' env: JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation run: npm run build:cli - name: Validate npm package artifact + if: steps.resolve.outputs.skip != 'true' run: npm run check:pack-artifact - name: Publish to npm - run: | - VERSION="${{ steps.resolve.outputs.version }}" - TAG="${{ steps.resolve.outputs.tag }}" - # Check if this version is already published — skip instead of failing with E403 - if npm view "omniroute@${VERSION}" version --silent 2>/dev/null | grep -q "^${VERSION}$"; then - echo "⚠️ Version ${VERSION} is already published on npm — skipping." - exit 0 - fi - if [ "$TAG" = "latest" ]; then - npm publish --access public - else - npm publish --access public --tag "$TAG" - fi - echo "✅ Published omniroute@$VERSION (tag: $TAG)" + if: steps.resolve.outputs.skip != 'true' env: + VERSION: ${{ steps.resolve.outputs.version }} + TAG: ${{ steps.resolve.outputs.tag }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + # Always pass --tag explicitly. Defense in depth: even if VERSION is + # accidentally an older release, `npm publish --tag historic` will + # NOT promote it to `@latest`. + npm publish --access public --tag "$TAG" + echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)" - name: Publish to GitHub Packages + if: steps.resolve.outputs.skip != 'true' + env: + VERSION: ${{ steps.resolve.outputs.version }} + TAG: ${{ steps.resolve.outputs.tag }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - VERSION="${{ steps.resolve.outputs.version }}" - TAG="${{ steps.resolve.outputs.tag }}" - + set -euo pipefail echo "Configuring for GitHub Packages..." - echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" > .npmrc + echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" > .npmrc npm pkg set name="@diegosouzapw/omniroute" - - if [ "$TAG" = "latest" ]; then - npm publish --registry=https://npm.pkg.github.com || echo "⚠️ Version ${VERSION} might already be published on GitHub." - else - npm publish --registry=https://npm.pkg.github.com --tag "$TAG" || echo "⚠️ Version ${VERSION} might already be published on GitHub." - fi + npm publish --registry=https://npm.pkg.github.com --tag "$TAG" \ + || echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages." echo "✅ Action finished for GitHub Packages" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} publish-opencode-plugin: runs-on: ubuntu-latest @@ -157,14 +196,17 @@ jobs: - name: Publish @omniroute/opencode-plugin to npm working-directory: "@omniroute/opencode-plugin" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} run: | + set -euo pipefail PKG_VERSION=$(node -p "require('./package.json').version") PKG_NAME=$(node -p "require('./package.json').name") - if npm view "${PKG_NAME}@${PKG_VERSION}" version --silent 2>/dev/null | grep -q "^${PKG_VERSION}$"; then + # Same hardened skip-check as the main job (no --silent flag). + PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)" + if [ "$PUBLISHED" = "$PKG_VERSION" ]; then echo "⚠️ ${PKG_NAME}@${PKG_VERSION} is already published on npm — skipping." exit 0 fi npm publish --access public --ignore-scripts echo "✅ Published ${PKG_NAME}@${PKG_VERSION}" - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 07341fcaae95c4ff4ee902126bba8271ab700a8d Mon Sep 17 00:00:00 2001 From: "M.M" Date: Tue, 26 May 2026 04:05:34 +0200 Subject: [PATCH 10/77] fix: add python3, make, g++ to builder stage apt-get for native addon compilation (#2713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.3 — required for native addon compilation (better-sqlite3) in the Docker builder stage. (cherry picked from commit 0dc516571d67636de5421ccb90415efc7b838f5f) --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3ffa72af5c58..764e36728698 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM node:24-trixie-slim AS builder WORKDIR /app RUN apt-get update \ - && apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \ + && apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates python3 make g++ \ && rm -rf /var/lib/apt/lists/* COPY package*.json ./ @@ -82,3 +82,4 @@ RUN apt-get update \ # Install CLI tools globally. Separate layer from apt for better cache reuse. RUN npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest + From a1d8b31171de96314417910fe3782c02d7a64512 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 25 May 2026 23:05:57 -0300 Subject: [PATCH 11/77] fix(i18n): restore real hint/placeholder text for web-cookie providers in en.json (#2694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.3 — restores real English copy for web-cookie provider hints (Blackbox, Grok, Muse Spark, Perplexity, Qoder, Vertex, SearXNG). (cherry picked from commit b7cbcbc6bfe8706914ba1f9d62632aad6eee2912) --- src/i18n/messages/en.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 2cc7e705df7b..93ba8e29fdf3 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -3752,8 +3752,8 @@ "aggregatorsGateways": "Aggregators Gateways", "enterpriseCloud": "Enterprise & Cloud", "apiFormatLabel": "Api Format Label", - "apiKeyOptionalHint": "Api Key Optional Hint", - "apiKeyOptionalLabel": "Api Key Optional Label", + "apiKeyOptionalHint": "Optional. Leave blank if your SearXNG instance does not require authentication.", + "apiKeyOptionalLabel": "API Key (optional)", "apiRegionChina": "Api Region China", "apiRegionHint": "Api Region Hint", "apiRegionInternational": "Api Region International", @@ -3765,8 +3765,8 @@ "audioShortLabel": "Audio Short Label", "azureOpenAiBaseUrlHint": "Azure Open Ai Base Url Hint", "bailianBaseUrlHint": "Bailian Base Url Hint", - "blackboxWebCookieHint": "Blackbox Web Cookie Hint", - "blackboxWebCookiePlaceholder": "Blackbox Web Cookie Placeholder", + "blackboxWebCookieHint": "Paste the __Secure-authjs.session-token cookie from app.blackbox.ai. A full cookie header also works.", + "blackboxWebCookiePlaceholder": "Paste your __Secure-authjs.session-token value", "t3ChatWebCookieHint": "Open t3.chat → DevTools → Application → Local Storage → https://t3.chat, copy 'convex-session-id'. Then open DevTools → Network, copy the full Cookie header from any chat request. Paste both values in the fields below.", "t3ChatWebCookiePlaceholder": "convex-session-id=abc123...", "blockClaudeExtraUsageDescription": "Hide extra Claude usage rows reported by some providers when they duplicate primary token accounting.", @@ -3840,8 +3840,8 @@ "tierFast": "Fast", "antigravityProjectIdLabel": "Google Cloud Project ID", "antigravityProjectIdPlaceholder": "my-gcp-project-id", - "grokWebCookieHint": "Grok Web Cookie Hint", - "grokWebCookiePlaceholder": "Grok Web Cookie Placeholder", + "grokWebCookieHint": "Paste the sso cookie from grok.com. A full `sso=...` value also works.", + "grokWebCookiePlaceholder": "Paste your sso cookie value from grok.com", "herokuBaseUrlHint": "Heroku Base Url Hint", "hideEmail": "Hide Email", "imageProviders": "Image Providers", @@ -3853,19 +3853,19 @@ "localProviderBaseUrlHint": "Local Provider Base Url Hint", "localProviders": "Local Providers", "maxConcurrentWholeNumberError": "Max Concurrent Whole Number Error", - "museSparkWebCookieHint": "Muse Spark Web Cookie Hint", - "museSparkWebCookiePlaceholder": "Muse Spark Web Cookie Placeholder", + "museSparkWebCookieHint": "Paste the abra_sess cookie from meta.ai. A full cookie header also works.", + "museSparkWebCookiePlaceholder": "Paste your abra_sess value", "oauth": "Oauth", "openCliTools": "Open Cli Tools", "openSettings": "Open Settings", "openaiResponsesStoreDescription": "Allow compatible Responses API requests to preserve stored response state.", "openaiResponsesStoreLabel": "OpenAI Responses store", "perplexitySearchSharedKeyInfo": "Perplexity Search Shared Key Info", - "perplexityWebCookieHint": "Perplexity Web Cookie Hint", - "perplexityWebCookiePlaceholder": "Perplexity Web Cookie Placeholder", - "personalAccessTokenLabel": "Personal Access Token Label", - "qoderPatHint": "Qoder Pat Hint", - "qoderPatPlaceholder": "Qoder Pat Placeholder", + "perplexityWebCookieHint": "Paste the __Secure-next-auth.session-token cookie from perplexity.ai.", + "perplexityWebCookiePlaceholder": "Paste your __Secure-next-auth.session-token value", + "personalAccessTokenLabel": "Personal Access Token", + "qoderPatHint": "Supported path: PAT via qodercli. Browser OAuth remains experimental.", + "qoderPatPlaceholder": "Paste your Qoder Personal Access Token", "refreshOauthTokenTitle": "Refresh Oauth Token Title", "regionHint": "Region Hint", "regionLabel": "Region Label", @@ -3883,7 +3883,7 @@ "searchProvidersHeading": "Search Providers Heading", "searxngBaseUrlHint": "Searxng Base Url Hint", "searxngInfo": "Searxng Info", - "sessionCookieLabel": "Session Cookie Label", + "sessionCookieLabel": "Session Cookie", "showEmail": "Show Email", "snowflakeBaseUrlHint": "Snowflake Base Url Hint", "supportedEndpointAudio": "Audio", @@ -3908,7 +3908,7 @@ "validationModelIdHint": "Validation Model Id Hint", "validationModelIdLabel": "Validation Model Id Label", "validationModelIdPlaceholder": "Validation Model Id Placeholder", - "vertexServiceAccountPlaceholder": "Vertex Service Account Placeholder", + "vertexServiceAccountPlaceholder": "Paste the Service Account JSON here", "webCookieProviders": "Web Cookie Providers", "weeklyShort": "Weekly Short", "xiaomiMimoBaseUrlHint": "Xiaomi Mimo Base Url Hint", From ad3d4b696a550301e879843af33d964a6708d4c9 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 25 May 2026 23:09:14 -0300 Subject: [PATCH 12/77] fix(oauth): Codex race + comprehensive provider error handling (#2718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.3 — comprehensive OAuth refresh race fix (Fix A-F via onPersist/AsyncLocalStorage + mutex consolidation). Replaces token-refresh-race.test.ts with broader token-refresh-race-comprehensive.test.ts that preserves the original invariant plus 11 new assertions. (cherry picked from commit ac76863deda9916f72e69607ef2f7136b6dd4263) --- open-sse/config/providerRegistry.ts | 28 + open-sse/executors/base.ts | 36 +- open-sse/handlers/chatCore.ts | 45 +- open-sse/services/tokenRefresh.ts | 574 ++++++++++++++++-- src/app/api/providers/[id]/refresh/route.ts | 26 +- src/app/api/providers/[id]/test/route.ts | 37 +- src/lib/oauth/constants/oauth.ts | 14 + src/lib/oauth/providers/claude.ts | 8 + src/lib/oauth/utils/codexAuthFile.ts | 43 +- src/lib/oauth/utils/codexAuthImport.ts | 19 + src/lib/tokenHealthCheck.ts | 222 +++++-- src/sse/handlers/chat.ts | 5 +- src/sse/services/tokenRefresh.ts | 86 ++- .../oauth-providers-error-handling.test.ts | 200 ++++++ .../token-refresh-race-comprehensive.test.ts | 171 ++++++ tests/unit/token-refresh-race.test.ts | 134 ---- tests/unit/token-refresh-service.test.ts | 36 +- 17 files changed, 1368 insertions(+), 316 deletions(-) create mode 100644 tests/unit/oauth-providers-error-handling.test.ts create mode 100644 tests/unit/token-refresh-race-comprehensive.test.ts delete mode 100644 tests/unit/token-refresh-race.test.ts diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 0a0dd6628df9..6f4c58fd550c 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -38,6 +38,7 @@ import { } from "./providerHeaderProfiles.ts"; import type { ProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; import { resolvePublicCred } from "../utils/publicCreds.ts"; +import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "@/lib/oauth/gitlab"; // ── Types ───────────────────────────────────────────────────────────────── @@ -1048,6 +1049,33 @@ export const REGISTRY: Record = { ], }, + "gitlab-duo": { + id: "gitlab-duo", + alias: "gld", + format: "openai", + executor: "gitlab", + // baseUrl is dynamic: resolved at request time from providerSpecificData.baseUrl + // by GitlabExecutor.buildUrl() via buildGitLabOAuthEndpoints(). + // The default here keeps the PROVIDERS map non-null so refreshAccessToken() + // can look up this provider. + baseUrl: buildGitLabOAuthEndpoints(GITLAB_DUO_DEFAULT_BASE_URL).publicCompletionsUrl, + authType: "oauth", + authHeader: "bearer", + defaultContextLength: 128000, + oauth: { + clientIdEnv: "GITLAB_DUO_OAUTH_CLIENT_ID", + clientIdDefault: process.env.GITLAB_OAUTH_CLIENT_ID || "", + clientSecretEnv: "GITLAB_DUO_OAUTH_CLIENT_SECRET", + clientSecretDefault: process.env.GITLAB_OAUTH_CLIENT_SECRET || "", + tokenUrl: buildGitLabOAuthEndpoints(GITLAB_DUO_DEFAULT_BASE_URL).tokenUrl, + authUrl: buildGitLabOAuthEndpoints(GITLAB_DUO_DEFAULT_BASE_URL).authorizeUrl, + }, + models: [ + { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (GitLab Duo)" }, + { id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (GitLab Duo)" }, + ], + }, + cursor: { id: "cursor", alias: "cu", diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 02aabd756db1..10a21b9172f9 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -8,6 +8,7 @@ import { } from "../services/apiKeyRotator.ts"; import type { KeyHealth } from "../services/apiKeyRotator.ts"; import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts"; +import { runWithOnPersist, getRefreshLeadMs } from "../services/tokenRefresh.ts"; import type { ProviderRequestDefaults } from "../services/providerRequestDefaults.ts"; import { signRequestBody } from "../services/claudeCodeCCH.ts"; import { @@ -453,7 +454,12 @@ export class BaseExecutor { needsRefresh(credentials?: ProviderCredentials | null) { if (!credentials?.expiresAt) return false; const expiresAtMs = new Date(credentials.expiresAt).getTime(); - return expiresAtMs - Date.now() < 5 * 60 * 1000; + // Use the provider-specific lead time (REFRESH_LEAD_MS) so rotating-token + // providers like Codex refresh proactively far ahead of expiry. Keeping the + // refresh_token "warm" prevents Auth0 from marking it as stale and revoking + // the token family on first use after long idle. + const lead = getRefreshLeadMs(this.provider); + return expiresAtMs - Date.now() < lead; } parseError(response: Response, bodyText: string) { @@ -551,14 +557,34 @@ export class BaseExecutor { if (this.needsRefresh(credentials)) { try { - const refreshed = await this.refreshCredentials(credentials, log || null); - if (refreshed) { + // Fix A: wire onCredentialsRefreshed through runWithOnPersist so it runs + // INSIDE the per-connection mutex inside getAccessToken. Not every + // executor routes through getAccessToken (e.g. github.ts), so use a flag + // to detect whether the persist callback actually fired and fall back to + // post-refresh mutation when it didn't. + let proactivePersistRan = false; + const proactiveOnPersist = arguments[0].onCredentialsRefreshed + ? async (refreshResult: Record) => { + proactivePersistRan = true; + activeCredentials = { + ...credentials, + ...(refreshResult as Partial), + }; + await arguments[0].onCredentialsRefreshed( + refreshResult as Partial + ); + } + : null; + + const refreshed = await runWithOnPersist(proactiveOnPersist, () => + this.refreshCredentials(credentials, log || null) + ); + + if (refreshed && !proactivePersistRan) { activeCredentials = { ...credentials, ...refreshed, }; - // Persist the proactively refreshed credentials to prevent consuming rotating tokens - // without updating the central database connection. if (arguments[0].onCredentialsRefreshed) { await arguments[0].onCredentialsRefreshed(refreshed); } diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 2131cf532ad2..334c1a69f4a4 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -14,7 +14,11 @@ import { resolveStreamReadinessTimeout } from "../utils/streamReadinessPolicy.ts import { createStreamController, pipeWithDisconnect } from "../utils/streamHandler.ts"; import { createSseHeartbeatTransform, shapeForClientFormat } from "../utils/sseHeartbeat.ts"; import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/usageTracking.ts"; -import { refreshWithRetry, isUnrecoverableRefreshError } from "../services/tokenRefresh.ts"; +import { + refreshWithRetry, + isUnrecoverableRefreshError, + runWithOnPersist, +} from "../services/tokenRefresh.ts"; import { createRequestLogger } from "../utils/requestLogger.ts"; import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts"; import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../config/defaultThinkingSignature.ts"; @@ -3813,8 +3817,30 @@ export async function handleChatCore({ isQwenExpiredError) && !hadStreamOptions // Skip refresh if failure may be from stream_options removal, not auth ) { + // Fix A: wrap refreshCredentials in runWithOnPersist so the persist callback + // executes INSIDE the per-connection mutex held by getAccessToken. This makes + // [network refresh + DB write + outer-state mutation] one atomic step and + // prevents concurrent requests from reading a stale refreshToken before the + // DB has been updated (refresh_token_reused on Codex/OpenAI). + // + // Not every executor routes refresh through getAccessToken (e.g. github.ts + // calls refreshCopilotToken directly). When the persistFn doesn't fire from + // inside getAccessToken, we still need to do the credentials mutation + user + // callback after refreshCredentials returns. The `persistFnRan` flag tracks + // which path executed so we don't double-fire (race-prone) or skip (regression). + let persistFnRan = false; + const persistFn = onCredentialsRefreshed + ? async (refreshResult: any) => { + persistFnRan = true; + // Mutate the shared credentials object so subsequent executor calls + // in this request see the new tokens. Runs INSIDE the mutex. + Object.assign(credentials, refreshResult); + await onCredentialsRefreshed(refreshResult); + } + : undefined; + const newCredentials = (await refreshWithRetry( - () => executor.refreshCredentials(credentials, log), + () => runWithOnPersist(persistFn, () => executor.refreshCredentials(credentials, log)), 3, log, provider // Explicitly pass the provider to avoid universally tripping the "unknown" circuit breaker @@ -3826,12 +3852,15 @@ export async function handleChatCore({ if (newCredentials?.accessToken || newCredentials?.copilotToken) { log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`); - // Update credentials - Object.assign(credentials, newCredentials); - - // Notify caller about refreshed credentials - if (onCredentialsRefreshed && newCredentials) { - await onCredentialsRefreshed(newCredentials); + // Fall back to post-mutex mutation only for executors that don't route + // through getAccessToken (and therefore never fire onPersist). For + // executors that DO route through it (Codex, Claude, Gemini, etc.) the + // mutation already happened atomically inside the mutex. + if (!persistFnRan) { + Object.assign(credentials, newCredentials); + if (onCredentialsRefreshed) { + await onCredentialsRefreshed(newCredentials); + } } // Retry with new credentials — model + extra headers follow translatedBody.model so they diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 94001479ce5b..f925475d938f 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -1,13 +1,59 @@ // @ts-nocheck +import { AsyncLocalStorage } from "node:async_hooks"; import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts"; import { getGitHubCopilotRefreshHeaders } from "../config/providerHeaderProfiles.ts"; import { pbkdf2Sync } from "node:crypto"; import { runWithProxyContext } from "../utils/proxyFetch.ts"; import { WINDSURF_CONFIG } from "@/lib/oauth/constants/oauth"; +import { buildGitLabOAuthEndpoints, resolveGitLabOAuthBaseUrl } from "@/lib/oauth/gitlab"; -// Token expiry buffer (refresh if expires within 5 minutes) +// Default token expiry buffer (refresh if expires within 5 minutes). +// Used as fallback for providers without an explicit lead time in +// REFRESH_LEAD_MS below. export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; +// Per-provider proactive-refresh lead time. +// +// For multi-account OAuth on providers that enforce "single active session per +// client_id" (notably OpenAI Codex / Auth0), refreshing one account's token +// can invalidate the refresh_token family of OTHER accounts under the same +// client. We MINIMIZE refresh frequency for these providers: stay on the +// original access_token until it is genuinely about to expire, so each account +// gets the full access_token lifetime without triggering Auth0's family- +// invalidation logic on its siblings. +// +// Trade-off: when refresh finally happens (last 5 min before expiry), Auth0 +// MAY invalidate other accounts' refresh_tokens. The user must re-auth those. +// This is the upstream limitation documented in openai/codex#9648. +// +// Providers with non-rotating tokens (Google, Anthropic) or where multi- +// account is naturally isolated keep longer lead times. +export const REFRESH_LEAD_MS: Record = { + // Rotating refresh tokens — minimize refresh frequency to avoid the + // "refresh-invalidates-siblings" cascade documented for OpenAI Auth0. + codex: 5 * 60 * 1000, // 5 minutes + openai: 5 * 60 * 1000, // same Auth0 backend as codex + claude: 5 * 60 * 1000, // Anthropic OAuth rotates refresh_tokens (user-reported) + "gitlab-duo": 5 * 60 * 1000, // GitLab token family revocation on misuse + kiro: 5 * 60 * 1000, // AWS SSO OIDC issues one-time-use refresh tokens + "kimi-coding": 5 * 60 * 1000, // Moonshot rotates per-refresh + qwen: 5 * 60 * 1000, // Alibaba device-code path also rotates + // Non-rotating providers — longer lead is safe. + iflow: 24 * 60 * 60 * 1000, // 24 hours + // Google OAuth refresh_tokens are permanent (non-rotating) — longer lead + // is safe and reduces unnecessary upstream chatter. + "gemini-cli": 15 * 60 * 1000, + antigravity: 15 * 60 * 1000, +}; + +/** + * Get the proactive refresh lead time (ms) for a given provider. + * Falls back to TOKEN_EXPIRY_BUFFER_MS (5 min) when not explicitly listed. + */ +export function getRefreshLeadMs(provider: string): number { + return REFRESH_LEAD_MS[provider] ?? TOKEN_EXPIRY_BUFFER_MS; +} + const CACHE_SECRET = "omniroute-token-cache"; // In-flight refresh promise cache to prevent race conditions @@ -19,6 +65,98 @@ const refreshPromiseCache = new Map(); // Primary dedup when credentials.connectionId is present; refreshPromiseCache is fallback. const connectionRefreshMutex = new Map(); +// ─── Token Rotation Map (codex-multi-auth pattern) ───────────────────────── +// +// When a rotating-token provider (Codex, Kimi, GitLab Duo, etc.) refreshes, +// the old refresh_token is consumed and a new one is issued. Any subsequent +// caller arriving with the OLD token would, without protection, hit upstream +// and trigger "refresh_token_reused" — which Auth0 treats as a security event +// and invalidates the entire token family. +// +// This in-memory map caches RECENT rotations so a stale caller can be redirected +// to the new tokens WITHOUT touching upstream. The DB staleness check inside +// the per-connection mutex covers the same scenario when connectionId is known, +// but not all callers pass connectionId (e.g., legacy code paths, retries that +// snapshot credentials before the rotation lands in DB). +// +// Ported from ndycode/codex-multi-auth (lib/refresh-queue.ts:218-248), the only +// publicly known tool that reliably sustains multiple Codex OAuth accounts. +// +// Key format: `provider:sha256(oldRefreshToken)` +// Value: { result: tokens, expiresAt: ms_since_epoch } +type RotationEntry = { + result: { accessToken: string; refreshToken: string; expiresIn?: number; expiresAt?: string }; + expiresAt: number; +}; +const tokenRotationMap = new Map(); +const ROTATION_MAP_TTL_MS = 60 * 1000; // 60 seconds — long enough to catch in-flight stale callers + +function cleanupRotationMap(now: number = Date.now()): void { + if (tokenRotationMap.size === 0) return; + for (const [key, entry] of tokenRotationMap.entries()) { + if (entry.expiresAt <= now) tokenRotationMap.delete(key); + } +} + +function lookupRotation(provider: string, refreshToken: string): RotationEntry | undefined { + cleanupRotationMap(); + const key = getRefreshCacheKey(provider, refreshToken); + const entry = tokenRotationMap.get(key); + if (!entry) return undefined; + if (entry.expiresAt <= Date.now()) { + tokenRotationMap.delete(key); + return undefined; + } + return entry; +} + +function recordRotation( + provider: string, + oldRefreshToken: string, + result: { accessToken: string; refreshToken: string; expiresIn?: number; expiresAt?: string } +): void { + if (!oldRefreshToken || !result.refreshToken || oldRefreshToken === result.refreshToken) { + return; + } + const key = getRefreshCacheKey(provider, oldRefreshToken); + tokenRotationMap.set(key, { + result, + expiresAt: Date.now() + ROTATION_MAP_TTL_MS, + }); +} + +// Exported for tests + diagnostics; not part of the public API surface. +export function _getTokenRotationMapStats(): { size: number; entries: number } { + cleanupRotationMap(); + return { size: tokenRotationMap.size, entries: tokenRotationMap.size }; +} + +export function _clearTokenRotationMap(): void { + tokenRotationMap.clear(); +} + +// AsyncLocalStorage for plumbing `onPersist` through executor.refreshCredentials +// without modifying every executor's signature. The chatCore.ts / base.ts call +// sites wrap executor.refreshCredentials in `runWithOnPersist(persistFn, () => ...)` +// and `getAccessToken` reads the active store as a fallback when no explicit +// onPersist parameter is provided. This keeps Fix A's atomic [refresh + persist] +// guarantee while avoiding per-executor signature changes. +type RefreshPersistResult = Record; +type RefreshPersistFn = (result: RefreshPersistResult) => Promise; +const onPersistStore = new AsyncLocalStorage(); + +export function runWithOnPersist( + onPersist: RefreshPersistFn | undefined | null, + fn: () => Promise +): Promise { + if (!onPersist) return fn(); + return onPersistStore.run(onPersist, fn); +} + +export function getActiveOnPersist(): RefreshPersistFn | undefined { + return onPersistStore.getStore(); +} + type RefreshLogger = { info?: (tag: string, message: string, data?: Record) => void; warn?: (tag: string, message: string, data?: Record) => void; @@ -177,6 +315,36 @@ export async function refreshWindsurfToken( status: response.status, error: errorText.slice(0, 200), }); + + // Firebase STS returns structured errors. Detect unrecoverable token states. + try { + const fbError = JSON.parse(errorText); + const fbCode = + typeof fbError?.error?.message === "string" + ? fbError.error.message + : typeof fbError?.error === "string" + ? fbError.error + : null; + if ( + typeof fbCode === "string" && + (fbCode.includes("USER_DISABLED") || + fbCode.includes("TOKEN_EXPIRED") || + fbCode.includes("INVALID_REFRESH_TOKEN") || + fbCode.includes("USER_NOT_FOUND")) + ) { + log?.error?.( + "TOKEN_REFRESH", + "Windsurf Firebase token is permanently invalid. Re-authentication required.", + { + fbCode, + } + ); + return { error: "unrecoverable_refresh_error", code: fbCode }; + } + } catch { + // not JSON — fall through + } + return null; } @@ -261,20 +429,39 @@ export async function refreshClineToken(refreshToken, log, proxyConfig: unknown /** * Specialized refresh for Kimi Coding OAuth tokens. * Uses custom X-Msh-* headers required by Kimi OAuth API. + * + * Uses a stable device_id from providerSpecificData (stored at login) to avoid + * anti-bot detection from ephemeral IDs. If absent, derives a deterministic ID + * from the refresh token hash so it is at least stable across refreshes for the + * same token. */ -export async function refreshKimiCodingToken(refreshToken, log, proxyConfig: unknown = null) { +export async function refreshKimiCodingToken( + refreshToken: string, + providerSpecificData: Record | null | undefined, + log: RefreshLogger, + proxyConfig: unknown = null +) { const endpoint = PROVIDERS["kimi-coding"]?.refreshUrl || PROVIDERS["kimi-coding"]?.tokenUrl; if (!endpoint) { log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Kimi Coding"); return null; } - // Generate device info for headers (same as OAuth flow) - const deviceId = "kimi-refresh-" + Date.now(); - const platform = "omniroute"; - const version = "2.1.2"; - const deviceModel = - typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown"; + // Prefer stable device_id persisted at login time; fall back to a + // deterministic hash of the refresh token so it is at least consistent + // across refreshes for the same session. + const stableDeviceId = + (providerSpecificData?.deviceId as string) || + pbkdf2Sync(refreshToken, "kimi-device-id", 1000, 16, "sha256").toString("hex"); + + const platform = "kimi_cli"; + const version = process.env.KIMI_CLI_VERSION || "1.36.0"; + + // Build device model string matching the format from providers/kimi-coding.ts. + // open-sse is a portable workspace — use process.platform/arch (always available in Node). + const osTypeStr = typeof process !== "undefined" ? process.platform : "unknown"; + const archStr = typeof process !== "undefined" ? process.arch : "unknown"; + const deviceModel = [osTypeStr, archStr].filter(Boolean).join(" "); try { const params = new URLSearchParams({ @@ -292,7 +479,12 @@ export async function refreshKimiCodingToken(refreshToken, log, proxyConfig: unk "X-Msh-Platform": platform, "X-Msh-Version": version, "X-Msh-Device-Model": deviceModel, - "X-Msh-Device-Id": deviceId, + "X-Msh-Device-Id": stableDeviceId, + // These headers match getKimiOAuthHeaders() in providers/kimi-coding.ts. + // They're derived at runtime from os module calls; use safe fallbacks here + // since open-sse is a portable workspace without direct fs/os access. + "X-Msh-Device-Name": (providerSpecificData?.deviceName as string) || osTypeStr, + "X-Msh-Os-Version": (providerSpecificData?.osVersion as string) || osTypeStr, }, body: params, }) @@ -300,9 +492,28 @@ export async function refreshKimiCodingToken(refreshToken, log, proxyConfig: unk if (!response.ok) { const errorText = await response.text(); + + // Detect unrecoverable errors + try { + const parsed = JSON.parse(errorText); + const errorCode = parsed?.error; + if (errorCode === "invalid_grant" || errorCode === "invalid_request") { + log?.error?.( + "TOKEN_REFRESH", + "Kimi Coding refresh token invalid. Re-authentication required.", + { + errorCode, + } + ); + return { error: "unrecoverable_refresh_error", code: errorCode }; + } + } catch { + // not JSON — fall through + } + log?.error?.("TOKEN_REFRESH", "Failed to refresh Kimi Coding token", { status: response.status, - error: errorText, + error: errorText.slice(0, 200), }); return null; } @@ -322,7 +533,106 @@ export async function refreshKimiCodingToken(refreshToken, log, proxyConfig: unk scope: tokens.scope, }; } catch (error) { - log?.error?.("TOKEN_REFRESH", `Network error refreshing Kimi Coding token: ${error.message}`); + log?.error?.( + "TOKEN_REFRESH", + `Network error refreshing Kimi Coding token: ${error instanceof Error ? error.message : String(error)}` + ); + return null; + } +} + +/** + * Specialized refresh for GitLab Duo OAuth tokens. + * Token URL is instance-specific; resolves from providerSpecificData.baseUrl. + * Uses PKCE authorization_code flow initially but refresh_token grant does NOT + * require code_verifier — only client_id + refresh_token. + * On invalid_grant (revoked/expired refresh token) returns the unrecoverable sentinel. + */ +export async function refreshGitLabDuoToken( + refreshToken: string, + providerSpecificData: Record | null | undefined, + log: RefreshLogger, + proxyConfig: unknown = null +) { + if (!refreshToken) { + log?.warn?.("TOKEN_REFRESH", "No refresh token for GitLab Duo"); + return null; + } + + const baseUrl = resolveGitLabOAuthBaseUrl(providerSpecificData); + const endpoints = buildGitLabOAuthEndpoints(baseUrl); + const tokenUrl = endpoints.tokenUrl; + + // client_id from providerSpecificData (stored at login) or fall back to PROVIDERS config + const clientId = + (providerSpecificData?.clientId as string) || + PROVIDERS["gitlab-duo"]?.clientId || + process.env.GITLAB_DUO_OAUTH_CLIENT_ID || + process.env.GITLAB_OAUTH_CLIENT_ID || + ""; + + try { + const response = await runWithProxyContext(proxyConfig, () => + fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: buildFormParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: clientId, + }), + }) + ); + + if (!response.ok) { + const errorText = await response.text(); + + // Detect unrecoverable token — GitLab returns standard OAuth2 error codes. + try { + const errorBody = JSON.parse(errorText); + const errorCode = errorBody.error; + if (errorCode === "invalid_grant" || errorCode === "invalid_request") { + log?.error?.( + "TOKEN_REFRESH", + "GitLab Duo refresh token invalid. Re-authentication required.", + { + errorCode, + } + ); + return { error: "unrecoverable_refresh_error", code: errorCode }; + } + } catch { + // not JSON — fall through + } + + log?.error?.("TOKEN_REFRESH", "Failed to refresh GitLab Duo token", { + status: response.status, + error: errorText.slice(0, 200), + }); + return null; + } + + const tokens = await response.json(); + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitLab Duo token", { + hasNewAccessToken: !!tokens.access_token, + hasNewRefreshToken: !!tokens.refresh_token, + expiresIn: tokens.expires_in, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in, + }; + } catch (error) { + log?.error?.( + "TOKEN_REFRESH", + `Network error refreshing GitLab Duo token: ${error instanceof Error ? error.message : String(error)}` + ); return null; } } @@ -364,7 +674,7 @@ export async function refreshClaudeOAuthToken(refreshToken, log, proxyConfig: un error: errorBody, }); if (errorBody.error === "invalid_grant" || errorBody.error === "invalid_request") { - return { error: errorBody.error, code: `http_${response.status}` }; + return { error: "unrecoverable_refresh_error", code: errorBody.error }; } return null; } @@ -418,8 +728,22 @@ export async function refreshGoogleToken( const errorText = await response.text(); log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", { status: response.status, - error: errorText, + error: errorText.slice(0, 200), }); + + // Detect unrecoverable token (invalid_grant = revoked / expired refresh token) + try { + const errorBody = JSON.parse(errorText); + if (errorBody.error === "invalid_grant") { + log?.error?.("TOKEN_REFRESH", "Google refresh token invalid. Re-authentication required.", { + provider: "google", + }); + return { error: "unrecoverable_refresh_error", code: "invalid_grant" }; + } + } catch { + // not JSON — fall through + } + return null; } @@ -486,15 +810,16 @@ export async function refreshQwenToken(refreshToken, log, proxyConfig: unknown = // not JSON, ignore } - if (errorCode === "invalid_request") { + if (errorCode === "invalid_request" || errorCode === "invalid_grant") { log?.error?.( "TOKEN_REFRESH", "Qwen refresh token is invalid or expired. Re-authentication required.", { status: response.status, + errorCode, } ); - return { error: "invalid_request" }; + return { error: "unrecoverable_refresh_error", code: errorCode }; } log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, { @@ -527,11 +852,17 @@ export async function refreshCodexToken(refreshToken, log, proxyConfig: unknown "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, + // Body intentionally omits `scope`. RFC 6749 §6 makes scope optional on a + // refresh_token grant (the server reuses the originally-granted scope when + // absent). Including `scope` causes Auth0 (which OpenAI Codex OAuth is + // built on) to treat the request as a re-scope, which can invalidate + // sibling refresh_token families on the same client_id. Matches the + // pattern used by ndycode/codex-multi-auth, the only known tool that + // sustains multiple Codex accounts without cross-invalidation. body: buildFormParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: PROVIDERS.codex.clientId, - scope: "openid profile email offline_access", }), }) ); @@ -636,9 +967,32 @@ export async function refreshKiroToken( if (!response.ok) { const errorText = await response.text(); + + // AWS SSO OIDC uses {"__type": "InvalidGrantException"} error format (not standard OAuth2). + try { + const awsError = JSON.parse(errorText); + const awsErrorType = awsError.__type || awsError.error; + if ( + awsErrorType === "InvalidGrantException" || + awsErrorType === "ExpiredTokenException" || + awsErrorType === "invalid_grant" + ) { + log?.error?.( + "TOKEN_REFRESH", + "Kiro AWS refresh token expired/invalid. Re-authentication required.", + { + awsErrorType, + } + ); + return { error: "unrecoverable_refresh_error", code: awsErrorType }; + } + } catch { + // not JSON — fall through + } + log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", { status: response.status, - error: errorText, + error: errorText.slice(0, 200), }); return null; } @@ -678,9 +1032,32 @@ export async function refreshKiroToken( if (!response.ok) { const errorText = await response.text(); + + // Also check for AWS-style errors on the social auth path (Kiro may relay them) + try { + const awsError = JSON.parse(errorText); + const awsErrorType = awsError.__type || awsError.error; + if ( + awsErrorType === "InvalidGrantException" || + awsErrorType === "ExpiredTokenException" || + awsErrorType === "invalid_grant" + ) { + log?.error?.( + "TOKEN_REFRESH", + "Kiro social refresh token expired/invalid. Re-authentication required.", + { + awsErrorType, + } + ); + return { error: "unrecoverable_refresh_error", code: awsErrorType }; + } + } catch { + // not JSON — fall through + } + log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", { status: response.status, - error: errorText, + error: errorText.slice(0, 200), }); return null; } @@ -885,7 +1262,20 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: return await refreshClineToken(credentials.refreshToken, log, proxyConfig); case "kimi-coding": - return await refreshKimiCodingToken(credentials.refreshToken, log, proxyConfig); + return await refreshKimiCodingToken( + credentials.refreshToken, + credentials.providerSpecificData, + log, + proxyConfig + ); + + case "gitlab-duo": + return await refreshGitLabDuoToken( + credentials.refreshToken, + credentials.providerSpecificData, + log, + proxyConfig + ); case "windsurf": case "devin-cli": @@ -921,6 +1311,7 @@ export function supportsTokenRefresh(provider) { "kimi-coding", "windsurf", "devin-cli", + "gitlab-duo", ]); if (explicitlySupported.has(provider)) return true; const config = PROVIDERS[provider]; @@ -957,13 +1348,30 @@ export function isUnrecoverableRefreshError(result) { * Additionally, when connectionId is present, the stale-token check reads the DB to detect * whether another process already refreshed the token. If the DB token is still valid it is * returned immediately without a new upstream call. + * + * @param onPersist - Optional callback invoked INSIDE the per-connection mutex closure after a + * successful refresh, before the mutex releases. Use this to atomically persist the new tokens + * to the DB within the same lock window. If `onPersist` throws, the error is logged and + * re-thrown so the caller is aware of the persistence failure. */ -export async function getAccessToken(provider, credentials, log, proxyConfig: unknown = null) { +export async function getAccessToken( + provider, + credentials, + log, + proxyConfig: unknown = null, + onPersist?: RefreshPersistFn +) { if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") { log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`); return null; } + // If the caller did not pass onPersist explicitly, fall back to the active + // AsyncLocalStorage store. This lets `runWithOnPersist(persistFn, () => + // executor.refreshCredentials(creds, log))` plumb the persist callback through + // executors (e.g. CodexExecutor) without modifying their signature. + const effectiveOnPersist = onPersist ?? getActiveOnPersist(); + const connectionId = credentials.connectionId; // ── Layer 1: per-connection mutex ────────────────────────────────────────── @@ -980,12 +1388,29 @@ export async function getAccessToken(provider, credentials, log, proxyConfig: un } const entry = { promise: null, waiters: 0 }; - entry.promise = _getAccessTokenWithStalenessCheck( - provider, - credentials, - log, - proxyConfig - ).finally(() => { + entry.promise = (async () => { + const result = await _getAccessTokenWithStalenessCheck( + provider, + credentials, + log, + proxyConfig + ); + // Invoke onPersist INSIDE the mutex so [network call + DB write] are one atomic step. + // This prevents a concurrent waiter from reading stale credentials before the DB is updated. + if (result?.accessToken && effectiveOnPersist) { + try { + await effectiveOnPersist(result); + } catch (persistErr) { + const { sanitizeErrorMessage } = await import("../utils/error.ts"); + log?.error?.( + "TOKEN_REFRESH", + `onPersist callback failed for ${provider}/${connectionId}: ${sanitizeErrorMessage(persistErr instanceof Error ? persistErr : new Error(String(persistErr)))}` + ); + throw persistErr; + } + } + return result; + })().finally(() => { connectionRefreshMutex.delete(connectionId); }); connectionRefreshMutex.set(connectionId, entry); @@ -1015,6 +1440,22 @@ export async function getAccessToken(provider, credentials, log, proxyConfig: un * Only called from the per-connection mutex path (Layer 1 above). */ async function _getAccessTokenWithStalenessCheck(provider, credentials, log, proxyConfig) { + // ROTATION MAP CHECK (codex-multi-auth pattern): if this refresh_token was + // rotated very recently (within ROTATION_MAP_TTL_MS), reuse the cached new + // tokens INSTEAD of hitting upstream. Auth0 treats re-use of a rotated token + // as a security event and revokes the entire token family — fatal for + // multi-account Codex setups. The in-memory rotation map catches this even + // when the caller bypasses the DB staleness path (no connectionId, stale + // in-memory credentials in retries, etc.). + const rotated = lookupRotation(provider, credentials.refreshToken); + if (rotated) { + log?.info?.( + "TOKEN_REFRESH", + `Rotation map hit for ${provider}. Returning cached rotated tokens (avoids family-revoke).` + ); + return rotated.result; + } + // RACE CONDITION PREVENTION: // If the credentials object in memory is stale (e.g. it waited in a semaphore while another // request refreshed the token), using its OLD refreshToken will cause the provider (e.g. OpenAI) @@ -1024,33 +1465,41 @@ async function _getAccessTokenWithStalenessCheck(provider, credentials, log, pro try { const { getProviderConnectionById } = await import("../../src/lib/db/providers"); const dbConnection = await getProviderConnectionById(credentials.connectionId); - if ( - dbConnection && - dbConnection.refreshToken && - dbConnection.refreshToken !== credentials.refreshToken - ) { - log?.info?.( - "TOKEN_REFRESH", - `Stale token detected in memory for ${provider}. Using refreshed token from DB.` - ); - - // If the DB token is not expired, we can just return it! + if (dbConnection && dbConnection.refreshToken) { const now = Date.now(); const dbExpiresAt = dbConnection.expiresAt ? new Date(dbConnection.expiresAt).getTime() : 0; - if (dbExpiresAt > now + 60000) { - // 60 seconds buffer - log?.info?.("TOKEN_REFRESH", `DB token is still valid. Skipping OAuth refresh.`); - return { - accessToken: dbConnection.accessToken, - refreshToken: dbConnection.refreshToken, - expiresIn: dbConnection.expiresIn, - }; - } else { - // DB token is also expired, but it's the NEWEST one. We must use it to refresh. - credentials.refreshToken = dbConnection.refreshToken; - credentials.accessToken = dbConnection.accessToken; + if (dbConnection.refreshToken !== credentials.refreshToken) { + log?.info?.( + "TOKEN_REFRESH", + `Stale token detected in memory for ${provider}. Using refreshed token from DB.` + ); + + // If the DB token is not expired, we can just return it! + if (dbExpiresAt > now + 60000) { + // 60 seconds buffer + log?.info?.("TOKEN_REFRESH", `DB token is still valid. Skipping OAuth refresh.`); + return { + accessToken: dbConnection.accessToken, + refreshToken: dbConnection.refreshToken, + // Return absolute expiresAt so downstream callers do NOT recompute lifetime + // from a relative expiresIn value (which would incorrectly extend the TTL). + // expiresIn intentionally omitted here. + expiresAt: dbConnection.expiresAt, + }; + } else { + // DB token is also expired, but it's the NEWEST one. We must use it to refresh. + credentials.refreshToken = dbConnection.refreshToken; + credentials.accessToken = dbConnection.accessToken; + } } + // NOTE: Fix F (skip when DB == memory and DB > now+60s) was intentionally + // removed. The caller (checkAndRefreshToken) already decided to refresh + // because the token is within TOKEN_EXPIRY_BUFFER_MS of expiry. Re-checking + // with a tighter 60-second window here would skip legitimate refreshes and + // let near-expired tokens hit the upstream. Layer-1 mutex (per-connection) + // and Layer-2 dedup (token-hash) already prevent concurrent refreshes for + // the import-burst scenario. } } catch (e) { log?.warn?.( @@ -1060,7 +1509,32 @@ async function _getAccessTokenWithStalenessCheck(provider, credentials, log, pro } } - return _getAccessTokenInternal(provider, credentials, log, proxyConfig); + const oldRefreshToken = credentials.refreshToken; + const result = await _getAccessTokenInternal(provider, credentials, log, proxyConfig); + + // Record the rotation so subsequent stale callers can be redirected to the + // new tokens without re-hitting upstream (which would trigger Auth0 family + // revocation). Only records when the refresh actually rotated the token. + if ( + result && + typeof result === "object" && + !("error" in result) && + (result as { accessToken?: string }).accessToken && + (result as { refreshToken?: string }).refreshToken + ) { + recordRotation( + provider, + oldRefreshToken, + result as { + accessToken: string; + refreshToken: string; + expiresIn?: number; + expiresAt?: string; + } + ); + } + + return result; } /** diff --git a/src/app/api/providers/[id]/refresh/route.ts b/src/app/api/providers/[id]/refresh/route.ts index ddc2c3cba692..bbe3bd6cef07 100644 --- a/src/app/api/providers/[id]/refresh/route.ts +++ b/src/app/api/providers/[id]/refresh/route.ts @@ -55,8 +55,15 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id }; // Use the existing getAccessToken helper which knows how to refresh - // tokens for each provider type (Claude, GitHub, Gemini, etc.) - const newCredentials = (await getAccessToken(provider, credentials)) as RefreshResult | null; + // tokens for each provider type (Claude, GitHub, Gemini, etc.). + // Pass onPersist so the DB write happens atomically INSIDE the per-connection + // mutex — prevents the race where a concurrent request reads stale credentials + // between the network call and the DB update. + let persistedCredentials: RefreshResult | null = null; + const newCredentials = (await getAccessToken(provider, credentials, async (result) => { + await updateProviderCredentials(id, result); + persistedCredentials = result; + })) as RefreshResult | null; if (newCredentials && typeof newCredentials === "object" && newCredentials.error) { if ( @@ -82,12 +89,17 @@ export async function POST(_request: Request, { params }: { params: Promise<{ id ); } - // Persist new credentials to DB - await updateProviderCredentials(id, newCredentials); + // If onPersist was not called (e.g. no connectionId in credentials path), persist now. + if (!persistedCredentials) { + await updateProviderCredentials(id, newCredentials); + } - const expiresAt = newCredentials.expiresIn - ? new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString() - : null; + const resolvedCreds = persistedCredentials || newCredentials; + const expiresAt = resolvedCreds.expiresAt + ? resolvedCreds.expiresAt + : resolvedCreds.expiresIn + ? new Date(Date.now() + resolvedCreds.expiresIn * 1000).toISOString() + : null; return NextResponse.json({ success: true, diff --git a/src/app/api/providers/[id]/test/route.ts b/src/app/api/providers/[id]/test/route.ts index fc3e5d1d6d1e..692c49737eec 100644 --- a/src/app/api/providers/[id]/test/route.ts +++ b/src/app/api/providers/[id]/test/route.ts @@ -303,13 +303,46 @@ async function refreshOAuthToken(connection: any) { if (!refreshToken) return null; try { - // Kiro needs extra fields the generic function expects + // Fix B: Pass connectionId + accessToken + expiresAt so getAccessToken enters + // the per-connection mutex (Layer 1) instead of falling through to the + // token-hash fallback (Layer 2). Without connectionId, parallel dashboard + // batch-tests would each acquire a separate Layer-2 lock keyed by token hash + // and concurrently POST the same refresh_token to Codex/OpenAI, triggering + // refresh_token_reused on rotating providers. const credentials = { + connectionId: connection.id, + accessToken: connection.accessToken, refreshToken, + expiresAt: connection.expiresAt, providerSpecificData: connection.providerSpecificData || {}, }; - const result = await getAccessToken(provider, credentials, console); + // Fix A: onPersist runs INSIDE the mutex inside getAccessToken so the DB + // write happens before the lock releases. This prevents a concurrent caller + // from reading the stale refresh_token between the network call and the DB + // update. + const result = await getAccessToken(provider, credentials, console, null, async (refreshed) => { + if (!refreshed?.accessToken) return; + const update: any = { + accessToken: refreshed.accessToken, + }; + if (refreshed.refreshToken) update.refreshToken = refreshed.refreshToken; + if (refreshed.expiresAt) { + update.expiresAt = refreshed.expiresAt; + update.tokenExpiresAt = refreshed.expiresAt; + } else if (refreshed.expiresIn) { + const expiresAt = new Date(Date.now() + refreshed.expiresIn * 1000).toISOString(); + update.expiresAt = expiresAt; + update.tokenExpiresAt = expiresAt; + } + if (refreshed.providerSpecificData) { + update.providerSpecificData = { + ...(connection.providerSpecificData || {}), + ...refreshed.providerSpecificData, + }; + } + await updateProviderConnection(connection.id, update); + }); return result; // { accessToken, expiresIn, refreshToken } or null } catch (err) { console.log(`Error refreshing ${provider} token:`, (err as any).message); diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index bfb86fd9ee73..5164ac75a63b 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -61,6 +61,16 @@ export const CODEX_CONFIG = { id_token_add_organizations: "true", codex_cli_simplified_flow: "true", originator: "codex_cli_rs", + // prompt=login forces Auth0/OpenAI to RE-AUTHENTICATE the user instead of + // silently reusing an existing browser session. This is THE KEY parameter + // that enables multi-account OAuth on the same device + same client_id: + // without it, OAuth flow #2 carries over session state from OAuth flow #1 + // and Auth0 invalidates the previous account's refresh_token family as a + // "session takeover". With prompt=login, each OAuth flow creates an + // isolated session that does not trample siblings. + // Ported from ndycode/codex-multi-auth (auth.ts: forceNewLogin option) — + // the only known tool that sustains multiple Codex OAuth accounts. + prompt: "login", }, }; @@ -178,6 +188,9 @@ export const ANTIGRAVITY_CONFIG = { // OpenAI OAuth Configuration (Authorization Code Flow with PKCE) // Re-uses CODEX_CONFIG.clientId to avoid duplication — same provider, different originator. +// IMPORTANT: same Auth0 backend as Codex → same multi-account session-takeover +// risk. `prompt: "login"` is mandatory to allow multiple OpenAI Native accounts +// on the same device. See CODEX_CONFIG above for the full explanation. export const OPENAI_CONFIG = { clientId: CODEX_CONFIG.clientId, authorizeUrl: "https://auth.openai.com/oauth/authorize", @@ -187,6 +200,7 @@ export const OPENAI_CONFIG = { extraParams: { id_token_add_organizations: "true", originator: "openai_native", + prompt: "login", }, }; diff --git a/src/lib/oauth/providers/claude.ts b/src/lib/oauth/providers/claude.ts index b3cd5151a4e1..2c6b48c673b0 100644 --- a/src/lib/oauth/providers/claude.ts +++ b/src/lib/oauth/providers/claude.ts @@ -89,6 +89,14 @@ export const claude = { code_challenge: codeChallenge, code_challenge_method: config.codeChallengeMethod, state: state, + // prompt=login forces Anthropic IdP to RE-AUTHENTICATE the user on each + // OAuth flow instead of silently reusing the browser session. Prevents + // the "session takeover" that invalidates the previous account's + // refresh_token family on the same client_id. Same pattern that unlocked + // multi-account Codex — applied defensively here because Anthropic's + // OAuth uses the same client_id across all accounts AND the user reports + // Claude tokens expiring in multi-account scenarios. + prompt: "login", }); return `${config.authorizeUrl}?${params.toString()}`; }, diff --git a/src/lib/oauth/utils/codexAuthFile.ts b/src/lib/oauth/utils/codexAuthFile.ts index dc801f6d3dfd..eec0841e50d6 100644 --- a/src/lib/oauth/utils/codexAuthFile.ts +++ b/src/lib/oauth/utils/codexAuthFile.ts @@ -223,15 +223,24 @@ async function resolveFreshCodexConnection(connectionId: string): Promise { + await updateProviderCredentials(connectionId, result); + persistedRefresh = result; + } + ); if (isUnrecoverableRefreshError(refreshed)) { throw new CodexAuthFileError( @@ -249,7 +258,16 @@ async function resolveFreshCodexConnection(connectionId: string): Promise 0; const isAboutToExpire = hasKnownExpiry && tokenExpiresAt - Date.now() < TOKEN_EXPIRY_BUFFER; - const shouldRefreshByInterval = !hasKnownExpiry && Date.now() - lastCheck >= intervalMs; + + // ROTATING_REFRESH_PROVIDERS — providers whose refresh_tokens are SINGLE-USE + // (each refresh consumes the old one and returns a new one). For these, refreshing + // on a fixed interval — instead of strictly on imminent expiry — burns rotations + // unnecessarily AND can trigger Auth0's token family revocation (especially OpenAI + // Codex). 9router did not have this background sweep; it was introduced in OmniRoute + // and is the root cause of "adding account B invalidates account A" reports. + // The interval path is kept ONLY for non-rotating providers where token state can + // drift silently (e.g. cookie-based, opaque sessions without expires_at). + const ROTATING_REFRESH_PROVIDERS = new Set([ + "codex", + "openai", + "kimi-coding", + "cline", + "kiro", + "amazon-q", + "gitlab-duo", + "claude", + ]); + const isRotatingProvider = ROTATING_REFRESH_PROVIDERS.has(conn.provider); + const shouldRefreshByInterval = + !hasKnownExpiry && !isRotatingProvider && Date.now() - lastCheck >= intervalMs; if (!isAboutToExpire && !shouldRefreshByInterval) return; @@ -323,21 +345,62 @@ export async function checkConnection(conn) { const hideLogs = await shouldHideLogs(); const proxyResolution = await resolveProxyForConnection(conn.id); const proxyConfig = extractResolvedProxyConfig(proxyResolution); + + const healthCheckLog = { + info: (tag: string, msg: string) => { + if (!hideLogs) console.log(LOG_PREFIX, `[${tag}]`, msg); + }, + warn: (tag: string, msg: string) => { + if (!hideLogs) console.warn(LOG_PREFIX, `[${tag}]`, msg); + }, + error: (tag: string, msg: string, extra?: Record) => { + if (!hideLogs) console.error(LOG_PREFIX, `[${tag}]`, msg, extra || ""); + }, + }; + + // Pass onPersist so the DB write is atomic with the network call inside the mutex. + // This prevents a concurrent sweep or request from reading stale credentials + // and re-using an already-consumed rotating refresh token (Codex/OpenAI). + let persistedResult: any = null; const result = await getAccessToken( conn.provider, credentials, - { - info: (tag, msg) => { - if (!hideLogs) console.log(`${LOG_PREFIX} [${tag}] ${msg}`); - }, - warn: (tag, msg) => { - if (!hideLogs) console.warn(`${LOG_PREFIX} [${tag}] ${msg}`); - }, - error: (tag, msg, extra) => { - if (!hideLogs) console.error(`${LOG_PREFIX} [${tag}] ${msg}`, extra || ""); - }, - }, - proxyConfig + healthCheckLog, + proxyConfig, + async (refreshResult) => { + const now = new Date().toISOString(); + const updateData: any = { + accessToken: refreshResult.accessToken, + lastHealthCheckAt: now, + testStatus: "active", + lastError: null, + lastErrorAt: null, + lastErrorType: null, + lastErrorSource: null, + errorCode: null, + expiredRetryCount: null, + expiredRetryAt: null, + }; + if (refreshResult.refreshToken) { + updateData.refreshToken = refreshResult.refreshToken; + } + if (refreshResult.expiresAt) { + updateData.expiresAt = refreshResult.expiresAt; + updateData.tokenExpiresAt = refreshResult.expiresAt; + } else if (refreshResult.expiresIn) { + const expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString(); + updateData.expiresAt = expiresAt; + updateData.tokenExpiresAt = expiresAt; + } + if (refreshResult.providerSpecificData) { + updateData.providerSpecificData = { + ...(conn.providerSpecificData || {}), + ...refreshResult.providerSpecificData, + }; + } + await updateProviderConnection(conn.id, updateData); + persistedResult = refreshResult; + } ); const now = new Date().toISOString(); @@ -402,41 +465,112 @@ export async function checkConnection(conn) { } if (result && result.accessToken) { - const updateData: any = { - accessToken: result.accessToken, - lastHealthCheckAt: now, - testStatus: "active", - lastError: null, - lastErrorAt: null, - lastErrorType: null, - lastErrorSource: null, - errorCode: null, - expiredRetryCount: null, - expiredRetryAt: null, - }; - - if (result.refreshToken) { - updateData.refreshToken = result.refreshToken; - } + // onPersist already wrote the core token fields atomically inside the mutex. + // Only write the lastHealthCheckAt timestamp (and any fields onPersist may have + // missed) here, to avoid a redundant full update that would race against another + // concurrent refresh that already wrote fresh credentials. + if (persistedResult) { + await updateProviderConnection(conn.id, { lastHealthCheckAt: now }); + } else { + // No onPersist (e.g. no connectionId — token-hash dedup path). Write all fields. + const updateData: any = { + accessToken: result.accessToken, + lastHealthCheckAt: now, + testStatus: "active", + lastError: null, + lastErrorAt: null, + lastErrorType: null, + lastErrorSource: null, + errorCode: null, + expiredRetryCount: null, + expiredRetryAt: null, + }; - if (result.expiresIn) { - const expiresAt = new Date(Date.now() + result.expiresIn * 1000).toISOString(); - updateData.expiresAt = expiresAt; - updateData.tokenExpiresAt = expiresAt; - } else if (result.expiresAt) { - updateData.expiresAt = result.expiresAt; - updateData.tokenExpiresAt = result.expiresAt; - } + if (result.refreshToken) { + updateData.refreshToken = result.refreshToken; + } - if (result.providerSpecificData) { - updateData.providerSpecificData = { - ...(conn.providerSpecificData || {}), - ...result.providerSpecificData, - }; - } + if (result.expiresAt) { + updateData.expiresAt = result.expiresAt; + updateData.tokenExpiresAt = result.expiresAt; + } else if (result.expiresIn) { + const expiresAt = new Date(Date.now() + result.expiresIn * 1000).toISOString(); + updateData.expiresAt = expiresAt; + updateData.tokenExpiresAt = expiresAt; + } - await updateProviderConnection(conn.id, updateData); + if (result.providerSpecificData) { + updateData.providerSpecificData = { + ...(conn.providerSpecificData || {}), + ...result.providerSpecificData, + }; + } + + await updateProviderConnection(conn.id, updateData); + } log(`${LOG_PREFIX} ✓ ${conn.provider}/${getConnectionLogLabel(conn)} refreshed`); + + // ── GitHub Copilot sub-token refresh ────────────────────────────────────── + // GitHub Copilot issues a short-lived (~30 min) API token separate from the + // GitHub OAuth token. The health check must also refresh this sub-token before + // it expires mid-session. The Copilot token expiry is stored in + // providerSpecificData.copilotTokenExpiresAt (Unix seconds). + if (conn.provider === "github") { + // Re-read the latest connection after the OAuth refresh (onPersist may have updated it). + const latestConn = (await getProviderConnectionById(conn.id).catch(() => null)) || conn; + const accessTokenForCopilot = result.accessToken || latestConn.accessToken; + + if (accessTokenForCopilot) { + const copilotExpiresAtRaw = + latestConn.providerSpecificData?.copilotTokenExpiresAt ?? + conn.providerSpecificData?.copilotTokenExpiresAt; + const copilotExpiresAtMs = + typeof copilotExpiresAtRaw === "number" && copilotExpiresAtRaw < 1e12 + ? copilotExpiresAtRaw * 1000 // Unix seconds → ms + : typeof copilotExpiresAtRaw === "string" + ? new Date(copilotExpiresAtRaw).getTime() + : typeof copilotExpiresAtRaw === "number" + ? copilotExpiresAtRaw + : 0; + + const copilotAboutToExpire = + !copilotExpiresAtMs || copilotExpiresAtMs - Date.now() < 5 * 60 * 1000; + + if (copilotAboutToExpire) { + log( + `${LOG_PREFIX} Refreshing GitHub Copilot sub-token for ${getConnectionLogLabel(conn)}` + ); + try { + const copilotResult = await refreshCopilotToken( + accessTokenForCopilot, + healthCheckLog, + proxyConfig + ); + if (copilotResult?.token) { + await updateProviderConnection(conn.id, { + providerSpecificData: { + ...(latestConn.providerSpecificData || {}), + copilotToken: copilotResult.token, + copilotTokenExpiresAt: copilotResult.expiresAt, + }, + }); + log( + `${LOG_PREFIX} ✓ GitHub Copilot sub-token refreshed for ${getConnectionLogLabel(conn)}` + ); + } else { + logWarn( + `${LOG_PREFIX} ✗ GitHub Copilot sub-token refresh failed for ${getConnectionLogLabel(conn)}` + ); + } + } catch (copilotErr) { + logError( + `${LOG_PREFIX} Error refreshing Copilot sub-token:`, + copilotErr?.message || copilotErr + ); + } + } + } + } } else { const updateData = buildRefreshFailureUpdate(conn, now); await updateProviderConnection(conn.id, updateData); diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 820983958013..da5e716d5653 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -200,7 +200,10 @@ export async function handleChat(request: any, clientRawRequest: any = null) { // Log request endpoint and model const url = new URL(request.url); - const modelStr = body.model; + // `let` because the middleware-hook pipeline (line ~319) may reassign this + // when a hook rewrites the target model. Previously declared `const`, which + // broke turbopack/strict-mode builds (PR #2670 regression). + let modelStr = body.model; // Count messages (support both messages[] and input[] formats) const msgCount = body.messages?.length || body.input?.length || 0; diff --git a/src/sse/services/tokenRefresh.ts b/src/sse/services/tokenRefresh.ts index c6b047150bb2..9d35b8dfe769 100755 --- a/src/sse/services/tokenRefresh.ts +++ b/src/sse/services/tokenRefresh.ts @@ -3,6 +3,7 @@ import * as log from "../utils/logger"; import { updateProviderConnection, resolveProxyForProvider } from "@/lib/localDb"; import { TOKEN_EXPIRY_BUFFER_MS as BUFFER_MS, + getRefreshLeadMs as _getRefreshLeadMs, refreshAccessToken as _refreshAccessToken, refreshClaudeOAuthToken as _refreshClaudeOAuthToken, refreshGoogleToken as _refreshGoogleToken, @@ -17,31 +18,13 @@ import { getAllAccessTokens as _getAllAccessTokens, } from "@omniroute/open-sse/services/tokenRefresh.ts"; -// Per-connection mutex: prevents concurrent OAuth refresh for rotating tokens. -// Key = connectionId, Value = { promise: in-flight refresh, waiters: count of callers sharing it } -const connectionRefreshMutex = new Map; waiters: number }>(); - -export async function withConnectionRefreshMutex( - connectionId: string, - fn: () => Promise -): Promise { - const existing = connectionRefreshMutex.get(connectionId); - if (existing) { - existing.waiters++; - log.info("TOKEN_REFRESH", "Concurrent refresh detected — sharing in-flight refresh", { - connectionId, - waiters: existing.waiters, - }); - return existing.promise as Promise; - } - - const entry: { promise: Promise; waiters: number } = { promise: null as any, waiters: 0 }; - entry.promise = fn().finally(() => { - connectionRefreshMutex.delete(connectionId); - }); - connectionRefreshMutex.set(connectionId, entry); - return entry.promise; -} +// DEPRECATED: withConnectionRefreshMutex was removed. The per-connection mutex +// is now consolidated in open-sse/services/tokenRefresh.ts and protected by +// passing an `onPersist` callback to `getAccessToken`, which runs the DB write +// INSIDE the mutex closure (atomic [network + persist]). The old src/sse-side +// mutex Map was redundant and created the illusion of two locks when there was +// actually only one. Removing it eliminates the dual-Map confusion. See +// docs/architecture/SSE_BOUNDARY.md and tests/unit/token-refresh-race-comprehensive.test.ts. export const TOKEN_EXPIRY_BUFFER_MS = BUFFER_MS; @@ -94,9 +77,13 @@ export const refreshCopilotToken = async (githubAccessToken: string) => { return _refreshCopilotToken(githubAccessToken, log, proxy); }; -export const getAccessToken = async (provider: string, credentials: any) => { +export const getAccessToken = async ( + provider: string, + credentials: any, + onPersist?: (result: any) => Promise +) => { const proxy = await resolveProxyForProvider(provider); - return _getAccessToken(provider, credentials, log, proxy); + return _getAccessToken(provider, credentials, log, proxy, onPersist); }; export const refreshTokenByProvider = async (provider: string, credentials: any) => { @@ -164,32 +151,39 @@ export async function updateProviderCredentials(connectionId: string, newCredent export async function checkAndRefreshToken(provider: string, credentials: any) { let updatedCredentials = { ...credentials }; - // Check regular token expiry + // Check regular token expiry. Use the provider-specific lead time so rotating- + // token providers (Codex/OpenAI) refresh FAR ahead of access_token expiry. This + // keeps the refresh_token "warm" — refreshed regularly enough that Auth0 doesn't + // mark it as stale and revoke the token family on first use after long idle. if (updatedCredentials.expiresAt) { const expiresAt = new Date(updatedCredentials.expiresAt).getTime(); const now = Date.now(); + const refreshLead = _getRefreshLeadMs(provider); - if (expiresAt - now < TOKEN_EXPIRY_BUFFER_MS) { + if (expiresAt - now < refreshLead) { log.info("TOKEN_REFRESH", "Token expiring soon, refreshing proactively", { provider, expiresIn: Math.round((expiresAt - now) / 1000), + refreshLeadMs: refreshLead, }); const connectionId: string | undefined = updatedCredentials.connectionId; - const newCredentials = connectionId - ? await withConnectionRefreshMutex(connectionId, async () => { - const result = await getAccessToken(provider, updatedCredentials); - if (result?.accessToken) { - // Persist BEFORE the mutex releases so a concurrent request - // cannot read stale DB credentials and re-use a rotated refresh token. - await updateProviderCredentials(connectionId, result); - } - return result; - }) - : await getAccessToken(provider, updatedCredentials); + + // Pass onPersist so the DB write happens INSIDE the open-sse per-connection + // mutex, making [network call + DB write] one atomic step. This eliminates the + // race where a concurrent request reads stale DB credentials before the write + // and re-uses a rotated refresh token (refresh_token_reused on Codex/OpenAI). + // The separate withConnectionRefreshMutex wrapper is no longer needed here. + const persistCallback = connectionId + ? async (result: any) => { + await updateProviderCredentials(connectionId, result); + } + : undefined; + + const newCredentials = await getAccessToken(provider, updatedCredentials, persistCallback); + if (newCredentials && newCredentials.accessToken) { - // DB already updated inside the mutex when connectionId is present. - // For the no-connectionId path, persist here as before. + // For the no-connectionId path (no mutex, no onPersist), persist here as before. if (!connectionId) { await updateProviderCredentials(updatedCredentials.connectionId, newCredentials); } @@ -198,9 +192,11 @@ export async function checkAndRefreshToken(provider: string, credentials: any) { ...updatedCredentials, accessToken: newCredentials.accessToken, refreshToken: newCredentials.refreshToken || updatedCredentials.refreshToken, - expiresAt: newCredentials.expiresIn - ? new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString() - : updatedCredentials.expiresAt, + expiresAt: newCredentials.expiresAt + ? newCredentials.expiresAt + : newCredentials.expiresIn + ? new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString() + : updatedCredentials.expiresAt, }; } } diff --git a/tests/unit/oauth-providers-error-handling.test.ts b/tests/unit/oauth-providers-error-handling.test.ts new file mode 100644 index 000000000000..70132f838229 --- /dev/null +++ b/tests/unit/oauth-providers-error-handling.test.ts @@ -0,0 +1,200 @@ +/** + * Structural regression tests for OAuth provider error handling. + * + * These are text-based assertions on source files (no network calls). + * They verify that each refresh function: + * - Exists with the expected signature + * - Handles the provider-specific unrecoverable error codes + * - Returns the normalized { error: "unrecoverable_refresh_error", code } sentinel + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "fs/promises"; +import path from "path"; + +const root = path.resolve(import.meta.dirname, "../.."); +const read = (rel: string) => readFile(path.join(root, rel), "utf8"); + +// ─── P0: GitLab Duo ─────────────────────────────────────────────────────────── + +test("P0: gitlab-duo is registered in providerRegistry", async () => { + const src = await read("open-sse/config/providerRegistry.ts"); + assert.match(src, /["']gitlab-duo["']\s*:/, "gitlab-duo must be a key in REGISTRY"); +}); + +test("P0: refreshGitLabDuoToken exists and handles invalid_grant as unrecoverable", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + assert.match( + src, + /export\s+async\s+function\s+refreshGitLabDuoToken\(/, + "refreshGitLabDuoToken must be exported" + ); + + // Extract the function body + const fnMatch = src.match(/export\s+async\s+function\s+refreshGitLabDuoToken\([\s\S]+?\n\}/); + assert.ok(fnMatch, "refreshGitLabDuoToken function body not found"); + assert.match(fnMatch[0], /invalid_grant/, "must detect invalid_grant"); + assert.match(fnMatch[0], /unrecoverable_refresh_error/, "must return unrecoverable sentinel"); +}); + +test("P0: gitlab-duo case exists in _getAccessTokenInternal", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + assert.match(src, /case\s+["']gitlab-duo["']/, "gitlab-duo case must exist in switch"); +}); + +test("P0: gitlab-duo is in supportsTokenRefresh explicit set", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + // Find the explicitlySupported Set + const setMatch = src.match(/const\s+explicitlySupported\s*=\s*new\s+Set\(\[[\s\S]+?\]\)/); + assert.ok(setMatch, "explicitlySupported Set not found"); + assert.match(setMatch[0], /["']gitlab-duo["']/, "gitlab-duo must be in explicitlySupported"); +}); + +// ─── P1: Kimi Coding stable device_id ──────────────────────────────────────── + +test("P1: refreshKimiCodingToken accepts providerSpecificData parameter", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + assert.match( + src, + /export\s+async\s+function\s+refreshKimiCodingToken\([^)]*providerSpecificData/, + "refreshKimiCodingToken must accept providerSpecificData" + ); +}); + +test("P1: refreshKimiCodingToken does NOT use ephemeral Date.now() device ID", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + // Extract function body — match from declaration to next top-level export function + const fnMatch = src.match(/export\s+async\s+function\s+refreshKimiCodingToken\([\s\S]+?\n\}/); + assert.ok(fnMatch, "refreshKimiCodingToken function body not found"); + assert.doesNotMatch( + fnMatch[0], + /["']kimi-refresh-["']\s*\+\s*Date\.now\(\)/, + "must NOT use ephemeral kimi-refresh-+Date.now() device ID" + ); +}); + +test("P1: refreshKimiCodingToken handles invalid_grant as unrecoverable", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + const fnMatch = src.match(/export\s+async\s+function\s+refreshKimiCodingToken\([\s\S]+?\n\}/); + assert.ok(fnMatch, "refreshKimiCodingToken function body not found"); + assert.match(fnMatch[0], /invalid_grant/, "must detect invalid_grant"); + assert.match(fnMatch[0], /unrecoverable_refresh_error/, "must return unrecoverable sentinel"); +}); + +test("P1: _getAccessTokenInternal passes providerSpecificData to refreshKimiCodingToken", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + // The case for kimi-coding should pass credentials.providerSpecificData + assert.match( + src, + /case\s+["']kimi-coding["']:[\s\S]{1,300}providerSpecificData/, + "kimi-coding case must pass providerSpecificData" + ); +}); + +// ─── P1: GitHub Copilot sub-token health check ──────────────────────────────── + +test("P1: GitHub Copilot sub-token is refreshed by tokenHealthCheck", async () => { + const src = await read("src/lib/tokenHealthCheck.ts"); + assert.match(src, /copilot|Copilot/i, "tokenHealthCheck must reference Copilot"); + // Must import refreshCopilotToken + assert.match(src, /refreshCopilotToken/, "must import and call refreshCopilotToken"); +}); + +test("P1: tokenHealthCheck checks copilotTokenExpiresAt before refreshing", async () => { + const src = await read("src/lib/tokenHealthCheck.ts"); + assert.match(src, /copilotTokenExpiresAt/, "must check copilotTokenExpiresAt"); + assert.match(src, /conn\.provider\s*===\s*["']github["']/, "must be gated on github provider"); +}); + +// ─── P2: Google invalid_grant ───────────────────────────────────────────────── + +test("P2: refreshGoogleToken parses invalid_grant as unrecoverable", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + const fnMatch = src.match(/export\s+async\s+function\s+refreshGoogleToken\([\s\S]+?\n\}/); + assert.ok(fnMatch, "refreshGoogleToken function body not found"); + assert.match(fnMatch[0], /invalid_grant/, "must detect invalid_grant"); + assert.match(fnMatch[0], /unrecoverable_refresh_error/, "must return unrecoverable sentinel"); +}); + +// ─── P2: Qwen invalid_grant ─────────────────────────────────────────────────── + +test("P2: refreshQwenToken handles invalid_grant in addition to invalid_request", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + const fnMatch = src.match(/export\s+async\s+function\s+refreshQwenToken\([\s\S]+?\n\}/); + assert.ok(fnMatch, "refreshQwenToken function body not found"); + assert.match(fnMatch[0], /invalid_grant/, "must detect invalid_grant"); + assert.match(fnMatch[0], /unrecoverable_refresh_error/, "must return unrecoverable sentinel"); +}); + +// ─── P2: Kiro AWS InvalidGrantException ────────────────────────────────────── + +test("P2: refreshKiroToken parses AWS InvalidGrantException", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + const fnMatch = src.match(/export\s+async\s+function\s+refreshKiroToken\([\s\S]+?\n\}/); + assert.ok(fnMatch, "refreshKiroToken function body not found"); + assert.match( + fnMatch[0], + /InvalidGrantException|ExpiredTokenException/, + "must detect AWS error types" + ); + assert.match(fnMatch[0], /unrecoverable_refresh_error/, "must return unrecoverable sentinel"); +}); + +test("P2: refreshKiroToken handles AWS errors on both AWS OIDC and social auth paths", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + const fnMatch = src.match(/export\s+async\s+function\s+refreshKiroToken\([\s\S]+?\n\}/); + assert.ok(fnMatch, "refreshKiroToken function body not found"); + // Count occurrences of InvalidGrantException — should appear in both paths + const matchCount = (fnMatch[0].match(/InvalidGrantException/g) || []).length; + assert.ok( + matchCount >= 2, + `InvalidGrantException should be checked in both paths (found ${matchCount} occurrences)` + ); +}); + +// ─── P3: Claude error shape normalization ───────────────────────────────────── + +test("P3: refreshClaudeOAuthToken normalizes invalid_grant to unrecoverable_refresh_error sentinel", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + const fnMatch = src.match(/export\s+async\s+function\s+refreshClaudeOAuthToken\([\s\S]+?\n\}/); + assert.ok(fnMatch, "refreshClaudeOAuthToken function body not found"); + assert.match( + fnMatch[0], + /unrecoverable_refresh_error[\s\S]{1,100}invalid_grant|invalid_grant[\s\S]{1,100}unrecoverable_refresh_error/, + "invalid_grant must map to unrecoverable_refresh_error sentinel" + ); + // Must NOT return the old non-normalized shape { error: errorBody.error, code: "http_..." } + assert.doesNotMatch( + fnMatch[0], + /code:\s*`http_\$\{response\.status\}`/, + "must NOT return http_NNN code format for invalid_grant" + ); +}); + +// ─── P3: Windsurf Firebase errors ──────────────────────────────────────────── + +test("P3: refreshWindsurfToken parses Firebase USER_DISABLED/TOKEN_EXPIRED errors", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + const fnMatch = src.match(/export\s+async\s+function\s+refreshWindsurfToken\([\s\S]+?\n\}/); + assert.ok(fnMatch, "refreshWindsurfToken function body not found"); + assert.match( + fnMatch[0], + /USER_DISABLED|TOKEN_EXPIRED|INVALID_REFRESH_TOKEN/, + "must detect Firebase error codes" + ); + assert.match(fnMatch[0], /unrecoverable_refresh_error/, "must return unrecoverable sentinel"); +}); + +// ─── isUnrecoverableRefreshError consistency ────────────────────────────────── + +test("isUnrecoverableRefreshError detects the normalized sentinel shape", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + const fnMatch = src.match(/export\s+function\s+isUnrecoverableRefreshError\([\s\S]+?\n\}/); + assert.ok(fnMatch, "isUnrecoverableRefreshError function body not found"); + assert.match( + fnMatch[0], + /unrecoverable_refresh_error/, + "must detect unrecoverable_refresh_error" + ); +}); diff --git a/tests/unit/token-refresh-race-comprehensive.test.ts b/tests/unit/token-refresh-race-comprehensive.test.ts new file mode 100644 index 000000000000..ec85c9138369 --- /dev/null +++ b/tests/unit/token-refresh-race-comprehensive.test.ts @@ -0,0 +1,171 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "fs/promises"; +import path from "path"; + +const root = path.resolve(import.meta.dirname, "../.."); + +const read = (rel: string) => readFile(path.join(root, rel), "utf8"); + +test("Fix A: getAccessToken accepts an onPersist parameter", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + // Accepts either the legacy `(result: any) => Promise` or the + // refactored named-type form (RefreshPersistFn) — the latter is preferred + // because it satisfies the t11 any-budget. + assert.match( + src, + /export async function getAccessToken\([\s\S]{0,500}onPersist\?:\s*(?:RefreshPersistFn|\(result:\s*\w+\)\s*=>\s*Promise)/, + "getAccessToken must declare onPersist as the 5th parameter" + ); +}); + +test("Fix A: getAccessToken invokes onPersist INSIDE the per-connection mutex closure", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + const closureMatch = src.match(/entry\.promise\s*=\s*\(async\s*\(\)\s*=>\s*\{([\s\S]+?)\}\)\(\)/); + assert.ok(closureMatch, "Per-connection mutex closure must use the (async () => {...})() form"); + const closureBody = closureMatch![1]; + assert.match( + closureBody, + /effectiveOnPersist/, + "The mutex closure body must reference effectiveOnPersist so the persist runs before the mutex releases" + ); +}); + +test("Fix A: runWithOnPersist + getActiveOnPersist are exported for executor plumbing", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + assert.match(src, /export function runWithOnPersist\b/); + assert.match(src, /export function getActiveOnPersist\b/); + assert.match(src, /AsyncLocalStorage/); +}); + +test("Fix A: chatCore.ts wraps executor.refreshCredentials with runWithOnPersist", async () => { + const src = await read("open-sse/handlers/chatCore.ts"); + assert.match( + src, + /runWithOnPersist\([\s\S]{0,200}executor\.refreshCredentials/, + "chatCore.ts reactive 401 path must wrap refreshCredentials with runWithOnPersist" + ); +}); + +test("Fix A: base.ts proactive refresh also wraps refreshCredentials with runWithOnPersist", async () => { + const src = await read("open-sse/executors/base.ts"); + assert.match( + src, + /runWithOnPersist\([\s\S]{0,200}this\.refreshCredentials/, + "base.ts proactive needsRefresh branch must wrap refreshCredentials with runWithOnPersist" + ); +}); + +test("Fix B: refreshOAuthToken in test/route.ts includes connectionId in credentials", async () => { + const src = await read("src/app/api/providers/[id]/test/route.ts"); + const fnIdx = src.indexOf("async function refreshOAuthToken("); + assert.ok(fnIdx >= 0, "refreshOAuthToken function must exist"); + const fnSlice = src.slice(fnIdx, fnIdx + 2500); + assert.match( + fnSlice, + /connectionId:\s*connection\.id/, + "refreshOAuthToken credentials must include connectionId so Layer 1 (per-connection mutex) is used instead of Layer 2 (token-hash dedup)" + ); + assert.match( + fnSlice, + /onPersist|async\s*\(refreshed\)\s*=>/, + "refreshOAuthToken must pass an onPersist callback so the DB write is atomic with the network refresh" + ); +}); + +test("Fix C reverted: codexAuthImport does NOT refresh tokens on import (avoids family revocation on stale auth.json)", async () => { + const src = await read("src/lib/oauth/utils/codexAuthImport.ts"); + assert.doesNotMatch( + src, + /refreshConnectionTokensOnImport\(/, + "Fix C was reverted because auth.json files exported from Codex CLI are often partially rotated; refreshing with a stale refresh_token caused upstream to invalidate the entire token family" + ); + assert.doesNotMatch( + src, + /import\s*\{[^}]*getAccessToken[^}]*\}\s*from\s*"@omniroute\/open-sse\/services\/tokenRefresh/, + "codexAuthImport should not import getAccessToken (refresh-on-import was reverted)" + ); +}); + +test("Fix D: staleness fallback returns absolute expiresAt, not raw expiresIn", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + // Locate the actual return statement, not the JSDoc mention. + // The anchor is the log line "DB token is still valid. Skipping OAuth refresh." + // which only appears in the code path (the JSDoc says "DB token is still valid it is"). + const idx = src.indexOf("DB token is still valid. Skipping"); + assert.ok(idx >= 0, "Staleness-valid log line not found"); + const slice = src.slice(idx, idx + 800); + assert.match( + slice, + /expiresAt:\s*dbConnection\.expiresAt/, + "Staleness fallback must return absolute expiresAt" + ); + assert.doesNotMatch( + slice, + /expiresIn:\s*dbConnection\.expiresIn(?!\s*\/\/)/, + "Staleness fallback must NOT return raw expiresIn (causes downstream lifetime extension)" + ); +}); + +test("Fix D: src/sse wrapper prefers expiresAt over expiresIn when recomputing", async () => { + const src = await read("src/sse/services/tokenRefresh.ts"); + // The recompute block in checkAndRefreshToken + assert.match( + src, + /expiresAt:\s*newCredentials\.expiresAt\s*\?\s*newCredentials\.expiresAt/, + "checkAndRefreshToken must prefer the absolute expiresAt before falling back to expiresIn arithmetic" + ); +}); + +test("Fix E: chatCore.ts moves credentials mutation INSIDE the mutex closure via persistFn", async () => { + const src = await read("open-sse/handlers/chatCore.ts"); + // The persistFn closure must do BOTH the Object.assign AND the user callback + const persistFnMatch = src.match( + /const\s+persistFn\s*=[\s\S]{0,800}Object\.assign\(credentials,[\s\S]{0,300}onCredentialsRefreshed/ + ); + assert.ok( + persistFnMatch, + "chatCore.ts must build persistFn that both Object.assigns credentials and calls onCredentialsRefreshed, so both happen INSIDE the mutex" + ); +}); + +test("Fix F removed: no over-eager skip that would bypass legitimate refreshes", async () => { + const src = await read("open-sse/services/tokenRefresh.ts"); + // Fix F was intentionally removed because it skipped refreshes that the + // caller (checkAndRefreshToken) had already decided were needed. Verify the + // dead-code branch is gone (no `credentials.accessToken === dbConnection.accessToken` + // skip path remains). + assert.doesNotMatch( + src, + /credentials\.accessToken\s*===\s*dbConnection\.accessToken[\s\S]{0,300}Skipping OAuth refresh/, + "Fix F was removed because it caused legitimate refreshes to be skipped" + ); +}); + +test("Mutex consolidation: src/sse wrapper no longer holds its own connectionRefreshMutex map", async () => { + const src = await read("src/sse/services/tokenRefresh.ts"); + // It's OK to keep a withConnectionRefreshMutex shim for back-compat, but + // the actual Map should not be re-instantiated here (use the open-sse one). + const hasOwnMap = /const\s+connectionRefreshMutex\s*=\s*new\s+Map/.test(src); + if (hasOwnMap) { + // If a Map remains, the file must clearly document that it is a deprecated + // shim — otherwise it's a regression that recreates the dual-mutex bug. + assert.match( + src, + /deprecated|legacy|shim|redundant|kept for back-compat/i, + "If src/sse keeps its own connectionRefreshMutex Map, the file must document it as deprecated/legacy" + ); + } +}); + +test("Imports: chatCore.ts imports runWithOnPersist from open-sse tokenRefresh", async () => { + const src = await read("open-sse/handlers/chatCore.ts"); + assert.match(src, /runWithOnPersist/); + assert.match(src, /from\s+"\.\.\/services\/tokenRefresh\.ts"/); +}); + +test("Imports: base.ts imports runWithOnPersist from open-sse tokenRefresh", async () => { + const src = await read("open-sse/executors/base.ts"); + assert.match(src, /runWithOnPersist/); + assert.match(src, /from\s+"\.\.\/services\/tokenRefresh\.ts"/); +}); diff --git a/tests/unit/token-refresh-race.test.ts b/tests/unit/token-refresh-race.test.ts deleted file mode 100644 index b6dd1733d3e1..000000000000 --- a/tests/unit/token-refresh-race.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; - -/** - * Structural test: locks the invariant that updateProviderCredentials is - * called INSIDE the withConnectionRefreshMutex closure in checkAndRefreshToken. - * - * Background: with rotating refresh tokens (OpenAI/Codex), if the DB write - * happens AFTER the mutex releases, a concurrent request can read stale - * credentials and send the old refresh token. OpenAI detects this as - * refresh_token_reused and permanently revokes the entire token family. - * - * This test parses the source text of tokenRefresh.ts and verifies that - * the mutex closure body contains the DB write call. It is intentionally - * a source-level check so it survives refactors that would re-introduce - * the race (e.g., moving the write back outside). - */ -test("tokenRefresh.ts wraps updateProviderCredentials inside withConnectionRefreshMutex", async () => { - const srcPath = path.resolve("src/sse/services/tokenRefresh.ts"); - const source = await readFile(srcPath, "utf8"); - - // Locate the withConnectionRefreshMutex invocation in checkAndRefreshToken. - // Match: withConnectionRefreshMutex(connectionId, async () => { ... }) - // The regex captures everything between the opening { and the matching } - // of the async arrow function passed as the second argument. - // - // We look for the async closure form specifically — if someone reverts to - // the non-async one-liner, this assertion will fire and force a review. - const mutexCallIndex = source.indexOf("withConnectionRefreshMutex(connectionId, async () => {"); - assert.ok( - mutexCallIndex !== -1, - "withConnectionRefreshMutex must be called with an async arrow function closure (not a one-liner). " + - "This ensures the DB write can be awaited inside before the mutex releases." - ); - - // Extract everything from the opening of the async closure to the - // corresponding closing brace + ) that ends the mutex call. - const closureStart = source.indexOf("{", mutexCallIndex); - assert.ok(closureStart !== -1, "Could not locate opening brace of mutex closure"); - - // Walk forward to find the matching closing brace. - let depth = 0; - let closureEnd = -1; - for (let i = closureStart; i < source.length; i++) { - if (source[i] === "{") depth++; - else if (source[i] === "}") { - depth--; - if (depth === 0) { - closureEnd = i; - break; - } - } - } - assert.ok(closureEnd !== -1, "Could not locate closing brace of mutex closure"); - - const closureBody = source.slice(closureStart, closureEnd + 1); - - assert.match( - closureBody, - /updateProviderCredentials/, - "updateProviderCredentials DB write MUST be inside the withConnectionRefreshMutex " + - "closure body. Moving it outside creates a race window where a concurrent request " + - "reads stale credentials and re-uses a rotated refresh token, triggering " + - "refresh_token_reused (permanent token family revocation on OpenAI/Codex)." - ); -}); - -/** - * Complementary structural check: the unconditional updateProviderCredentials - * call outside the mutex must be guarded by !connectionId (no-connectionId - * fallback path only). This prevents a double-write that would bypass the - * serialization guarantee. - */ -test("tokenRefresh.ts outer updateProviderCredentials call is guarded by !connectionId", async () => { - const srcPath = path.resolve("src/sse/services/tokenRefresh.ts"); - const source = await readFile(srcPath, "utf8"); - - // Find the checkAndRefreshToken function body. - const fnStart = source.indexOf("export async function checkAndRefreshToken("); - assert.ok(fnStart !== -1, "checkAndRefreshToken function not found in source"); - - // Walk to find the full function body. - const bodyStart = source.indexOf("{", fnStart); - let depth = 0; - let bodyEnd = -1; - for (let i = bodyStart; i < source.length; i++) { - if (source[i] === "{") depth++; - else if (source[i] === "}") { - depth--; - if (depth === 0) { - bodyEnd = i; - break; - } - } - } - assert.ok(bodyEnd !== -1, "Could not locate closing brace of checkAndRefreshToken"); - - const fnBody = source.slice(fnStart, bodyEnd + 1); - - // Count occurrences of updateProviderCredentials in the function body. - const allOccurrences = [...fnBody.matchAll(/updateProviderCredentials/g)]; - - // There must be at least 2: one inside the mutex closure, one in the - // copilot block (lines ~212-218), and optionally one for the !connectionId guard. - // The critical invariant: no bare updateProviderCredentials call appears - // OUTSIDE the mutex without a !connectionId guard. - - // Locate the outer (non-mutex, non-copilot) call and verify it is inside - // an `if (!connectionId)` block. - const mutexCallEnd = fnBody.indexOf("})") + 2; // end of mutex call block - const afterMutex = fnBody.slice(mutexCallEnd); - - // The first updateProviderCredentials after the mutex call (but before - // the copilot section) should be guarded by !connectionId. - const outerCallMatch = afterMutex.match( - /if\s*\(\s*!connectionId\s*\)\s*\{[\s\S]*?updateProviderCredentials/ - ); - assert.ok( - outerCallMatch !== null, - "The updateProviderCredentials call outside the mutex closure MUST be inside " + - "an `if (!connectionId)` guard. Without this guard, the DB write executes " + - "twice when connectionId is set: once inside the mutex (correct) and once " + - "after (race condition re-introduced)." - ); - - // Verify the total occurrence count is consistent (not 0 outside mutex). - assert.ok( - allOccurrences.length >= 2, - `Expected at least 2 calls to updateProviderCredentials in checkAndRefreshToken ` + - `(mutex closure + copilot block), got ${allOccurrences.length}` - ); -}); diff --git a/tests/unit/token-refresh-service.test.ts b/tests/unit/token-refresh-service.test.ts index 143ef92a8ab4..adea08462d4e 100644 --- a/tests/unit/token-refresh-service.test.ts +++ b/tests/unit/token-refresh-service.test.ts @@ -294,7 +294,12 @@ test("refreshKimiCodingToken adds provider-specific headers and fields", async ( }); }, async () => { - const result = await refreshKimiCodingToken("kimi-refresh", log); + // Pass providerSpecificData with a stable deviceId (second positional arg after signature change) + const result = await refreshKimiCodingToken( + "kimi-refresh", + { deviceId: "test-stable-device" }, + log + ); assert.deepEqual(result, { accessToken: "kimi-access", refreshToken: "kimi-refresh-next", @@ -306,9 +311,18 @@ test("refreshKimiCodingToken adds provider-specific headers and fields", async ( ); assert.equal(calls[0].url, PROVIDERS["kimi-coding"].refreshUrl); - assert.equal(calls[0].options.headers["X-Msh-Platform"], "omniroute"); - assert.equal(calls[0].options.headers["X-Msh-Version"], "2.1.2"); - assert.match(calls[0].options.headers["X-Msh-Device-Id"], /^kimi-refresh-/); + // Platform is now "kimi_cli" (matching the real Kimi CLI fingerprint) + assert.equal(calls[0].options.headers["X-Msh-Platform"], "kimi_cli"); + // Version comes from KIMI_CLI_VERSION env or default "1.36.0" + assert.ok(calls[0].options.headers["X-Msh-Version"], "X-Msh-Version must be set"); + // Device-Id must NOT be an ephemeral "kimi-refresh-" value + assert.ok( + calls[0].options.headers["X-Msh-Device-Id"] && + !calls[0].options.headers["X-Msh-Device-Id"].startsWith("kimi-refresh-"), + "X-Msh-Device-Id must be stable (not ephemeral kimi-refresh-)" + ); + // When providerSpecificData.deviceId is provided, it should be used directly + assert.equal(calls[0].options.headers["X-Msh-Device-Id"], "test-stable-device"); assert.match(bodyToString(calls[0].options.body), /grant_type=refresh_token/); }); @@ -403,7 +417,8 @@ test("refreshQwenToken surfaces invalid_request as unrecoverable", async () => { async () => textResponse(JSON.stringify({ error: "invalid_request" }), 400), async () => { const result = await refreshQwenToken("qwen-refresh", log); - assert.deepEqual(result, { error: "invalid_request" }); + // Normalized to unrecoverable_refresh_error sentinel (Fix 4) + assert.deepEqual(result, { error: "unrecoverable_refresh_error", code: "invalid_request" }); } ); }); @@ -1189,6 +1204,12 @@ test("getAccessToken per-connection mutex: mutex cleared after success, next cal const log = createLog(); let upstreamCallCount = 0; + // The rotation map (added for the codex-multi-auth pattern) is process-wide + // and intentionally redirects a stale-token caller to the cached rotated + // tokens. Clear it BEFORE and BETWEEN calls so this test exercises the + // lower-level mutex semantics it was designed for. + tokenRefresh._clearTokenRotationMap(); + await withPatchedProperties( PROVIDERS, { "custom-oauth-conn-mutex": { tokenUrl: "https://auth.example.com/token" } }, @@ -1206,6 +1227,7 @@ test("getAccessToken per-connection mutex: mutex cleared after success, next cal const credentials = { connectionId: "conn-refire", refreshToken: "rt" }; const first = await getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log); + tokenRefresh._clearTokenRotationMap(); const second = await getAccessToken("custom-oauth-conn-mutex", { ...credentials }, log); assert.equal(upstreamCallCount, 2, "each sequential call fires upstream once"); @@ -1274,7 +1296,9 @@ test("refreshClaudeOAuthToken returns error object for invalid_grant (expired re async () => { const result = await refreshClaudeOAuthToken("expired-token", log); assert.ok(result && typeof result === "object", "should return error object, not null"); - assert.equal((result as any).error, "invalid_grant"); + // Normalized to unrecoverable_refresh_error sentinel (Fix 6) + assert.equal((result as any).error, "unrecoverable_refresh_error"); + assert.equal((result as any).code, "invalid_grant"); assert.ok(isUnrecoverableRefreshError(result), "should be detected as unrecoverable"); } ); From 9543762a162360a28abd0028f4c34d4718c84e46 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 25 May 2026 23:39:47 -0300 Subject: [PATCH 13/77] docs(changelog): add [3.8.4] section, bump openapi to 3.8.4, document incoming fixes --- CHANGELOG.md | 18 ++++++++++++++++++ docs/i18n/ar/CHANGELOG.md | 8 ++++++++ docs/i18n/az/CHANGELOG.md | 8 ++++++++ docs/i18n/bg/CHANGELOG.md | 8 ++++++++ docs/i18n/bn/CHANGELOG.md | 8 ++++++++ docs/i18n/cs/CHANGELOG.md | 8 ++++++++ docs/i18n/da/CHANGELOG.md | 8 ++++++++ docs/i18n/de/CHANGELOG.md | 8 ++++++++ docs/i18n/es/CHANGELOG.md | 8 ++++++++ docs/i18n/fa/CHANGELOG.md | 8 ++++++++ docs/i18n/fi/CHANGELOG.md | 8 ++++++++ docs/i18n/fr/CHANGELOG.md | 8 ++++++++ docs/i18n/gu/CHANGELOG.md | 8 ++++++++ docs/i18n/he/CHANGELOG.md | 8 ++++++++ docs/i18n/hi/CHANGELOG.md | 8 ++++++++ docs/i18n/hu/CHANGELOG.md | 8 ++++++++ docs/i18n/id/CHANGELOG.md | 8 ++++++++ docs/i18n/in/CHANGELOG.md | 8 ++++++++ docs/i18n/it/CHANGELOG.md | 8 ++++++++ docs/i18n/ja/CHANGELOG.md | 8 ++++++++ docs/i18n/ko/CHANGELOG.md | 8 ++++++++ docs/i18n/mr/CHANGELOG.md | 8 ++++++++ docs/i18n/ms/CHANGELOG.md | 8 ++++++++ docs/i18n/nl/CHANGELOG.md | 8 ++++++++ docs/i18n/no/CHANGELOG.md | 8 ++++++++ docs/i18n/phi/CHANGELOG.md | 8 ++++++++ docs/i18n/pl/CHANGELOG.md | 8 ++++++++ docs/i18n/pt-BR/CHANGELOG.md | 8 ++++++++ docs/i18n/pt/CHANGELOG.md | 8 ++++++++ docs/i18n/ro/CHANGELOG.md | 8 ++++++++ docs/i18n/ru/CHANGELOG.md | 8 ++++++++ docs/i18n/sk/CHANGELOG.md | 8 ++++++++ docs/i18n/sv/CHANGELOG.md | 8 ++++++++ docs/i18n/sw/CHANGELOG.md | 8 ++++++++ docs/i18n/ta/CHANGELOG.md | 8 ++++++++ docs/i18n/te/CHANGELOG.md | 8 ++++++++ docs/i18n/th/CHANGELOG.md | 8 ++++++++ docs/i18n/tr/CHANGELOG.md | 8 ++++++++ docs/i18n/uk-UA/CHANGELOG.md | 8 ++++++++ docs/i18n/ur/CHANGELOG.md | 8 ++++++++ docs/i18n/vi/CHANGELOG.md | 8 ++++++++ docs/i18n/zh-CN/CHANGELOG.md | 8 ++++++++ docs/reference/openapi.yaml | 2 +- 43 files changed, 347 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4857b398172f..2cc544ee02ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,28 @@ ### ✨ New Features +### 🔧 Bug Fixes + +--- + +## [3.8.4] + +### ✨ New Features + - **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) ### 🔧 Bug Fixes +- **fix(db):** remove transactions from migrations to avoid nested-transaction errors when the runner already wraps the file in a transaction. +- **fix(docker):** use `node:24-trixie-slim` base image (and add `python3`/`make`/`g++` to the builder stage) so native addons like `better-sqlite3` compile from source within the `engines` range. ([#2713](https://github.com/diegosouzapw/OmniRoute/pull/2713) — thanks @mrmm) +- **fix(ci):** semver-aware release publish guards in the npm and Docker workflows. +- **fix(i18n):** restore real English copy for web-cookie provider hints in `en.json` (Blackbox, Grok, Muse Spark, Perplexity, Qoder, Vertex, SearXNG) — replaces placeholder strings that were leaking the translation key into the UI. ([#2694](https://github.com/diegosouzapw/OmniRoute/pull/2694) — thanks @diegosouzapw) +- **fix(oauth):** comprehensive Codex/OpenAI rotating-refresh race fix — moves credential persistence INSIDE the per-connection mutex via `runWithOnPersist`/`AsyncLocalStorage`, consolidates duplicate mutex maps in `src/sse` and `open-sse`, adds absolute `expiresAt` staleness fallback, and prevents `refresh_token_reused` family revocation. Replaces `token-refresh-race.test.ts` with a broader `token-refresh-race-comprehensive.test.ts` covering Fix A–F. ([#2718](https://github.com/diegosouzapw/OmniRoute/pull/2718) — thanks @diegosouzapw) +- **fix(vision-bridge):** process images when a vision-capable model is routed through a combo whose targets may NOT support vision — resolves combo mapping for the requested model and only short-circuits the bridge when ALL combo targets natively support vision. ([#2706](https://github.com/diegosouzapw/OmniRoute/pull/2706) — thanks @herjarsa) +- **fix(antigravity):** default exhausted quota to 0% remaining (was incorrectly defaulting to 100% when the API omitted `remainingFraction`). Clamps to `[0, 1]` and distinguishes unlimited (no `resetTime`) from full-with-reset. ([#2700](https://github.com/diegosouzapw/OmniRoute/pull/2700) — thanks @ahmet-cetinkaya) +- **fix(electron):** Caps Lock indicator (`` + onboarding) with ARIA live region, Electron-aware forgot-password page showing the data directory, macOS Helper-binary spawn for the Next.js server (prevents a second dock icon and terminal flash), plus `windowsHide: true` and `shell: false` hardening on `spawn()`. ([#2714](https://github.com/diegosouzapw/OmniRoute/pull/2714) — thanks @benzntech) +- **fix(proxy):** atomically create/update a custom proxy and its assignment inside a single SQLite transaction — eliminates the 3-call race that could leave orphan registry rows or stale legacy `proxyConfig` entries when the UI saved a dashboard custom proxy. New `createProxyAndAssign` / `updateProxyAndAssign` helpers. ([#2697](https://github.com/diegosouzapw/OmniRoute/pull/2697) — thanks @terence71-glitch) + --- ## [3.8.3] — 2026-05-24 diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index b294dfa2f7e7..3857b3064a74 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index a111434bf002..1cfb50551938 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index a111434bf002..1cfb50551938 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 4bbfd1289858..0d135eedb16d 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index 957e777b6a66..c404fafffe13 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index d1d36d46d551..b335b25bd5e0 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 37a27028fe2f..685cb2567d0a 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 56e825d9e447..61258fd22a1d 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index c1770dde2c31..c72c23d1626e 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index a37390b008fa..da038e62e8c0 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 99a94615a5a9..3444f73a51b1 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 9156ac9b1a0d..934b46419f67 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index c4046b74eda5..37bbf4119fe4 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 4cf4b0da1e45..7be893db5dc2 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index b0b0fb1e318e..4f791c1d2048 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 425158565fe8..93a5f92f5c7b 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 476f7a228faa..0b8576e899c1 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index 7ea5ab1f1501..875603cb90a1 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 86bf9a3812ec..e4adf9265215 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index e057db24751c..6a7e4af64e84 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 15a7512028cf..200636334fb5 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 8733921d7035..f854326ecc8d 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index edd25f7e6320..fe55a2f1561a 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index e64d07bc0f20..dac0d0c8fe83 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index a7b1fa0c3d06..d056d0bd90e3 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index e52edb01fdc7..e9b6691063ec 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 0dcc8d046301..6561012350a0 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 8dee06f28392..5173385f0557 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index a102cad6817d..ddcbdb31fa03 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index a6dcecc366b7..0066f04703fb 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 168eb1315855..09178904744b 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 53d0a134df87..b53dd53dd346 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 18b9347f7d7a..bab15f107b1d 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 0b91c56428f5..48e287f86f39 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index d1943f3cd70c..304de6a70a09 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index b11f5f15aaf5..b064d9612c67 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index d348d9c2f59b..615046c9e3b7 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 9db359c27186..93dea837eef9 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 9da752370beb..465f44cfaf95 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 3071874f57eb..31d58dff8840 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 01eaa6baee8a..9e18951b806c 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -12,6 +12,14 @@ --- +## [3.8.4] + +### ✨ New Features + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index fe2c5dc66a89..69a4683a8542 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 3.8.3 + version: 3.8.4 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, From baab21626fe982fbf9c400de3fc11b869bbc961d Mon Sep 17 00:00:00 2001 From: Hernan Javier Ardila Sanchez Date: Mon, 25 May 2026 23:44:12 -0300 Subject: [PATCH 14/77] fix(vision-bridge): process images when vision-capable model has combo mapping (#2706) Thanks @herjarsa. --- src/lib/guardrails/visionBridge.ts | 72 +++++++++++++++++++++- tests/unit/guardrails/visionBridge.test.ts | 70 +++++++++++++++++++++ 2 files changed, 141 insertions(+), 1 deletion(-) diff --git a/src/lib/guardrails/visionBridge.ts b/src/lib/guardrails/visionBridge.ts index 460d342471ef..b1fea0c11165 100644 --- a/src/lib/guardrails/visionBridge.ts +++ b/src/lib/guardrails/visionBridge.ts @@ -18,6 +18,64 @@ import { isVisionBridgeForcedModel, } from "@/shared/constants/visionBridgeDefaults"; +/// Check if a model with a combo mapping should trigger vision bridge processing. +/// Resolves the combo targets and only returns true if at least one target model +/// does NOT support vision natively. This avoids unnecessary vision model calls +/// when all combo targets can handle images directly. +async function shouldProcessImagesForComboModel(model: string): Promise { + try { + const { getComboByName } = await import("@/lib/localDb"); + const { resolveComboForModel } = await import("@/lib/db/modelComboMappings"); + + // 1. Try to find combo by exact name match + let combo = await getComboByName(model); + + // 2. If no exact match, try model-combo mapping + if (!combo) { + const mapping = await resolveComboForModel(model); + if (!mapping) return false; + const comboName = mapping.comboName ?? mapping.name ?? null; + if (!comboName) return false; + combo = await getComboByName(comboName); + } + + if (!combo) return false; + + // 3. Get the combo's models (target steps) + const rawModels = (combo as Record).models; + if (!Array.isArray(rawModels)) return false; + + // 4. Check each target for vision support + // combo-ref → conservative (process images) + // model step with no native vision → process images + // all model steps with native vision → safe to skip + let hasModelStep = false; + for (const step of rawModels) { + const s = step as Record; + if (s.kind === "combo-ref") return true; + if (s.kind === "model") { + hasModelStep = true; + const targetModel = s.model; + if (typeof targetModel === "string") { + const caps = getResolvedModelCapabilities(targetModel); + if (caps.supportsVision !== true) { + return true; + } + } + } + } + + // All model steps support vision — safe to skip + if (hasModelStep) return false; + + // No recognizable steps — don't force bridge + return false; + } catch { + // On error, try to process images (conservative) + return true; + } +} + export interface VisionBridgeDependencies { getSettings?: () => Promise>; callVisionModel?: ( @@ -25,6 +83,8 @@ export interface VisionBridgeDependencies { config: import("./visionBridgeHelpers").VisionModelConfig, apiKey?: string ) => Promise; + /** Override combo-target vision check — return true to force processing, false to skip. */ + checkModelHasComboMapping?: (model: string) => Promise; } export class VisionBridgeGuardrail extends BaseGuardrail { @@ -61,7 +121,17 @@ export class VisionBridgeGuardrail extends BaseGuardrail { // 4. Check if model supports vision const capabilities = getResolvedModelCapabilities(model); if (capabilities.supportsVision === true && !forceVisionBridge) { - return { block: false }; + // The request model supports vision natively, but check if a + // model-combo mapping routes this model through a combo where + // some targets may NOT support vision. In that case, the vision + // bridge must process images so combo targets can describe them. + const hasMapping = this.deps.checkModelHasComboMapping + ? await this.deps.checkModelHasComboMapping(model) + : await shouldProcessImagesForComboModel(model); + if (!hasMapping) { + return { block: false }; + } + // Combo mapping found — fall through to process images } // 5. Get body and check for messages diff --git a/tests/unit/guardrails/visionBridge.test.ts b/tests/unit/guardrails/visionBridge.test.ts index 0925bbe8bc20..68a2eb0e9cc5 100644 --- a/tests/unit/guardrails/visionBridge.test.ts +++ b/tests/unit/guardrails/visionBridge.test.ts @@ -38,6 +38,7 @@ function createGuardrail(options?: Parameters[0]) } return mockVisionResponse; }, + ...(options?.deps ?? {}), }, }); } @@ -501,3 +502,72 @@ test("VB-S10: returns meta with imagesProcessed count", async () => { assert.strictEqual(typeof meta.processingTimeMs, "number"); assert.strictEqual(meta.visionModel, "openai/gpt-4o-mini"); }); + +// ── VB-S11: Combo mapping forces vision processing despite vision-capable model ── + +test("VB-S11: processes images when vision-capable model has combo mapping", async () => { + mockVisionResponse = "A description from combo-mapped vision bridge"; + const guardrail = createGuardrail({ + deps: { + checkModelHasComboMapping: async (_model: string) => true, + }, + }); + + const payload = createPayload({ + model: "openai/gpt-4o", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { + type: "image_url", + image_url: { url: "https://example.com/image.png" }, + }, + ], + }, + ], + }); + + const startCallCount = visionCallCount; + const result = await guardrail.preCall(payload, createContext({ model: "openai/gpt-4o" })); + + // Vision bridge should have processed the image + assert.strictEqual(result.block, false); + assert.ok(visionCallCount > startCallCount, "Expected vision model to be called"); + assert.ok( + result.modifiedPayload !== undefined, + "Expected modifiedPayload when combo mapping forces vision bridge" + ); +}); + +test("VB-S11b: passthroughs when vision-capable model has NO combo mapping", async () => { + const guardrail = createGuardrail({ + deps: { + checkModelHasComboMapping: async (_model: string) => false, + }, + }); + + const payload = createPayload({ + model: "openai/gpt-4o", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "What is this?" }, + { + type: "image_url", + image_url: { url: "https://example.com/image.png" }, + }, + ], + }, + ], + }); + + const result = await guardrail.preCall(payload, createContext({ model: "openai/gpt-4o" })); + + // Vision bridge should skip (passthrough) since model supports vision and no combo mapping + assert.strictEqual(result.block, false); + assert.strictEqual(result.modifiedPayload, undefined); + assert.strictEqual(visionCallCount, 0); +}); From fb3f64e2acaa82a8e342bf1bbe07ec92082e6fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C3=87etinkaya?= Date: Mon, 25 May 2026 23:45:31 -0300 Subject: [PATCH 15/77] fix(antigravity): default exhausted quota to 0% instead of 100% (#2700) Thanks @ahmet-cetinkaya. --- open-sse/services/usage.ts | 21 +- src/lib/usage/fetcher.ts | 10 +- tests/unit/antigravity-usage-fetcher.test.ts | 285 +++++++++++++++++++ tests/unit/antigravity-usage-service.test.ts | 261 +++++++++++++++++ 4 files changed, 564 insertions(+), 13 deletions(-) create mode 100644 tests/unit/antigravity-usage-fetcher.test.ts create mode 100644 tests/unit/antigravity-usage-service.test.ts diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index f18ba970bc41..e96eeaa1236d 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -3,14 +3,6 @@ */ import { PROVIDERS } from "../config/constants.ts"; - -// Quota / usage upstream URLs (overridable for testing or relays). -const CROF_USAGE_URL = process.env.OMNIROUTE_CROF_USAGE_URL ?? "https://crof.ai/usage_api/"; -const GEMINI_CLI_USAGE_URL = - process.env.OMNIROUTE_GEMINI_CLI_USAGE_URL ?? - "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; -const CODEWHISPERER_BASE_URL = - process.env.OMNIROUTE_CODEWHISPERER_BASE_URL ?? "https://codewhisperer.us-east-1.amazonaws.com"; import { getAntigravityFetchAvailableModelsUrls, ANTIGRAVITY_BASE_URLS, @@ -43,6 +35,14 @@ import { extractCodeAssistSubscriptionTier, } from "./codeAssistSubscription.ts"; +// Quota / usage upstream URLs (overridable for testing or relays). +const CROF_USAGE_URL = process.env.OMNIROUTE_CROF_USAGE_URL ?? "https://crof.ai/usage_api/"; +const GEMINI_CLI_USAGE_URL = + process.env.OMNIROUTE_GEMINI_CLI_USAGE_URL ?? + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist"; +const CODEWHISPERER_BASE_URL = + process.env.OMNIROUTE_CODEWHISPERER_BASE_URL ?? "https://codewhisperer.us-east-1.amazonaws.com"; + // Antigravity API config (credentials from PROVIDERS via credential loader) const ANTIGRAVITY_CONFIG = { quotaApiUrls: getAntigravityFetchAvailableModelsUrls(), @@ -1940,8 +1940,9 @@ async function getAntigravityUsage( const rawFraction = toNumber(quotaInfo.remainingFraction, -1); const resetAt = parseResetTime(quotaInfo.resetTime); - // Default to 100% when the API doesn't report a fraction - const remainingFraction = rawFraction < 0 ? 1 : rawFraction; + // When remainingFraction is undefined/NaN (exhausted quota), default to 0%. Clamp to valid range [0, 1] + // Unlimited models have remainingFraction=1 AND no resetTime + const remainingFraction = rawFraction < 0 ? 0 : Math.max(0, Math.min(1, rawFraction)); // Models with no resetTime and full remaining are unlimited (e.g. tab-completion models) const isUnlimited = !resetAt && remainingFraction >= 1; const remainingPercentage = remainingFraction * 100; diff --git a/src/lib/usage/fetcher.ts b/src/lib/usage/fetcher.ts index 58f4d3d0867b..092de749d77b 100644 --- a/src/lib/usage/fetcher.ts +++ b/src/lib/usage/fetcher.ts @@ -348,9 +348,13 @@ async function getAntigravityUsage( if (info.isInternal) continue; const quotaInfo = (info.quotaInfo as Record) ?? {}; - if ("remainingFraction" in quotaInfo) { + // Process models with any quota metadata (exhausted models may have only resetTime, active models have remainingFraction, credit-based models have empty quotaInfo) + if (Object.keys(quotaInfo).length > 0) { + // Default to 0 when remainingFraction is undefined/null/invalid, clamp to valid range [0, 1] const fraction = - typeof quotaInfo.remainingFraction === "number" ? quotaInfo.remainingFraction : 1; + typeof quotaInfo.remainingFraction === "number" + ? Math.max(0, Math.min(1, quotaInfo.remainingFraction)) + : 0; const resetTime = typeof quotaInfo.resetTime === "string" ? quotaInfo.resetTime : null; modelQuotas[modelId] = { remaining: Math.round(fraction * 100), @@ -360,7 +364,7 @@ async function getAntigravityUsage( quotaModelsTotal++; if (fraction > 0) quotaModelsAvailable++; } - // Credit-based models have no remainingFraction — their availability is + // Credit-based models have empty quotaInfo — their availability is // tracked via the GOOGLE_ONE_AI credit balance cached from SSE responses. } diff --git a/tests/unit/antigravity-usage-fetcher.test.ts b/tests/unit/antigravity-usage-fetcher.test.ts new file mode 100644 index 000000000000..b53e482d5336 --- /dev/null +++ b/tests/unit/antigravity-usage-fetcher.test.ts @@ -0,0 +1,285 @@ +/** + * Tests for src/lib/usage/fetcher.ts — Antigravity quota parsing. + * + * Verifies that remainingFraction is correctly parsed: + * - undefined → 0% remaining (exhausted quota) + * - 0 → 0% remaining (exhausted quota, explicit) + * - 1.0 → 100% remaining (full quota) + * - 0.5 → 50% remaining (partial quota) + */ + +import { describe, it, mock } from "node:test"; +import assert from "node:assert/strict"; + +describe("getUsageForProvider (antigravity in fetcher.ts)", () => { + const connectionBase = { + provider: "antigravity", + accessToken: "fake-token", + providerSpecificData: {}, + projectId: undefined, + id: "test-conn", + }; + + it("defaults to 0% remaining when remainingFraction is undefined", async () => { + const fetcherModule = await import("../../src/lib/usage/fetcher.ts"); + const { getUsageForProvider } = fetcherModule; + + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-2.5-pro": { + quotaInfo: { + remainingFraction: undefined, + resetTime: "2026-05-26T00:00:00Z", + }, + }, + }, + }), + })); + + try { + const result = await getUsageForProvider(connectionBase); + assert.ok(result, "should return a result"); + + if ("modelQuotas" in result) { + const quota = result.modelQuotas["gemini-2.5-pro"]; + assert.ok(quota, "should have quota for gemini-2.5-pro"); + assert.equal(quota.remaining, 0, "remaining should be 0%"); + assert.equal(quota.limited, true, "should be marked as limited"); + } + } finally { + mockFetch.mock.restore(); + } + }); + + it("defaults to 0% remaining when remainingFraction key is absent (exhausted quota)", async () => { + const fetcherModule = await import("../../src/lib/usage/fetcher.ts"); + const { getUsageForProvider } = fetcherModule; + + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-2.5-pro": { + quotaInfo: { + // remainingFraction key is omitted (exhausted quota) + resetTime: "2026-05-26T00:00:00Z", + }, + }, + }, + }), + })); + + try { + const result = await getUsageForProvider(connectionBase); + assert.ok(result, "should return a result"); + + if ("modelQuotas" in result) { + const quota = result.modelQuotas["gemini-2.5-pro"]; + assert.ok(quota, "should have quota for gemini-2.5-pro"); + assert.equal(quota.remaining, 0, "remaining should be 0% when key is absent"); + assert.equal(quota.limited, true, "should be marked as limited"); + assert.equal(quota.resetAt, "2026-05-26T00:00:00Z", "should preserve resetTime"); + } + } finally { + mockFetch.mock.restore(); + } + }); + + it("parses remainingFraction=0 as exhausted quota", async () => { + const fetcherModule = await import("../../src/lib/usage/fetcher.ts"); + const { getUsageForProvider } = fetcherModule; + + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-2.5-pro": { + quotaInfo: { + remainingFraction: 0, + resetTime: "2026-05-26T00:00:00Z", + }, + }, + }, + }), + })); + + try { + const result = await getUsageForProvider(connectionBase); + assert.ok(result, "should return a result"); + + if ("modelQuotas" in result) { + const quota = result.modelQuotas["gemini-2.5-pro"]; + assert.ok(quota, "should have quota for gemini-2.5-pro"); + assert.equal(quota.remaining, 0, "remaining should be 0%"); + assert.equal(quota.limited, true, "should be marked as limited"); + } + } finally { + mockFetch.mock.restore(); + } + }); + + it("parses remainingFraction=1.0 with resetTime as full quota", async () => { + const fetcherModule = await import("../../src/lib/usage/fetcher.ts"); + const { getUsageForProvider } = fetcherModule; + + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-2.5-pro": { + quotaInfo: { + remainingFraction: 1.0, + resetTime: "2026-05-26T00:00:00Z", + }, + }, + }, + }), + })); + + try { + const result = await getUsageForProvider(connectionBase); + assert.ok(result, "should return a result"); + + if ("modelQuotas" in result) { + const quota = result.modelQuotas["gemini-2.5-pro"]; + assert.ok(quota, "should have quota for gemini-2.5-pro"); + assert.equal(quota.remaining, 100, "remaining should be 100%"); + assert.equal(quota.limited, false, "should not be marked as limited"); + } + } finally { + mockFetch.mock.restore(); + } + }); + + it("parses remainingFraction=0.5 as partial quota", async () => { + const fetcherModule = await import("../../src/lib/usage/fetcher.ts"); + const { getUsageForProvider } = fetcherModule; + + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-2.5-pro": { + quotaInfo: { + remainingFraction: 0.5, + resetTime: "2026-05-26T00:00:00Z", + }, + }, + }, + }), + })); + + try { + const result = await getUsageForProvider(connectionBase); + assert.ok(result, "should return a result"); + + if ("modelQuotas" in result) { + const quota = result.modelQuotas["gemini-2.5-pro"]; + assert.ok(quota, "should have quota for gemini-2.5-pro"); + assert.equal(quota.remaining, 50, "remaining should be 50%"); + assert.equal(quota.limited, false, "should not be marked as limited"); + } + } finally { + mockFetch.mock.restore(); + } + }); + + it("skips credit-based models without remainingFraction", async () => { + const fetcherModule = await import("../../src/lib/usage/fetcher.ts"); + const { getUsageForProvider } = fetcherModule; + + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-2.5-pro": { + quotaInfo: {}, + }, + }, + }), + })); + + try { + const result = await getUsageForProvider(connectionBase); + assert.ok(result, "should return a result"); + + if ("modelQuotas" in result) { + assert.equal( + Object.keys(result.modelQuotas).length, + 0, + "should not include credit-based models" + ); + } + } finally { + mockFetch.mock.restore(); + } + }); + + it("clamps remainingFraction > 1 to 100%", async () => { + const fetcherModule = await import("../../src/lib/usage/fetcher.ts"); + const { getUsageForProvider } = fetcherModule; + + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-2.5-pro": { + quotaInfo: { + remainingFraction: 1.5, + resetTime: "2026-05-26T00:00:00Z", + }, + }, + }, + }), + })); + + try { + const result = await getUsageForProvider(connectionBase); + assert.ok(result, "should return a result"); + + if ("modelQuotas" in result) { + const quota = result.modelQuotas["gemini-2.5-pro"]; + assert.ok(quota, "should have quota for gemini-2.5-pro"); + assert.equal(quota.remaining, 100, "remaining should be clamped to 100%"); + assert.equal(quota.limited, false, "should not be marked as limited"); + } + } finally { + mockFetch.mock.restore(); + } + }); + + it("clamps negative remainingFraction to 0%", async () => { + const fetcherModule = await import("../../src/lib/usage/fetcher.ts"); + const { getUsageForProvider } = fetcherModule; + + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-2.5-pro": { + quotaInfo: { + remainingFraction: -0.5, + resetTime: "2026-05-26T00:00:00Z", + }, + }, + }, + }), + })); + + try { + const result = await getUsageForProvider(connectionBase); + assert.ok(result, "should return a result"); + + if ("modelQuotas" in result) { + const quota = result.modelQuotas["gemini-2.5-pro"]; + assert.ok(quota, "should have quota for gemini-2.5-pro"); + assert.equal(quota.remaining, 0, "remaining should be clamped to 0%"); + assert.equal(quota.limited, true, "should be marked as limited"); + } + } finally { + mockFetch.mock.restore(); + } + }); +}); diff --git a/tests/unit/antigravity-usage-service.test.ts b/tests/unit/antigravity-usage-service.test.ts new file mode 100644 index 000000000000..9a357f6a0183 --- /dev/null +++ b/tests/unit/antigravity-usage-service.test.ts @@ -0,0 +1,261 @@ +/** + * Tests for open-sse/services/usage.ts — Antigravity quota parsing. + * + * Verifies that remainingFraction is correctly parsed: + * - undefined → 0% remaining (exhausted quota) + * - 0 → 0% remaining (exhausted quota, explicit) + * - 1.0 → 100% remaining (full quota) + * - 1.0 without resetTime → unlimited (e.g. tab-completion) + * - 0.5 → 50% remaining (partial quota) + */ + +import { describe, it, mock } from "node:test"; +import assert from "node:assert/strict"; + +describe("getUsageForProvider (antigravity in usage.ts)", () => { + const connectionBase = { + id: "test-conn", + provider: "antigravity", + accessToken: "fake-token", + providerSpecificData: {}, + projectId: undefined, + }; + + it("defaults to 0% remaining when remainingFraction is undefined", async () => { + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-preview": { + quotaInfo: { + remainingFraction: undefined, + resetTime: "2026-05-26T00:00:00Z", + }, + }, + }, + }), + })); + + try { + const usageModule = await import("../../open-sse/services/usage.ts"); + const { getUsageForProvider } = usageModule; + + const result = await getUsageForProvider(connectionBase, { forceRefresh: true }); + assert.ok(result, "should return a result"); + assert.ok("quotas" in result, "should have quotas"); + + if ("quotas" in result) { + const quota = result.quotas["gemini-3.5-flash-preview"]; + assert.ok(quota, "should have quota for gemini-3.5-flash-preview"); + assert.equal(quota.remainingPercentage, 0, "remaining should be 0%"); + assert.equal(quota.unlimited, false, "should not be unlimited"); + assert.equal(quota.used > 0, true, "used should be > 0 when quota is exhausted"); + } + } finally { + mockFetch.mock.restore(); + } + }); + + it("parses remainingFraction=0 as exhausted quota", async () => { + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-preview": { + quotaInfo: { + remainingFraction: 0, + resetTime: "2026-05-26T00:00:00Z", + }, + }, + }, + }), + })); + + try { + const usageModule = await import("../../open-sse/services/usage.ts"); + const { getUsageForProvider } = usageModule; + + const result = await getUsageForProvider(connectionBase, { forceRefresh: true }); + assert.ok(result, "should return a result"); + assert.ok("quotas" in result, "should have quotas"); + + if ("quotas" in result) { + const quota = result.quotas["gemini-3.5-flash-preview"]; + assert.ok(quota, "should have quota for gemini-3.5-flash-preview"); + assert.equal(quota.remainingPercentage, 0, "remaining should be 0%"); + assert.equal(quota.unlimited, false, "should not be unlimited"); + } + } finally { + mockFetch.mock.restore(); + } + }); + + it("parses remainingFraction=1.0 with resetTime as full quota", async () => { + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-preview": { + quotaInfo: { + remainingFraction: 1.0, + resetTime: "2026-05-26T00:00:00Z", + }, + }, + }, + }), + })); + + try { + const usageModule = await import("../../open-sse/services/usage.ts"); + const { getUsageForProvider } = usageModule; + + const result = await getUsageForProvider(connectionBase, { forceRefresh: true }); + assert.ok(result, "should return a result"); + assert.ok("quotas" in result, "should have quotas"); + + if ("quotas" in result) { + const quota = result.quotas["gemini-3.5-flash-preview"]; + assert.ok(quota, "should have quota for gemini-3.5-flash-preview"); + assert.equal(quota.remainingPercentage, 100, "remaining should be 100%"); + assert.equal(quota.unlimited, false, "should not be unlimited (has resetTime)"); + } + } finally { + mockFetch.mock.restore(); + } + }); + + it("parses remainingFraction=1.0 without resetTime as unlimited (e.g. tab-completion)", async () => { + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.1-flash-lite": { + quotaInfo: { + remainingFraction: 1.0, + }, + }, + }, + }), + })); + + try { + const usageModule = await import("../../open-sse/services/usage.ts"); + const { getUsageForProvider } = usageModule; + + const result = await getUsageForProvider(connectionBase, { forceRefresh: true }); + assert.ok(result, "should return a result"); + assert.ok("quotas" in result, "should have quotas"); + + if ("quotas" in result) { + const quota = result.quotas["gemini-3.1-flash-lite"]; + assert.ok(quota, "should have quota for gemini-3.1-flash-lite"); + assert.equal(quota.remainingPercentage, 100, "remaining should be 100%"); + assert.equal(quota.unlimited, true, "should be unlimited (no resetTime)"); + } + } finally { + mockFetch.mock.restore(); + } + }); + + it("parses remainingFraction=0.5 as partial quota", async () => { + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-preview": { + quotaInfo: { + remainingFraction: 0.5, + resetTime: "2026-05-26T00:00:00Z", + }, + }, + }, + }), + })); + + try { + const usageModule = await import("../../open-sse/services/usage.ts"); + const { getUsageForProvider } = usageModule; + + const result = await getUsageForProvider(connectionBase, { forceRefresh: true }); + assert.ok(result, "should return a result"); + assert.ok("quotas" in result, "should have quotas"); + + if ("quotas" in result) { + const quota = result.quotas["gemini-3.5-flash-preview"]; + assert.ok(quota, "should have quota for gemini-3.5-flash-preview"); + assert.equal(quota.remainingPercentage, 50, "remaining should be 50%"); + assert.equal(quota.unlimited, false, "should not be unlimited"); + } + } finally { + mockFetch.mock.restore(); + } + }); + + it("clamps remainingFraction > 1 to 100%", async () => { + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-preview": { + quotaInfo: { + remainingFraction: 1.5, + resetTime: "2026-05-26T00:00:00Z", + }, + }, + }, + }), + })); + + try { + const usageModule = await import("../../open-sse/services/usage.ts"); + const { getUsageForProvider } = usageModule; + + const result = await getUsageForProvider(connectionBase, { forceRefresh: true }); + assert.ok(result, "should return a result"); + assert.ok("quotas" in result, "should have quotas"); + + if ("quotas" in result) { + const quota = result.quotas["gemini-3.5-flash-preview"]; + assert.ok(quota, "should have quota for gemini-3.5-flash-preview"); + assert.equal(quota.remainingPercentage, 100, "remaining should be clamped to 100%"); + assert.equal(quota.unlimited, false, "should not be unlimited (has resetTime)"); + } + } finally { + mockFetch.mock.restore(); + } + }); + + it("clamps negative remainingFraction to 0%", async () => { + const mockFetch = mock.method(global, "fetch", async () => ({ + ok: true, + json: async () => ({ + models: { + "gemini-3.5-flash-preview": { + quotaInfo: { + remainingFraction: -0.5, + resetTime: "2026-05-26T00:00:00Z", + }, + }, + }, + }), + })); + + try { + const usageModule = await import("../../open-sse/services/usage.ts"); + const { getUsageForProvider } = usageModule; + + const result = await getUsageForProvider(connectionBase, { forceRefresh: true }); + assert.ok(result, "should return a result"); + assert.ok("quotas" in result, "should have quotas"); + + if ("quotas" in result) { + const quota = result.quotas["gemini-3.5-flash-preview"]; + assert.ok(quota, "should have quota for gemini-3.5-flash-preview"); + assert.equal(quota.remainingPercentage, 0, "remaining should be clamped to 0%"); + assert.equal(quota.unlimited, false, "should not be unlimited"); + } + } finally { + mockFetch.mock.restore(); + } + }); +}); From d32bdfbd6caa81e4655dcbba045e0ec5bcecc7dc Mon Sep 17 00:00:00 2001 From: Benson K B Date: Mon, 25 May 2026 23:46:58 -0300 Subject: [PATCH 16/77] fix(electron): Caps Lock indicator, Electron-aware reset message & suppress shell window (#2714) Thanks @benzntech. --- electron/main.js | 30 +++ .../(dashboard)/dashboard/onboarding/page.tsx | 13 ++ src/app/forgot-password/page.tsx | 206 +++++++++++++----- src/shared/components/Input.tsx | 62 +++++- 4 files changed, 257 insertions(+), 54 deletions(-) diff --git a/electron/main.js b/electron/main.js index 24cf54b2d788..ec744af8e5b6 100644 --- a/electron/main.js +++ b/electron/main.js @@ -68,6 +68,32 @@ const getServerUrl = () => `http://localhost:${serverPort}`; function resolveNodeExecutable(env = process.env) { // #1081: Ensure Next.js standalone runs using Electron's Node runtime // instead of a randomly found system Node to prevent ABI architecture mismatches. + // + // On macOS packaged builds, process.execPath is the main Electron binary + // (e.g. OmniRoute.app/Contents/MacOS/OmniRoute). Spawning it with + // ELECTRON_RUN_AS_NODE causes macOS to show a second dock icon and/or + // flash a shell window. Use the Helper binary instead — macOS treats + // Helper processes as background tasks with no visible UI artifacts. + if (process.platform === "darwin" && !isDev) { + const helperPath = path.join(path.dirname(process.execPath), `${app.getName()} Helper`); + if (fs.existsSync(helperPath)) { + return helperPath; + } + // Electron \u003e= 20 may use "(Renderer)" / "(GPU)" / "(Plugin)" suffixed helpers. + // The unsuffixed Helper is the one suitable for ELECTRON_RUN_AS_NODE. + const frameworkHelper = path.join( + path.dirname(process.execPath), + "..", + "Frameworks", + `${app.getName()} Helper.app`, + "Contents", + "MacOS", + `${app.getName()} Helper` + ); + if (fs.existsSync(frameworkHelper)) { + return frameworkHelper; + } + } return process.execPath; } @@ -594,6 +620,8 @@ function startNextServer() { sendToRenderer("server-status", { status: "starting", port: serverPort }); // Fix #10: Use pipe instead of inherit for logging & readiness detection + // windowsHide prevents a visible console window from spawning alongside the GUI app. + // shell: false avoids launching via a shell wrapper which can flash a terminal on macOS. nextServer = spawn(nodeExecutable, [serverScript], { cwd: NEXT_SERVER_PATH, env: { @@ -605,6 +633,8 @@ function startNextServer() { NODE_PATH: resolveServerNodePath(serverEnv), }, stdio: "pipe", + windowsHide: true, + shell: false, }); // Capture server output for logging diff --git a/src/app/(dashboard)/dashboard/onboarding/page.tsx b/src/app/(dashboard)/dashboard/onboarding/page.tsx index fc99f0a89a18..36ace2b1c1af 100644 --- a/src/app/(dashboard)/dashboard/onboarding/page.tsx +++ b/src/app/(dashboard)/dashboard/onboarding/page.tsx @@ -31,6 +31,7 @@ export default function OnboardingWizard() { const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [skipSecurity, setSkipSecurity] = useState(false); + const [capsLockOn, setCapsLockOn] = useState(false); // Provider step state const [selectedProvider, setSelectedProvider] = useState(null); @@ -325,6 +326,8 @@ export default function OnboardingWizard() { placeholder={t("enterPassword")} value={password} onChange={(e) => setPassword(e.target.value)} + onKeyDown={(e) => setCapsLockOn(e.getModifierState("CapsLock"))} + onKeyUp={(e) => setCapsLockOn(e.getModifierState("CapsLock"))} className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40" /> setConfirmPassword(e.target.value)} + onKeyDown={(e) => setCapsLockOn(e.getModifierState("CapsLock"))} + onKeyUp={(e) => setCapsLockOn(e.getModifierState("CapsLock"))} className="w-full px-4 py-2.5 bg-white/[0.04] border border-white/10 rounded-lg text-text-main text-sm placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/40" /> + {capsLockOn && ( +

+ + Caps Lock is on +

+ )} {password && confirmPassword && password !== confirmPassword && (

{t("passwordsMismatch")}

)} diff --git a/src/app/forgot-password/page.tsx b/src/app/forgot-password/page.tsx index 1d7984d6ef5a..d821f8fdf34c 100644 --- a/src/app/forgot-password/page.tsx +++ b/src/app/forgot-password/page.tsx @@ -1,13 +1,15 @@ "use client"; import { useTranslations } from "next-intl"; +import { useState, useEffect } from "react"; +import { useIsElectron } from "@/shared/hooks/useElectron"; /** * Forgot Password Page — Phase 8.2 * - * Provides two recovery methods: - * 1. CLI reset via omniroute-reset-password command - * 2. Manual database reset instructions + * Provides recovery methods: + * - Web/CLI: CLI reset via omniroute-reset-password command + manual database reset + * - Electron: Data directory reset instructions */ import Link from "next/link"; @@ -15,6 +17,18 @@ import { Card } from "@/shared/components"; export default function ForgotPasswordPage() { const t = useTranslations("auth"); + const isElectron = useIsElectron(); + const [dataDir, setDataDir] = useState(null); + + useEffect(() => { + if (isElectron && typeof window !== "undefined" && (window as any).electronAPI?.getDataDir) { + (window as any).electronAPI + .getDataDir() + .then((dir: string) => setDataDir(dir)) + .catch(() => {}); + } + }, [isElectron]); + return (
@@ -23,58 +37,146 @@ export default function ForgotPasswordPage() {

{t("resetDescription")}

- {/* Method 1: CLI Reset */} - -
-
- -
-
-

{t("methodCliTitle")}

-

{t("methodCliDescription")}

-
- npx omniroute reset-password + {isElectron ? ( + <> + {/* Electron: App Reset Method */} + +
+
+ +
+
+

Reset via App Data

+

+ Delete the settings file from the app data directory to reset your password: +

+
    +
  1. Quit the OmniRoute desktop app completely
  2. +
  3. + Navigate to the app data directory: + {dataDir ? ( +
    + {dataDir} +
    + ) : ( +
    + + (Check your system app data folder) + +
    + )} +
  4. +
  5. + Delete{" "} + settings.json{" "} + ({t("orRemovePasswordHashField")}) +
  6. +
  7. Relaunch the OmniRoute desktop app — it will start fresh setup
  8. +
+
-

{t("methodCliHint")}

-
-
- + - {/* Method 2: Database Reset */} - -
-
- -
-
-

{t("methodManualTitle")}

-

{t("methodManualDescription")}

-
    -
  1. {t("stopServer")}
  2. -
  3. - {t("setPasswordInYour")}{" "} - .env{" "} - {t("fileLabelSuffix")} -
    - INITIAL_PASSWORD={t("newPasswordPlaceholder")} + {/* Electron: Env File Method */} + +
    +
    + +
    +
    +

    Alternative: Set New Password

    +

    + Set a new initial password via the server environment file: +

    +
      +
    1. Quit the OmniRoute desktop app completely
    2. +
    3. + Open{" "} + server.env in + the data directory + {dataDir && ( +
      + {dataDir}/server.env +
      + )} +
    4. +
    5. + Add or update: +
      + INITIAL_PASSWORD={t("newPasswordPlaceholder")} +
      +
    6. +
    7. + Delete{" "} + settings.json{" "} + from the data directory +
    8. +
    9. Relaunch the OmniRoute desktop app
    10. +
    +
    +
    +
    + + ) : ( + <> + {/* Method 1: CLI Reset */} + +
    +
    + +
    +
    +

    {t("methodCliTitle")}

    +

    {t("methodCliDescription")}

    +
    + npx omniroute reset-password
    -
  4. -
  5. - {t("deleteSettingsFile")}{" "} - - data/settings.json - {" "} - ({t("orRemovePasswordHashField")}) -
  6. -
  7. {t("restartServerWithNewPassword")}
  8. -
-
-
-
+

{t("methodCliHint")}

+
+
+ + + {/* Method 2: Database Reset */} + +
+
+ +
+
+

{t("methodManualTitle")}

+

{t("methodManualDescription")}

+
    +
  1. {t("stopServer")}
  2. +
  3. + {t("setPasswordInYour")}{" "} + .env{" "} + {t("fileLabelSuffix")} +
    + INITIAL_PASSWORD={t("newPasswordPlaceholder")} +
    +
  4. +
  5. + {t("deleteSettingsFile")}{" "} + + data/settings.json + {" "} + ({t("orRemovePasswordHashField")}) +
  6. +
  7. {t("restartServerWithNewPassword")}
  8. +
+
+
+
+ + )}
, "size"> { @@ -25,13 +25,47 @@ export default function Input({ className, inputClassName, id: externalId, + onKeyDown: externalOnKeyDown, + onKeyUp: externalOnKeyUp, ...props }: InputProps) { const generatedId = useId(); const inputId = externalId || generatedId; const errorId = error ? `${inputId}-error` : undefined; const hintId = hint && !error ? `${inputId}-hint` : undefined; - const describedBy = [errorId, hintId].filter(Boolean).join(" ") || undefined; + const capsLockId = `${inputId}-capslock`; + const isPassword = type === "password"; + const [capsLockOn, setCapsLockOn] = useState(false); + const [inputFocused, setInputFocused] = useState(false); + + const detectCapsLock = useCallback( + (e: React.KeyboardEvent) => { + if (isPassword) { + setCapsLockOn(e.getModifierState("CapsLock")); + } + }, + [isPassword] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + detectCapsLock(e); + externalOnKeyDown?.(e); + }, + [detectCapsLock, externalOnKeyDown] + ); + + const handleKeyUp = useCallback( + (e: React.KeyboardEvent) => { + detectCapsLock(e); + externalOnKeyUp?.(e); + }, + [detectCapsLock, externalOnKeyUp] + ); + + const showCapsLock = isPassword && capsLockOn && inputFocused; + const describedBy = + [errorId, showCapsLock ? capsLockId : undefined, hintId].filter(Boolean).join(" ") || undefined; return (
@@ -64,6 +98,17 @@ export default function Input({ aria-required={required || undefined} aria-invalid={error ? true : undefined} aria-describedby={describedBy} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} + onFocus={(e) => { + setInputFocused(true); + props.onFocus?.(e); + }} + onBlur={(e) => { + setInputFocused(false); + setCapsLockOn(false); + props.onBlur?.(e); + }} className={cn( "w-full py-2 px-3 text-sm text-text-main", "bg-white dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-md", @@ -79,6 +124,19 @@ export default function Input({ {...props} />
+ {showCapsLock && ( +

+ + Caps Lock is on +

+ )} {error && (
- -
-
- -

{t("globalProxy")}

-
-

{t("globalProxyDesc")}

-
- {globalProxy ? ( -
- - {globalProxy.type}://{globalProxy.host}:{globalProxy.port} - -
- ) : ( - {t("noGlobalProxy")} - )} - -
-
-
- - +
+
+ {TABS.map((tab) => ( + + ))}
- setProxyModalOpen(false)} - level="global" - levelLabel={t("globalLabel")} - onSaved={loadGlobalProxy} - /> - +
+ {activeTab === "global-config" && } + {activeTab === "proxy-pool" && } + {activeTab === "free-pool" && } + {activeTab === "documentation" && } +
+
); } diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/DocumentationTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/DocumentationTab.tsx new file mode 100644 index 000000000000..c9ddc8e42eb4 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/DocumentationTab.tsx @@ -0,0 +1,62 @@ +"use client"; +import { Card } from "@/shared/components"; + +export default function DocumentationTab() { + return ( + +
+

Proxy scope resolution

+

+ OmniRoute resolves the outbound proxy in priority order: + combo → account → provider → global. The most specific scope wins. +

+
+ +
+

Adding a custom proxy

+

+ Go to the Proxy Pool tab → click + Add proxy. Fill in type + (http/https/socks5), host, and port. Optionally assign it to a scope. +

+
+ +
+

Bulk import format

+
+          {`http://user:pass@1.2.3.4:8080\nhttps://5.6.7.8:3128\nsocks5://9.0.1.2:1080`}
+        
+

+ Pipe-delimited fields: type|host|port|user|pass|name +

+
+ +
+

SOCKS5

+

+ SOCKS5 proxies are disabled by default. Set{" "} + ENABLE_SOCKS5_PROXY=true to enable. +

+
+ +
+

Free Pool

+

+ The Free Pool tab aggregates proxies from 1proxy, Proxifly, and IPLocate. Use ⊕ to test + and promote a proxy to your registry. Only proxies that pass the connectivity test are + added. +

+
+ +
+

Vercel Relay

+

+ The Vercel Relay is an outbound edge relay — not an inbound tunnel. Deploying one sends + LLM API calls through Vercel's dynamic IPs, bypassing datacenter geo-blocks and rate + limits. The relay is protected by a generated secret header ( + x-relay-auth). Your Vercel token is + used only during deploy and is never stored. +

+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx new file mode 100644 index 000000000000..bfadaae2372b --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx @@ -0,0 +1,278 @@ +"use client"; +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/shared/components"; +import SourceToggleBar, { + type SourceId, + ALL_SOURCE_IDS, + loadDisabledSources, + saveDisabledSources, +} from "./SourceToggleBar"; +import FreeProxyRow, { type FreeProxyRowData } from "./FreeProxyRow"; + +type FreePoolStats = { + total: number; + inPool: number; + avgQuality: number | null; + lastSyncAt: string | null; +}; + +export default function FreePoolTab() { + const [proxies, setProxies] = useState([]); + const [stats, setStats] = useState(null); + const [disabledSources, setDisabledSources] = useState>(new Set()); + const [filterProtocol, setFilterProtocol] = useState(""); + const [filterCountry, setFilterCountry] = useState(""); + const [minQuality, setMinQuality] = useState(""); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [addingIds, setAddingIds] = useState>(new Set()); + const [bulkProgress, setBulkProgress] = useState(null); + + // Load persisted disabled-sources from localStorage on mount + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- localStorage hydration, runs once + setDisabledSources(loadDisabledSources()); + }, []); + + const handleToggleSource = useCallback((source: SourceId) => { + setDisabledSources((prev) => { + const next = new Set(prev); + if (next.has(source)) next.delete(source); + else next.add(source); + saveDisabledSources(next); + return next; + }); + }, []); + + const loadData = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s)); + if (enabledSources.length < ALL_SOURCE_IDS.length) { + params.set("sources", enabledSources.join(",")); + } + if (filterProtocol) params.set("protocol", filterProtocol); + if (filterCountry) params.set("country", filterCountry); + if (minQuality) params.set("minQuality", minQuality); + params.set("limit", "200"); + + const [proxiesRes, statsRes] = await Promise.all([ + fetch(`/api/settings/free-proxies?${params.toString()}`), + fetch("/api/settings/free-proxies/stats"), + ]); + if (proxiesRes.ok) { + const data = await proxiesRes.json(); + setProxies(data.items || []); + } + if (statsRes.ok) { + const data = await statsRes.json(); + setStats(data.stats || null); + } + } catch {} + setLoading(false); + }, [disabledSources, filterProtocol, filterCountry, minQuality]); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- async data fetch on filter change + loadData(); + }, [loadData]); + + const handleSync = async () => { + setSyncing(true); + try { + const enabledSources = ALL_SOURCE_IDS.filter((s) => !disabledSources.has(s)); + const body = + enabledSources.length < ALL_SOURCE_IDS.length ? { sources: enabledSources } : {}; + await fetch("/api/settings/free-proxies/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + await loadData(); + } catch {} + setSyncing(false); + }; + + const handleAddToPool = async (id: string) => { + setAddingIds((prev) => new Set(prev).add(id)); + try { + const res = await fetch(`/api/settings/free-proxies/${id}/add-to-pool`, { + method: "POST", + }); + if (res.ok) { + setProxies((prev) => prev.map((p) => (p.id === id ? { ...p, inPool: true } : p))); + } + } catch {} + setAddingIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + }; + + const handleToggleSelect = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleBulkAdd = async (ids: string[]) => { + if (!ids.length) return; + setBulkProgress("Testing proxies..."); + try { + const res = await fetch("/api/settings/free-proxies/bulk-add-to-pool", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }); + const data = await res.json(); + setBulkProgress(`${data.succeeded ?? 0} added, ${data.failed ?? 0} failed`); + await loadData(); + setSelected(new Set()); + } catch {} + setTimeout(() => setBulkProgress(null), 4000); + }; + + const notInPoolProxies = proxies.filter((p) => !p.inPool); + + return ( +
+
+ +
+ + setFilterCountry(e.target.value.toUpperCase().slice(0, 2))} + className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-28" + aria-label="Filter by country" + /> + setMinQuality(e.target.value)} + min={0} + max={100} + className="text-xs bg-surface-alt border border-border rounded px-2 py-1 w-24" + aria-label="Minimum quality score" + /> + +
+
+ + {stats && ( +
+ Total: {stats.total} + In pool: {stats.inPool} + {stats.avgQuality != null && Avg quality: {stats.avgQuality}} + {stats.lastSyncAt && ( + Last sync: {new Date(stats.lastSyncAt).toLocaleTimeString()} + )} +
+ )} + + {selected.size > 0 && ( +
+ {selected.size} selected + + {bulkProgress && {bulkProgress}} +
+ )} + + {notInPoolProxies.length > 0 && selected.size === 0 && ( +
+ +
+ )} + +
+ + + + + + + + + + + + + + + {loading ? ( + + + + ) : proxies.length === 0 ? ( + + + + ) : ( + proxies.map((p) => ( + + )) + )} + +
+ Source + + Host:Port + + Type + + Country + + Quality + + Latency +
+ Loading... +
+ No proxies found. Click Sync All to fetch from sources. +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/FreeProxyRow.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/FreeProxyRow.tsx new file mode 100644 index 000000000000..f2863cce3d54 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/FreeProxyRow.tsx @@ -0,0 +1,82 @@ +"use client"; + +export interface FreeProxyRowData { + id: string; + source: string; + host: string; + port: number; + type: string; + countryCode: string | null; + qualityScore: number | null; + latencyMs: number | null; + anonymity: string | null; + inPool: boolean; +} + +interface FreeProxyRowProps { + proxy: FreeProxyRowData; + selected: boolean; + onToggleSelect: (id: string) => void; + onAddToPool: (id: string) => void; + adding: boolean; +} + +export default function FreeProxyRow({ + proxy, + selected, + onToggleSelect, + onAddToPool, + adding, +}: FreeProxyRowProps) { + const qualityColor = + proxy.qualityScore == null + ? "text-text-muted" + : proxy.qualityScore >= 80 + ? "text-emerald-400" + : proxy.qualityScore >= 50 + ? "text-yellow-400" + : "text-red-400"; + + return ( + + + onToggleSelect(proxy.id)} + className="rounded" + disabled={proxy.inPool} + aria-label={`Select ${proxy.host}:${proxy.port}`} + /> + + {proxy.source} + + {proxy.host}:{proxy.port} + + {proxy.type} + {proxy.countryCode || "—"} + + {proxy.qualityScore != null ? proxy.qualityScore : "—"} + + + {proxy.latencyMs != null ? `${proxy.latencyMs}ms` : "—"} + + + {proxy.inPool ? ( + + in pool + + ) : ( + + )} + + + ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx new file mode 100644 index 000000000000..7b0b568df88c --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/GlobalConfigTab.tsx @@ -0,0 +1,71 @@ +"use client"; +import { useState, useEffect, useCallback } from "react"; +import { Card, Button, ProxyConfigModal } from "@/shared/components"; +import { useTranslations } from "next-intl"; + +type GlobalProxyConfig = { type: string; host: string; port: number } | null; + +export default function GlobalConfigTab() { + const [proxyModalOpen, setProxyModalOpen] = useState(false); + const [globalProxy, setGlobalProxy] = useState(null); + const t = useTranslations("settings"); + const tc = useTranslations("common"); + + const loadGlobalProxy = useCallback(async () => { + try { + const res = await fetch("/api/settings/proxy?level=global"); + if (res.ok) { + const data = await res.json(); + setGlobalProxy(data.proxy || null); + } + } catch {} + }, []); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect -- async data fetch on mount + loadGlobalProxy(); + }, [loadGlobalProxy]); + + return ( + <> + +
+
+ +

{t("globalProxy")}

+
+

{t("globalProxyDesc")}

+
+ {globalProxy ? ( + + {globalProxy.type}://{globalProxy.host}:{globalProxy.port} + + ) : ( + {t("noGlobalProxy")} + )} + +
+
+
+ setProxyModalOpen(false)} + level="global" + levelLabel={t("globalLabel")} + onSaved={loadGlobalProxy} + /> + + ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx new file mode 100644 index 000000000000..aacc5ec1ec33 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx @@ -0,0 +1,40 @@ +"use client"; +import { useState } from "react"; +import { Button } from "@/shared/components"; +import { useTranslations } from "next-intl"; +import ProxyRegistryManager from "../ProxyRegistryManager"; +import VercelRelayModal from "./VercelRelayModal"; + +export default function ProxyPoolTab() { + const t = useTranslations("settings"); + const [relayModalOpen, setRelayModalOpen] = useState(false); + + const showVercelRelay = process.env.NEXT_PUBLIC_VERCEL_RELAY_ENABLED !== "false"; + + const handleDeployed = (_poolProxyId: string, relayUrl: string) => { + alert(`${t("vercelRelaySuccess")}: ${relayUrl}`); + }; + + return ( +
+ {showVercelRelay && ( +
+ +
+ )} + + setRelayModalOpen(false)} + onDeployed={handleDeployed} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/SourceToggleBar.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/SourceToggleBar.tsx new file mode 100644 index 000000000000..c7d58b8a9aef --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/SourceToggleBar.tsx @@ -0,0 +1,65 @@ +"use client"; + +export type SourceId = "1proxy" | "proxifly" | "iplocate"; + +export const ALL_SOURCE_IDS: SourceId[] = ["1proxy", "proxifly", "iplocate"]; + +export const FREE_POOL_DISABLED_SOURCES_KEY = "freePool.disabledSources"; + +export function loadDisabledSources(): Set { + try { + const raw = globalThis.localStorage?.getItem(FREE_POOL_DISABLED_SOURCES_KEY); + if (!raw) return new Set(); + const arr = JSON.parse(raw) as unknown[]; + return new Set(arr.filter((id): id is SourceId => ALL_SOURCE_IDS.includes(id as SourceId))); + } catch { + return new Set(); + } +} + +export function saveDisabledSources(disabled: Set): void { + try { + globalThis.localStorage?.setItem( + FREE_POOL_DISABLED_SOURCES_KEY, + JSON.stringify([...disabled]) + ); + } catch {} +} + +interface SourceToggleBarProps { + disabledSources: Set; + onToggle: (source: SourceId) => void; +} + +const SOURCES: Array<{ id: SourceId; label: string }> = [ + { id: "1proxy", label: "1proxy" }, + { id: "proxifly", label: "Proxifly" }, + { id: "iplocate", label: "IPLocate" }, +]; + +export default function SourceToggleBar({ disabledSources, onToggle }: SourceToggleBarProps) { + return ( +
+ {SOURCES.map((s) => { + const enabled = !disabledSources.has(s.id); + return ( + + ); + })} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/settings/components/proxy/VercelRelayModal.tsx b/src/app/(dashboard)/dashboard/settings/components/proxy/VercelRelayModal.tsx new file mode 100644 index 000000000000..8a5427af3881 --- /dev/null +++ b/src/app/(dashboard)/dashboard/settings/components/proxy/VercelRelayModal.tsx @@ -0,0 +1,133 @@ +"use client"; +import { useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/shared/components"; + +interface VercelRelayModalProps { + isOpen: boolean; + onClose: () => void; + onDeployed: (poolProxyId: string, relayUrl: string) => void; +} + +export default function VercelRelayModal({ isOpen, onClose, onDeployed }: VercelRelayModalProps) { + const t = useTranslations("settings"); + const [token, setToken] = useState(""); + const [projectName, setProjectName] = useState( + process.env.NEXT_PUBLIC_VERCEL_RELAY_DEFAULT_PROJECT || "omniroute-relay" + ); + const [deploying, setDeploying] = useState(false); + const [error, setError] = useState(null); + + const handleDeploy = async () => { + if (!token.trim()) { + setError("Token is required"); + return; + } + setDeploying(true); + setError(null); + try { + const res = await fetch("/api/settings/proxy/vercel-deploy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: token.trim(), projectName: projectName.trim() }), + }); + const data = await res.json(); + if (!res.ok || !data.success) { + setError(data.error?.message || "Deploy failed"); + } else { + setToken(""); + onDeployed(data.poolProxyId as string, data.relayUrl as string); + onClose(); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setDeploying(false); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+

+ + {t("vercelRelayModalTitle")} +

+ +
+ +
+ {t("vercelRelayWarning")} +
+ +
+
+ + setToken(e.target.value)} + className="w-full text-sm bg-surface-alt border border-border rounded px-3 py-2 focus:outline-none focus:border-primary" + placeholder="vercel_pat_..." + autoComplete="off" + /> +

+ Token is used only during deploy — never stored. +

+
+
+ + setProjectName(e.target.value)} + className="w-full text-sm bg-surface-alt border border-border rounded px-3 py-2 focus:outline-none focus:border-primary" + placeholder="omniroute-relay" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +

{t("vercelRelayFreeTierNote")}

+ +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/system/1proxy/page.tsx b/src/app/(dashboard)/dashboard/system/1proxy/page.tsx index e4062e966095..5d7a31c48bb7 100644 --- a/src/app/(dashboard)/dashboard/system/1proxy/page.tsx +++ b/src/app/(dashboard)/dashboard/system/1proxy/page.tsx @@ -1,6 +1,5 @@ -"use client"; -import OneproxyTab from "@/app/(dashboard)/dashboard/settings/components/OneproxyTab"; +import { redirect } from "next/navigation"; export default function OneProxyPage() { - return ; + redirect("/dashboard/system/proxy?tab=free-pool"); } diff --git a/src/app/(dashboard)/dashboard/system/mitm-proxy/page.tsx b/src/app/(dashboard)/dashboard/system/mitm-proxy/page.tsx index 956c7f8c95c6..e61765975135 100644 --- a/src/app/(dashboard)/dashboard/system/mitm-proxy/page.tsx +++ b/src/app/(dashboard)/dashboard/system/mitm-proxy/page.tsx @@ -1,6 +1,6 @@ -"use client"; -import MitmProxyTab from "@/app/(dashboard)/dashboard/settings/components/MitmProxyTab"; +import { redirect } from "next/navigation"; export default function MitmProxyPage() { - return ; + // MITM Proxy será movido para Tools/AgentBridge (plano 11) + redirect("/dashboard/system/proxy"); } diff --git a/src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts b/src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts new file mode 100644 index 000000000000..91c084265237 --- /dev/null +++ b/src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts @@ -0,0 +1,118 @@ +import { request as undiciRequest } from "undici"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { getFreeProxyById, markFreeProxyInPool, createProxy } from "@/lib/localDb"; +import { + createProxyDispatcher, + proxyConfigToUrl, +} from "@omniroute/open-sse/utils/proxyDispatcher.ts"; + +type ConnectivityTester = ( + host: string, + port: number, + type: string +) => Promise<{ success: boolean; latencyMs: number; publicIp?: string }>; + +async function testProxyConnectivity( + host: string, + port: number, + type: string +): Promise<{ success: boolean; latencyMs: number; publicIp?: string }> { + const proxyUrl = proxyConfigToUrl({ type, host, port }); + if (!proxyUrl) return { success: false, latencyMs: 0 }; + + const dispatcher = createProxyDispatcher(proxyUrl); + const start = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + try { + const res = await undiciRequest("https://api64.ipify.org?format=json", { + method: "GET", + dispatcher, + signal: controller.signal, + headersTimeout: 5000, + bodyTimeout: 5000, + }); + const text = await res.body.text(); + let parsed: { ip?: string } = {}; + try { + parsed = JSON.parse(text) as { ip?: string }; + } catch {} + return { + success: res.statusCode === 200, + latencyMs: Date.now() - start, + publicIp: parsed.ip, + }; + } catch { + return { success: false, latencyMs: Date.now() - start }; + } finally { + clearTimeout(timeout); + } +} + +let _connectivityTester: ConnectivityTester = testProxyConnectivity; +export function _setConnectivityTesterForTests(fn: ConnectivityTester): void { + _connectivityTester = fn; +} +export function _resetConnectivityTesterForTests(): void { + _connectivityTester = testProxyConnectivity; +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + const { id } = await params; + const freeProxy = await getFreeProxyById(id); + if (!freeProxy) { + return createErrorResponse({ status: 404, message: "Free proxy not found", type: "not_found" }); + } + if (freeProxy.inPool) { + return Response.json({ + success: true, + alreadyInPool: true, + poolProxyId: freeProxy.poolProxyId, + }); + } + + try { + const testResult = await _connectivityTester(freeProxy.host, freeProxy.port, freeProxy.type); + if (!testResult.success) { + return Response.json({ + success: false, + error: "Proxy test failed", + latencyMs: testResult.latencyMs, + }); + } + + const poolProxy = await createProxy({ + name: `[${freeProxy.source}] ${freeProxy.host}:${freeProxy.port}`, + type: freeProxy.type, + host: freeProxy.host, + port: freeProxy.port, + source: freeProxy.source, + }); + + if (!poolProxy) { + return createErrorResponse({ + status: 500, + message: "Failed to create proxy in registry", + type: "internal_error", + }); + } + + await markFreeProxyInPool(id, poolProxy.id); + + return Response.json({ + success: true, + poolProxyId: poolProxy.id, + latencyMs: testResult.latencyMs, + }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to add proxy to pool"); + } +} diff --git a/src/app/api/settings/free-proxies/bulk-add-to-pool/route.ts b/src/app/api/settings/free-proxies/bulk-add-to-pool/route.ts new file mode 100644 index 000000000000..c3b145d7db92 --- /dev/null +++ b/src/app/api/settings/free-proxies/bulk-add-to-pool/route.ts @@ -0,0 +1,130 @@ +import { request as undiciRequest } from "undici"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { freeProxyBulkAddSchema } from "@/shared/validation/freeProxySchemas"; +import { getFreeProxyById, markFreeProxyInPool, createProxy } from "@/lib/localDb"; +import { + createProxyDispatcher, + proxyConfigToUrl, +} from "@omniroute/open-sse/utils/proxyDispatcher.ts"; + +type QuickTester = ( + host: string, + port: number, + type: string +) => Promise<{ ok: boolean; latencyMs: number }>; + +async function testProxyQuick( + host: string, + port: number, + type: string +): Promise<{ ok: boolean; latencyMs: number }> { + const proxyUrl = proxyConfigToUrl({ type, host, port }); + if (!proxyUrl) return { ok: false, latencyMs: 0 }; + const dispatcher = createProxyDispatcher(proxyUrl); + const start = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const res = await undiciRequest("https://api64.ipify.org?format=json", { + method: "GET", + dispatcher, + signal: controller.signal, + headersTimeout: 5000, + bodyTimeout: 5000, + }); + await res.body.dump(); + return { ok: res.statusCode === 200, latencyMs: Date.now() - start }; + } catch { + return { ok: false, latencyMs: Date.now() - start }; + } finally { + clearTimeout(timeout); + } +} + +let _quickTester: QuickTester = testProxyQuick; +export function _setQuickTesterForTests(fn: QuickTester): void { + _quickTester = fn; +} +export function _resetQuickTesterForTests(): void { + _quickTester = testProxyQuick; +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown = {}; + try { + rawBody = await request.json(); + } catch { + return createErrorResponse({ + status: 400, + message: "Invalid JSON", + type: "invalid_request", + }); + } + + const validation = validateBody(freeProxyBulkAddSchema, rawBody); + if (isValidationFailure(validation)) { + return createErrorResponse({ + status: 400, + message: validation.error.message, + type: "invalid_request", + }); + } + + try { + const results: Array<{ + id: string; + success: boolean; + poolProxyId?: string; + error?: string; + }> = []; + + for (const id of validation.data.ids) { + const freeProxy = await getFreeProxyById(id); + if (!freeProxy) { + results.push({ id, success: false, error: "Not found" }); + continue; + } + if (freeProxy.inPool) { + results.push({ + id, + success: true, + poolProxyId: freeProxy.poolProxyId ?? undefined, + }); + continue; + } + + const test = await _quickTester(freeProxy.host, freeProxy.port, freeProxy.type); + if (!test.ok) { + results.push({ id, success: false, error: "Test failed" }); + continue; + } + + const poolProxy = await createProxy({ + name: `[${freeProxy.source}] ${freeProxy.host}:${freeProxy.port}`, + type: freeProxy.type, + host: freeProxy.host, + port: freeProxy.port, + source: freeProxy.source, + }); + + if (!poolProxy) { + results.push({ id, success: false, error: "Failed to create registry entry" }); + continue; + } + + await markFreeProxyInPool(id, poolProxy.id); + results.push({ id, success: true, poolProxyId: poolProxy.id }); + } + + const succeeded = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + return Response.json({ succeeded, failed, results }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Bulk add failed"); + } +} diff --git a/src/app/api/settings/free-proxies/route.ts b/src/app/api/settings/free-proxies/route.ts new file mode 100644 index 000000000000..efa58a5051cf --- /dev/null +++ b/src/app/api/settings/free-proxies/route.ts @@ -0,0 +1,87 @@ +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { freeProxyListSchema, freeProxySourceSchema } from "@/shared/validation/freeProxySchemas"; +import { listFreeProxies, deleteFreeProxy, clearFreeProxiesBySource } from "@/lib/localDb"; +import type { FreeProxySourceId } from "@/lib/freeProxyProviders/types"; + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { searchParams } = new URL(request.url); + const raw = { + sources: searchParams.get("sources") || undefined, + protocol: searchParams.get("protocol") || undefined, + country: searchParams.get("country") || undefined, + minQuality: searchParams.get("minQuality") || undefined, + limit: searchParams.get("limit") || undefined, + offset: searchParams.get("offset") || undefined, + onlyNotInPool: searchParams.get("onlyNotInPool") || undefined, + }; + + const validation = validateBody(freeProxyListSchema, raw); + if (isValidationFailure(validation)) { + return createErrorResponse({ + status: 400, + message: validation.error.message, + type: "invalid_request", + }); + } + + const items = await listFreeProxies({ + sources: validation.data.sources as FreeProxySourceId[] | undefined, + protocol: validation.data.protocol, + country: validation.data.country, + minQuality: validation.data.minQuality, + limit: validation.data.limit, + offset: validation.data.offset, + onlyNotInPool: validation.data.onlyNotInPool || undefined, + }); + + return Response.json({ items, total: items.length }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to list free proxies"); + } +} + +export async function DELETE(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + const source = searchParams.get("source"); + + if (source) { + const parsed = freeProxySourceSchema.safeParse(source); + if (!parsed.success) { + return createErrorResponse({ + status: 400, + message: "Invalid source", + type: "invalid_request", + }); + } + const count = await clearFreeProxiesBySource(parsed.data); + return Response.json({ success: true, deleted: count }); + } + + if (!id) { + return createErrorResponse({ + status: 400, + message: "id or source required", + type: "invalid_request", + }); + } + + const deleted = await deleteFreeProxy(id); + if (!deleted) { + return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); + } + return Response.json({ success: true }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to delete free proxy"); + } +} diff --git a/src/app/api/settings/free-proxies/stats/route.ts b/src/app/api/settings/free-proxies/stats/route.ts new file mode 100644 index 000000000000..6310ba770e36 --- /dev/null +++ b/src/app/api/settings/free-proxies/stats/route.ts @@ -0,0 +1,21 @@ +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { getFreeProxyStats } from "@/lib/localDb"; +import { getAllProviders } from "@/lib/freeProxyProviders"; + +export async function GET(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const stats = await getFreeProxyStats(); + const providers = getAllProviders().map((p) => ({ + id: p.id, + name: p.name, + enabled: p.isEnabled(), + })); + return Response.json({ stats, providers }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to get free proxy stats"); + } +} diff --git a/src/app/api/settings/free-proxies/sync/route.ts b/src/app/api/settings/free-proxies/sync/route.ts new file mode 100644 index 000000000000..4fdcaefa3e62 --- /dev/null +++ b/src/app/api/settings/free-proxies/sync/route.ts @@ -0,0 +1,52 @@ +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { freeProxySyncSchema } from "@/shared/validation/freeProxySchemas"; +import { getEnabledProviders, getProvider } from "@/lib/freeProxyProviders"; +import type { FreeProxySourceId } from "@/lib/freeProxyProviders/types"; + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown = {}; + const ct = request.headers.get("content-type") || ""; + if (ct.includes("application/json")) { + try { + rawBody = await request.json(); + } catch { + return createErrorResponse({ + status: 400, + message: "Invalid JSON", + type: "invalid_request", + }); + } + } + + const validation = validateBody(freeProxySyncSchema, rawBody); + if (isValidationFailure(validation)) { + return createErrorResponse({ + status: 400, + message: validation.error.message, + type: "invalid_request", + }); + } + + try { + const providers = + validation.data.sources && validation.data.sources.length > 0 + ? validation.data.sources + .map((id) => getProvider(id as FreeProxySourceId)) + .filter((p): p is NonNullable => p != null) + : getEnabledProviders(); + + const results: Record = {}; + for (const provider of providers) { + results[provider.id] = await provider.sync(); + } + + return Response.json({ success: true, results }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Failed to sync free proxies"); + } +} diff --git a/src/app/api/settings/oneproxy/rotate/route.ts b/src/app/api/settings/oneproxy/rotate/route.ts index f6233062e794..b2ba08af2179 100644 --- a/src/app/api/settings/oneproxy/rotate/route.ts +++ b/src/app/api/settings/oneproxy/rotate/route.ts @@ -1,44 +1,12 @@ +// Compat redirect: 1 release — /rotate foi descontinuado junto com o sistema 1proxy legado. +// O conceito mais próximo no novo sistema é forçar uma sincronização das fontes de free proxy. import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { rotateOneproxyProxy } from "@/lib/oneproxyRotator"; -import { oneproxyRotateSchema } from "@/shared/validation/oneproxySchemas"; export async function POST(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; - - let rawBody: unknown; - try { - rawBody = await request.json(); - } catch { - rawBody = {}; - } - - try { - const validation = validateBody(oneproxyRotateSchema, rawBody); - if (isValidationFailure(validation)) { - return createErrorResponse({ - status: 400, - message: validation.error.message, - type: "invalid_request", - }); - } - - const proxy = await rotateOneproxyProxy({ - strategy: validation.data.strategy, - }); - - if (!proxy) { - return createErrorResponse({ - status: 404, - message: "No active 1proxy proxies available", - type: "not_found", - }); - } - - return Response.json(proxy); - } catch (error) { - return createErrorResponseFromUnknown(error, "Failed to rotate 1proxy proxy"); - } + return new Response(null, { + status: 308, + headers: { Location: "/api/settings/free-proxies/sync" }, + }); } diff --git a/src/app/api/settings/oneproxy/route.ts b/src/app/api/settings/oneproxy/route.ts index 61be15b9a3e8..a6ff3a56f48b 100644 --- a/src/app/api/settings/oneproxy/route.ts +++ b/src/app/api/settings/oneproxy/route.ts @@ -1,131 +1,25 @@ +// Compat redirect: 1 release — endpoints movidos para /api/settings/free-proxies +// Usa path relativo no header Location — nenhum input do usuário entra na URL destino. import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; -import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; -import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; -import { - listOneproxyProxies, - getOneproxyStats, - deleteOneproxyProxy, - clearAllOneproxyProxies, -} from "@/lib/localDb"; -import { - syncOneproxyProxies, - getOneproxySyncStatus, - resetOneproxyCircuitBreaker, -} from "@/lib/oneproxySync"; -import { oneproxyFilterSchema, oneproxySyncSchema } from "@/shared/validation/oneproxySchemas"; + +function relativeRedirect(location: string): Response { + return new Response(null, { status: 308, headers: { Location: location } }); +} export async function GET(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; - - try { - const { searchParams } = new URL(request.url); - const action = searchParams.get("action"); - - if (action === "stats") { - const stats = await getOneproxyStats(); - const status = getOneproxySyncStatus(); - return Response.json({ stats, status }); - } - - if (action === "status") { - return Response.json(getOneproxySyncStatus()); - } - - const filterValidation = validateBody(oneproxyFilterSchema, { - protocol: searchParams.get("protocol") || undefined, - countryCode: searchParams.get("countryCode") || undefined, - minQuality: searchParams.get("minQuality") || undefined, - limit: searchParams.get("limit") || undefined, - }); - - if (isValidationFailure(filterValidation)) { - return createErrorResponse({ - status: 400, - message: filterValidation.error.message, - type: "invalid_request", - }); - } - - const proxies = await listOneproxyProxies({ - protocol: filterValidation.data.protocol, - countryCode: filterValidation.data.countryCode, - minQuality: filterValidation.data.minQuality, - limit: filterValidation.data.limit, - }); - - return Response.json({ items: proxies, total: proxies.length }); - } catch (error) { - return createErrorResponseFromUnknown(error, "Failed to load 1proxy proxies"); - } + return relativeRedirect("/api/settings/free-proxies"); } export async function POST(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; - - let rawBody: unknown; - const contentType = request.headers.get("content-type") || ""; - if (contentType.includes("application/json")) { - try { - rawBody = await request.json(); - } catch { - return createErrorResponse({ - status: 400, - message: "Invalid JSON body", - type: "invalid_request", - }); - } - } else { - rawBody = {}; - } - - try { - const validation = validateBody(oneproxySyncSchema, rawBody); - if (isValidationFailure(validation)) { - return createErrorResponse({ - status: 400, - message: validation.error.message, - type: "invalid_request", - }); - } - - const result = await syncOneproxyProxies(); - return Response.json(result); - } catch (error) { - return createErrorResponseFromUnknown(error, "Failed to sync 1proxy proxies"); - } + return relativeRedirect("/api/settings/free-proxies/sync"); } export async function DELETE(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; - - try { - const { searchParams } = new URL(request.url); - const id = searchParams.get("id"); - const clearAll = searchParams.get("clearAll") === "1"; - - if (clearAll) { - const count = await clearAllOneproxyProxies(); - return Response.json({ success: true, deleted: count }); - } - - if (!id) { - return createErrorResponse({ - status: 400, - message: "id or clearAll is required", - type: "invalid_request", - }); - } - - const deleted = await deleteOneproxyProxy(id); - if (!deleted) { - return createErrorResponse({ status: 404, message: "Proxy not found", type: "not_found" }); - } - - return Response.json({ success: true }); - } catch (error) { - return createErrorResponseFromUnknown(error, "Failed to delete 1proxy proxy"); - } + return relativeRedirect("/api/settings/free-proxies"); } diff --git a/src/app/api/settings/proxy/test/route.ts b/src/app/api/settings/proxy/test/route.ts index b487ce475b22..8845c0b4315f 100644 --- a/src/app/api/settings/proxy/test/route.ts +++ b/src/app/api/settings/proxy/test/route.ts @@ -68,6 +68,7 @@ export async function POST(request: Request) { // the actual secrets for testing. const body = rawBody as Record; const proxyId = typeof body.proxyId === "string" ? body.proxyId.trim() : null; + let dbProxyNotes: string | null = null; if (proxyId) { const dbProxy = await getProxyById(proxyId, { includeSecrets: true }); if (dbProxy) { @@ -79,10 +80,67 @@ export async function POST(request: Request) { username: dbProxy.username, password: dbProxy.password, }; + dbProxyNotes = dbProxy.notes ?? null; } } const proxyType = String(proxy.type || "http").toLowerCase(); + + // Vercel Relay: test by hitting ipify via the relay headers + if (proxyType === "vercel") { + const relayHost = proxy.host; + // relayAuth lives in notes JSON: { relayAuth } stored by the vercel-deploy route. + // proxy.password is empty for relay entries; parse notes instead. + let relayAuth = ""; + if (dbProxyNotes) { + try { + const parsed = JSON.parse(dbProxyNotes) as { relayAuth?: string }; + relayAuth = parsed.relayAuth ?? ""; + } catch {} + } + // Fallback: ad-hoc callers may pass relayAuth in the password field + if (!relayAuth) relayAuth = proxy.password ?? ""; + const relayUrl = `https://${relayHost}`; + const start = Date.now(); + const controller2 = new AbortController(); + const timeout2 = setTimeout(() => controller2.abort(), 10000); + try { + // Send request to the relay URL with relay headers; relay forwards to ipify + const res = await undiciRequest(`${relayUrl}/`, { + method: "GET", + signal: controller2.signal, + headersTimeout: 10000, + bodyTimeout: 10000, + headers: { + "x-relay-target": "https://api64.ipify.org", + "x-relay-path": "/?format=json", + "x-relay-auth": relayAuth, + }, + }); + const text = await res.body.text(); + let parsedIp: { ip?: string } = {}; + try { parsedIp = JSON.parse(text) as { ip?: string }; } catch {} + return Response.json({ + success: res.statusCode === 200, + publicIp: parsedIp.ip || null, + latencyMs: Date.now() - start, + proxyUrl: relayUrl, + }); + } catch (relayErr) { + return Response.json({ + success: false, + error: + relayErr instanceof Error && relayErr.name === "AbortError" + ? "Connection timeout (10s)" + : getErrorMessage(relayErr, "Relay test failed"), + latencyMs: Date.now() - start, + proxyUrl: relayUrl, + }); + } finally { + clearTimeout(timeout2); + } + } + if (proxyType === "socks5" && !isSocks5ProxyEnabled()) { return createErrorResponse({ status: 400, diff --git a/src/app/api/settings/proxy/vercel-deploy/route.ts b/src/app/api/settings/proxy/vercel-deploy/route.ts new file mode 100644 index 000000000000..9c2bbd17d7a3 --- /dev/null +++ b/src/app/api/settings/proxy/vercel-deploy/route.ts @@ -0,0 +1,174 @@ +import { randomBytes } from "crypto"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse"; +import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; +import { vercelDeploySchema } from "@/shared/validation/freeProxySchemas"; +import { createProxy } from "@/lib/localDb"; + +const VERCEL_API_BASE = process.env.VERCEL_API_BASE || "https://api.vercel.com"; +const POLL_INTERVAL_MS = 3000; +const POLL_MAX_ATTEMPTS = 40; // ~2 min + +function buildRelayFunction(relayAuth: string): string { + // relayAuth is a random hex string generated server-side — no user input + return `export const config = { runtime: "edge" }; +export default async function handler(req) { + const auth = req.headers.get("x-relay-auth"); + if (auth !== "${relayAuth}") return new Response("Unauthorized", { status: 401 }); + const target = req.headers.get("x-relay-target"); + if (!target) return new Response("missing x-relay-target", { status: 400 }); + const relayPath = req.headers.get("x-relay-path") || "/"; + const headers = new Headers(req.headers); + ["x-relay-target", "x-relay-path", "x-relay-auth", "host"].forEach(h => headers.delete(h)); + const upstream = await fetch(target.replace(/\\/$/, "") + relayPath, { + method: req.method, + headers, + body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, + duplex: "half", + }); + return new Response(upstream.body, { status: upstream.status, headers: upstream.headers }); +}`; +} + +async function pollDeployment( + deploymentApiUrl: string, + token: string +): Promise<"READY" | "ERROR"> { + for (let i = 0; i < POLL_MAX_ATTEMPTS; i++) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); + try { + const res = await fetch(deploymentApiUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!res.ok) continue; + const data = (await res.json()) as { readyState?: string }; + if (data.readyState === "READY") return "READY"; + if (data.readyState === "ERROR") return "ERROR"; + } catch {} + } + return "ERROR"; +} + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown = {}; + try { + rawBody = await request.json(); + } catch { + return createErrorResponse({ + status: 400, + message: "Invalid JSON body", + type: "invalid_request", + }); + } + + const validation = validateBody(vercelDeploySchema, rawBody); + if (isValidationFailure(validation)) { + return createErrorResponse({ + status: 400, + message: validation.error.message, + type: "invalid_request", + }); + } + + const { token, projectName } = validation.data; + // Generate random auth secret for the relay — stored in proxy notes, never returned to client + const relayAuth = randomBytes(24).toString("hex"); + const relayCode = buildRelayFunction(relayAuth); + + try { + const deployRes = await fetch(`${VERCEL_API_BASE}/v13/deployments`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: projectName, + files: [ + { file: "api/relay.js", data: relayCode }, + { + file: "package.json", + data: JSON.stringify({ name: projectName, version: "1.0.0" }), + }, + { + file: "vercel.json", + data: JSON.stringify({ + rewrites: [{ source: "/(.*)", destination: "/api/relay" }], + }), + }, + ], + projectSettings: { framework: null }, + target: "production", + }), + }); + + if (!deployRes.ok) { + const errText = await deployRes.text().catch(() => ""); + return createErrorResponse({ + status: deployRes.status, + message: `Vercel deployment failed: ${errText.slice(0, 200)}`, + type: "upstream_error", + }); + } + + const deployment = (await deployRes.json()) as { + id?: string; + url?: string; + projectId?: string; + }; + + if (!deployment.url) { + return createErrorResponse({ + status: 502, + message: "Vercel returned no deployment URL", + type: "upstream_error", + }); + } + + // Disable Vercel SSO protection so the relay is publicly accessible + if (deployment.projectId) { + await fetch(`${VERCEL_API_BASE}/v9/projects/${deployment.projectId}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ ssoProtection: null }), + }).catch(() => {}); + } + + // Poll until READY + const deploymentApiUrl = `${VERCEL_API_BASE}/v13/deployments/${deployment.id}`; + const readyState = await pollDeployment(deploymentApiUrl, token); + + if (readyState !== "READY") { + return createErrorResponse({ + status: 504, + message: + "Deployment did not reach READY state within 2 minutes. Check your Vercel dashboard.", + type: "timeout", + }); + } + + // Store as proxy pool entry — token is NOT stored; relayAuth is stored in notes (JSON) + const poolProxy = await createProxy({ + name: `Vercel Relay (${projectName})`, + type: "vercel", + host: deployment.url, + port: 443, + notes: JSON.stringify({ relayAuth }), + source: "vercel-relay", + }); + + return Response.json({ + success: true, + relayUrl: `https://${deployment.url}`, + poolProxyId: poolProxy?.id, + }); + } catch (error) { + return createErrorResponseFromUnknown(error, "Vercel deploy failed"); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 93ba8e29fdf3..d5cc0caf72f5 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4216,6 +4216,25 @@ "noGlobalProxy": "No global proxy configured", "globalLabel": "Global", "configure": "Configure", + "proxyGlobalConfigTab": "Global Config", + "proxyPoolTab": "Proxy Pool", + "freePoolTab": "Free Pool", + "proxyDocumentationTab": "Documentation", + "freePoolSyncAll": "Sync All", + "freePoolAddSelected": "Add selected to pool", + "freePoolAddAllVisible": "Add all visible to pool", + "freePoolInPool": "In pool", + "freePoolAddToPool": "Add to pool", + "freePoolTestingAndAdding": "Testing and adding...", + "vercelRelayButton": "Vercel Relay", + "vercelRelayModalTitle": "Deploy Vercel Edge Relay", + "vercelRelayTokenLabel": "Vercel Token", + "vercelRelayProjectNameLabel": "Project Name", + "vercelRelayDeploy": "Deploy", + "vercelRelayDeploying": "Deploying...", + "vercelRelaySuccess": "Relay deployed and added to pool", + "vercelRelayWarning": "The relay URL is publicly accessible. The x-relay-auth secret header protects it — keep your Vercel project URL private.", + "vercelRelayFreeTierNote": "Vercel free tier limits apply (bandwidth/executions). Relay is best-effort.", "globalSystemPrompt": "Global System Prompt", "systemPromptDesc": "Injected into all requests at proxy level", "saved": "Saved", diff --git a/src/lib/db/freeProxies.ts b/src/lib/db/freeProxies.ts new file mode 100644 index 000000000000..026e632dcdd7 --- /dev/null +++ b/src/lib/db/freeProxies.ts @@ -0,0 +1,242 @@ +import { randomUUID } from "crypto"; +import { getDbInstance } from "./core"; +import { backupDbFile } from "./backup"; +import type { FreeProxyItem, FreeProxySourceId } from "@/lib/freeProxyProviders/types"; + +export interface FreeProxyRecord { + id: string; + source: FreeProxySourceId; + host: string; + port: number; + type: string; + countryCode: string | null; + qualityScore: number | null; + latencyMs: number | null; + anonymity: string | null; + lastValidated: string | null; + inPool: boolean; + poolProxyId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface FreeProxyStats { + total: number; + inPool: number; + avgQuality: number | null; + bySource: Array<{ source: string; count: number }>; + lastSyncAt: string | null; +} + +type DbRow = Record; + +function mapRow(row: unknown): FreeProxyRecord { + const r = row as DbRow; + return { + id: String(r.id ?? ""), + source: String(r.source ?? "1proxy") as FreeProxySourceId, + host: String(r.host ?? ""), + port: Number(r.port) || 0, + type: String(r.type ?? "http"), + countryCode: r.country_code != null ? String(r.country_code) : null, + qualityScore: r.quality_score != null ? Number(r.quality_score) : null, + latencyMs: r.latency_ms != null ? Number(r.latency_ms) : null, + anonymity: r.anonymity != null ? String(r.anonymity) : null, + lastValidated: r.last_validated != null ? String(r.last_validated) : null, + inPool: r.in_pool === 1 || r.in_pool === true, + poolProxyId: r.pool_proxy_id != null ? String(r.pool_proxy_id) : null, + createdAt: String(r.created_at ?? ""), + updatedAt: String(r.updated_at ?? ""), + }; +} + +export async function upsertFreeProxy( + item: FreeProxyItem +): Promise<{ id: string; action: "created" | "updated" }> { + const db = getDbInstance(); + const now = new Date().toISOString(); + + const existing = db + .prepare("SELECT id FROM free_proxies WHERE source = ? AND host = ? AND port = ?") + .get(item.source, item.host, item.port) as { id?: string } | undefined; + + if (existing?.id) { + db.prepare( + `UPDATE free_proxies + SET type = ?, country_code = ?, quality_score = ?, latency_ms = ?, + anonymity = ?, last_validated = ?, updated_at = ? + WHERE id = ?` + ).run( + item.type, + item.countryCode ?? null, + item.qualityScore ?? null, + item.latencyMs ?? null, + item.anonymity ?? null, + item.lastValidated ?? now, + now, + existing.id + ); + return { id: existing.id, action: "updated" }; + } + + const id = randomUUID(); + db.prepare( + `INSERT INTO free_proxies + (id, source, host, port, type, country_code, quality_score, latency_ms, + anonymity, last_validated, in_pool, pool_proxy_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?)` + ).run( + id, + item.source, + item.host, + item.port, + item.type, + item.countryCode ?? null, + item.qualityScore ?? null, + item.latencyMs ?? null, + item.anonymity ?? null, + item.lastValidated ?? now, + now, + now + ); + return { id, action: "created" }; +} + +export async function listFreeProxies(options?: { + sources?: FreeProxySourceId[]; + protocol?: string; + country?: string; + minQuality?: number; + onlyInPool?: boolean; + onlyNotInPool?: boolean; + limit?: number; + offset?: number; +}): Promise { + const db = getDbInstance(); + const params: unknown[] = []; + let sql = "SELECT * FROM free_proxies WHERE 1=1"; + + if (options?.sources?.length) { + sql += ` AND source IN (${options.sources.map(() => "?").join(",")})`; + params.push(...options.sources); + } + if (options?.protocol) { + sql += " AND type = ?"; + params.push(options.protocol); + } + if (options?.country) { + sql += " AND country_code = ?"; + params.push(options.country.toUpperCase()); + } + if (options?.minQuality != null) { + sql += " AND quality_score >= ?"; + params.push(options.minQuality); + } + if (options?.onlyInPool) { + sql += " AND in_pool = 1"; + } + if (options?.onlyNotInPool) { + sql += " AND in_pool = 0"; + } + + sql += " ORDER BY quality_score DESC, last_validated DESC"; + + if (options?.limit) { + sql += " LIMIT ?"; + params.push(options.limit); + if (options?.offset) { + sql += " OFFSET ?"; + params.push(options.offset); + } + } + + const rows = db.prepare(sql).all(...params); + return rows.map(mapRow); +} + +export async function listFreeProxiesBySource( + source: FreeProxySourceId, + filters: { + protocol?: string; + country?: string; + minQuality?: number; + limit?: number; + } +): Promise { + const records = await listFreeProxies({ + sources: [source], + protocol: filters.protocol, + country: filters.country, + minQuality: filters.minQuality, + limit: filters.limit, + }); + return records.map((r) => ({ + source: r.source, + host: r.host, + port: r.port, + type: r.type as FreeProxyItem["type"], + countryCode: r.countryCode, + qualityScore: r.qualityScore, + latencyMs: r.latencyMs, + anonymity: r.anonymity, + lastValidated: r.lastValidated, + })); +} + +export async function getFreeProxyById(id: string): Promise { + const db = getDbInstance(); + const row = db.prepare("SELECT * FROM free_proxies WHERE id = ?").get(id); + return row ? mapRow(row) : null; +} + +export async function markFreeProxyInPool(id: string, poolProxyId: string): Promise { + const db = getDbInstance(); + const now = new Date().toISOString(); + db.prepare( + "UPDATE free_proxies SET in_pool = 1, pool_proxy_id = ?, updated_at = ? WHERE id = ?" + ).run(poolProxyId, now, id); + backupDbFile("pre-write"); +} + +export async function deleteFreeProxy(id: string): Promise { + const db = getDbInstance(); + const result = db.prepare("DELETE FROM free_proxies WHERE id = ?").run(id); + backupDbFile("pre-write"); + return result.changes > 0; +} + +export async function clearFreeProxiesBySource(source: FreeProxySourceId): Promise { + const db = getDbInstance(); + const result = db + .prepare("DELETE FROM free_proxies WHERE source = ? AND in_pool = 0") + .run(source); + backupDbFile("pre-write"); + return result.changes; +} + +export async function getFreeProxyStats(): Promise { + const db = getDbInstance(); + const totals = db + .prepare( + `SELECT COUNT(*) as total, + SUM(CASE WHEN in_pool = 1 THEN 1 ELSE 0 END) as in_pool_count, + AVG(quality_score) as avg_quality, + MAX(last_validated) as last_sync_at + FROM free_proxies` + ) + .get() as DbRow; + + const bySource = db + .prepare( + "SELECT source, COUNT(*) as count FROM free_proxies GROUP BY source ORDER BY count DESC" + ) + .all() as DbRow[]; + + return { + total: Number(totals.total) || 0, + inPool: Number(totals.in_pool_count) || 0, + avgQuality: totals.avg_quality != null ? Math.round(Number(totals.avg_quality)) : null, + bySource: bySource.map((r) => ({ source: String(r.source), count: Number(r.count) })), + lastSyncAt: totals.last_sync_at != null ? String(totals.last_sync_at) : null, + }; +} diff --git a/src/lib/db/migrations/068_free_proxies.sql b/src/lib/db/migrations/068_free_proxies.sql new file mode 100644 index 000000000000..9dbdbaadabac --- /dev/null +++ b/src/lib/db/migrations/068_free_proxies.sql @@ -0,0 +1,52 @@ +-- 068_free_proxies.sql +-- Cria tabela de staging/catálogo para proxies gratuitos de múltiplas fontes. +-- Os proxies "promovidos" ao pool real ficam em proxy_registry; free_proxies +-- só rastreia o catálogo + quais foram promovidos (in_pool=1, pool_proxy_id=FK). + +CREATE TABLE IF NOT EXISTS free_proxies ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, -- '1proxy' | 'proxifly' | 'iplocate' + host TEXT NOT NULL, + port INTEGER NOT NULL, + type TEXT NOT NULL DEFAULT 'http', + country_code TEXT, + quality_score INTEGER, + latency_ms INTEGER, + anonymity TEXT, + last_validated TEXT, + in_pool INTEGER DEFAULT 0, -- 0 = not in registry, 1 = promoted to proxy_registry + pool_proxy_id TEXT, -- FK para proxy_registry.id se in_pool=1 + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(source, host, port) +); + +CREATE INDEX IF NOT EXISTS idx_free_proxies_source ON free_proxies(source); +CREATE INDEX IF NOT EXISTS idx_free_proxies_quality ON free_proxies(quality_score DESC); +CREATE INDEX IF NOT EXISTS idx_free_proxies_in_pool ON free_proxies(in_pool); + +-- Migrar dados existentes do proxy_registry (source='oneproxy') para free_proxies. +-- O proxy permanece em proxy_registry (não o removemos), mas o free_proxies o +-- referencia como já "in_pool". +INSERT OR IGNORE INTO free_proxies ( + id, source, host, port, type, country_code, + quality_score, latency_ms, anonymity, last_validated, + in_pool, pool_proxy_id, created_at, updated_at +) +SELECT + id, + '1proxy', + host, + port, + type, + country_code, + quality_score, + latency_ms, + anonymity, + last_validated, + 1, -- já está no registry + id, -- pool_proxy_id = próprio id (mesmo registro) + created_at, + updated_at +FROM proxy_registry +WHERE source = 'oneproxy'; diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 2e45779488ca..527f4c80b467 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -1,3 +1,5 @@ +// Convention: when type === "vercel", the `notes` column stores JSON { relayAuth: "" } +// used by proxyFetch.ts to route requests through the Vercel edge relay instead of an undici ProxyAgent. import { randomUUID } from "crypto"; import { getDbInstance } from "./core"; import { backupDbFile } from "./backup"; @@ -113,8 +115,20 @@ function mapAssignmentRow(row: unknown): ProxyAssignmentRecord { }; } +function extractRelayAuth(notes: unknown): string | undefined { + if (typeof notes !== "string") return undefined; + try { + const parsed = JSON.parse(notes) as { relayAuth?: string }; + return parsed.relayAuth || undefined; + } catch { + return undefined; + } +} + function toRegistryProxyResolution(row: unknown, level: ProxyScope, levelId: string | null) { const record = toRecord(row); + const relayAuth = + record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; return { proxy: { type: record.type, @@ -122,6 +136,7 @@ function toRegistryProxyResolution(row: unknown, level: ProxyScope, levelId: str port: record.port, username: record.username, password: record.password, + ...(relayAuth !== undefined ? { relayAuth } : {}), }, level, levelId, @@ -651,11 +666,13 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string const accountAssignment = db .prepare( - "SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'account' AND a.scope_id = ? LIMIT 1" + "SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'account' AND a.scope_id = ? LIMIT 1" ) .get(connectionId); if (accountAssignment) { const record = toRecord(accountAssignment); + const relayAuth = + record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; return { proxy: { type: record.type, @@ -663,6 +680,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string port: record.port, username: record.username, password: record.password, + ...(relayAuth !== undefined ? { relayAuth } : {}), }, level: "account", levelId: connectionId, @@ -677,11 +695,13 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string if (connection?.provider) { const providerAssignment = db .prepare( - "SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? LIMIT 1" + "SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? LIMIT 1" ) .get(connection.provider); if (providerAssignment) { const record = toRecord(providerAssignment); + const relayAuth = + record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; return { proxy: { type: record.type, @@ -689,6 +709,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string port: record.port, username: record.username, password: record.password, + ...(relayAuth !== undefined ? { relayAuth } : {}), }, level: "provider", levelId: connection.provider, @@ -699,11 +720,13 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string const globalAssignment = db .prepare( - "SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1" + "SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1" ) .get(); if (globalAssignment) { const record = toRecord(globalAssignment); + const relayAuth = + record.type === "vercel" ? extractRelayAuth(record.notes) : undefined; return { proxy: { type: record.type, @@ -711,6 +734,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string port: record.port, username: record.username, password: record.password, + ...(relayAuth !== undefined ? { relayAuth } : {}), }, level: "global", levelId: null, @@ -734,7 +758,7 @@ export async function resolveProxyForScopeFromRegistry(scope: string, scopeId?: if (normalizedScope === "global") { const globalAssignment = db .prepare( - "SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1" + "SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' LIMIT 1" ) .get(); return globalAssignment ? toRegistryProxyResolution(globalAssignment, "global", null) : null; @@ -745,7 +769,7 @@ export async function resolveProxyForScopeFromRegistry(scope: string, scopeId?: const assignment = db .prepare( - "SELECT p.id, p.type, p.host, p.port, p.username, p.password FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = ? AND a.scope_id = ? LIMIT 1" + "SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = ? AND a.scope_id = ? LIMIT 1" ) .get(normalizedScope, normalizedScopeId); diff --git a/src/lib/freeProxyProviders/index.ts b/src/lib/freeProxyProviders/index.ts new file mode 100644 index 000000000000..8a29d71eb03e --- /dev/null +++ b/src/lib/freeProxyProviders/index.ts @@ -0,0 +1,22 @@ +import type { FreeProxyProvider, FreeProxySourceId } from "./types"; +import { OneproxyProvider } from "./oneproxy"; +import { ProxiflyProvider } from "./proxifly"; +import { IplocateProvider } from "./iplocate"; + +const ALL_PROVIDERS: FreeProxyProvider[] = [ + new OneproxyProvider(), + new ProxiflyProvider(), + new IplocateProvider(), +]; + +export function getProvider(id: FreeProxySourceId): FreeProxyProvider | undefined { + return ALL_PROVIDERS.find((p) => p.id === id); +} + +export function getEnabledProviders(): FreeProxyProvider[] { + return ALL_PROVIDERS.filter((p) => p.isEnabled()); +} + +export function getAllProviders(): FreeProxyProvider[] { + return ALL_PROVIDERS; +} diff --git a/src/lib/freeProxyProviders/iplocate.ts b/src/lib/freeProxyProviders/iplocate.ts new file mode 100644 index 000000000000..df65bb24d927 --- /dev/null +++ b/src/lib/freeProxyProviders/iplocate.ts @@ -0,0 +1,103 @@ +import type { FreeProxyItem, FreeProxySyncResult, FreeProxyProvider } from "./types"; + +const BASE_URL = + "https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols"; +const PROTOCOLS = ["http", "https", "socks4", "socks5"] as const; + +// In-module cache to respect GitHub raw rate limits +let lastFetchAt = 0; +const CACHE_TTL_MS = 30 * 60 * 1000; // 30 min + +type IplocateProxy = { + ip: string; + port: number; + country: string; +}; + +export class IplocateProvider implements FreeProxyProvider { + readonly id = "iplocate" as const; + readonly name = "IPLocate"; + + isEnabled(): boolean { + return process.env.FREE_PROXY_IPLOCATE_ENABLED === "true"; + } + + async sync(): Promise { + if (!this.isEnabled()) { + return { + fetched: 0, + added: 0, + updated: 0, + errors: ["IPLocate provider disabled (opt-in via FREE_PROXY_IPLOCATE_ENABLED=true)"], + }; + } + + const now = Date.now(); + if (now - lastFetchAt < CACHE_TTL_MS) { + return { fetched: 0, added: 0, updated: 0, errors: ["IPLocate: cache fresh, skipping sync"] }; + } + + const { upsertFreeProxy } = await import("../db/freeProxies"); + const baseUrl = process.env.FREE_PROXY_IPLOCATE_BASE_URL || BASE_URL; + const errors: string[] = []; + let added = 0; + let updated = 0; + let fetched = 0; + + for (const proto of PROTOCOLS) { + try { + const url = `${baseUrl}/${proto}.json`; + const res = await fetch(url, { + signal: AbortSignal.timeout(15000), + headers: + lastFetchAt > 0 + ? { "If-Modified-Since": new Date(lastFetchAt).toUTCString() } + : {}, + }); + + if (res.status === 304) continue; + if (!res.ok) { + errors.push(`${proto}: HTTP ${res.status}`); + continue; + } + + const data = (await res.json()) as IplocateProxy[]; + if (!Array.isArray(data)) continue; + + for (const p of data) { + if (!p.ip || !p.port) continue; + const item: FreeProxyItem = { + source: "iplocate", + host: p.ip, + port: Number(p.port), + type: proto, + countryCode: p.country?.slice(0, 2).toUpperCase() || null, + qualityScore: null, + latencyMs: null, + anonymity: null, + lastValidated: new Date().toISOString(), + }; + const r = await upsertFreeProxy(item); + if (r.action === "created") added++; + else updated++; + fetched++; + } + } catch (err) { + errors.push(`${proto}: ${err instanceof Error ? err.message : String(err)}`); + } + } + + lastFetchAt = Date.now(); + return { fetched, added, updated, errors }; + } + + async list(filters: { + protocol?: string; + country?: string; + minQuality?: number; + limit?: number; + }): Promise { + const { listFreeProxiesBySource } = await import("../db/freeProxies"); + return listFreeProxiesBySource("iplocate", filters); + } +} diff --git a/src/lib/freeProxyProviders/oneproxy.ts b/src/lib/freeProxyProviders/oneproxy.ts new file mode 100644 index 000000000000..b870a569cff8 --- /dev/null +++ b/src/lib/freeProxyProviders/oneproxy.ts @@ -0,0 +1,122 @@ +import type { FreeProxyItem, FreeProxySyncResult, FreeProxyProvider } from "./types"; + +const DEFAULT_API_URL = "https://1proxy-api.aitradepulse.com/api/v1/proxies/advanced"; +const DEFAULT_MAX = 500; +const DEFAULT_MIN_QUALITY = 50; +const DEFAULT_PAGE_SIZE = 100; +const MAX_CONSECUTIVE_FAILURES = 5; + +type OneProxyApiProxy = { + ip: string; + port: number; + protocol: string; + country_code: string; + latency_ms: number; + anonymity: string; + quality_score: number; + last_validated: string; +}; + +type OneProxyApiResponse = { + total: number; + count: number; + offset: number; + proxies: OneProxyApiProxy[]; +}; + +export class OneproxyProvider implements FreeProxyProvider { + readonly id = "1proxy" as const; + readonly name = "1proxy"; + + private consecutiveFailures = 0; + + isEnabled(): boolean { + return process.env.FREE_PROXY_1PROXY_ENABLED !== "false"; + } + + private getConfig() { + return { + apiUrl: process.env.FREE_PROXY_1PROXY_API_URL || DEFAULT_API_URL, + maxProxies: parseInt(process.env.FREE_PROXY_1PROXY_MAX || "", 10) || DEFAULT_MAX, + minQuality: + parseInt(process.env.FREE_PROXY_1PROXY_MIN_QUALITY || "", 10) || DEFAULT_MIN_QUALITY, + }; + } + + async sync(): Promise { + if (!this.isEnabled()) { + return { fetched: 0, added: 0, updated: 0, errors: ["1proxy provider disabled"] }; + } + if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + return { + fetched: 0, + added: 0, + updated: 0, + errors: [`Circuit breaker open: ${this.consecutiveFailures} consecutive failures`], + }; + } + + const { upsertFreeProxy } = await import("../db/freeProxies"); + const { apiUrl, maxProxies, minQuality } = this.getConfig(); + const errors: string[] = []; + let added = 0; + let updated = 0; + let fetched = 0; + let offset = 0; + + try { + while (fetched < maxProxies) { + const limit = Math.min(DEFAULT_PAGE_SIZE, maxProxies - fetched); + const url = `${apiUrl}?offset=${offset}&limit=${limit}&min_quality_score=${minQuality}`; + const res = await fetch(url, { signal: AbortSignal.timeout(15000) }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + errors.push(`HTTP ${res.status}: ${text.slice(0, 100)}`); + this.consecutiveFailures++; + break; + } + + const json = (await res.json()) as OneProxyApiResponse; + if (!Array.isArray(json.proxies) || json.proxies.length === 0) break; + + for (const p of json.proxies) { + const item: FreeProxyItem = { + source: "1proxy", + host: p.ip, + port: p.port, + type: (p.protocol?.toLowerCase() as FreeProxyItem["type"]) || "http", + countryCode: p.country_code || null, + qualityScore: p.quality_score ?? null, + latencyMs: p.latency_ms ?? null, + anonymity: p.anonymity || null, + lastValidated: p.last_validated || new Date().toISOString(), + }; + const result = await upsertFreeProxy(item); + if (result.action === "created") added++; + else updated++; + } + + fetched += json.proxies.length; + offset += json.proxies.length; + if (json.proxies.length < limit) break; + } + this.consecutiveFailures = 0; + } catch (err) { + this.consecutiveFailures++; + errors.push(err instanceof Error ? err.message : String(err)); + } + + return { fetched, added, updated, errors }; + } + + async list(filters: { + protocol?: string; + country?: string; + minQuality?: number; + limit?: number; + }): Promise { + const { listFreeProxiesBySource } = await import("../db/freeProxies"); + return listFreeProxiesBySource("1proxy", filters); + } +} diff --git a/src/lib/freeProxyProviders/proxifly.ts b/src/lib/freeProxyProviders/proxifly.ts new file mode 100644 index 000000000000..df0f27e5149a --- /dev/null +++ b/src/lib/freeProxyProviders/proxifly.ts @@ -0,0 +1,84 @@ +import type { FreeProxyItem, FreeProxySyncResult, FreeProxyProvider } from "./types"; + +const DEFAULT_QUANTITY = 100; +const DEFAULT_ANONYMITY = "elite"; + +type ProxiflyProxy = { + ip: string; + port: number; + protocol: string; + country: string; + anonymity: string; + speed: number; +}; + +export class ProxiflyProvider implements FreeProxyProvider { + readonly id = "proxifly" as const; + readonly name = "Proxifly"; + + isEnabled(): boolean { + return process.env.FREE_PROXY_PROXIFLY_ENABLED !== "false"; + } + + async sync(): Promise { + if (!this.isEnabled()) { + return { fetched: 0, added: 0, updated: 0, errors: ["Proxifly provider disabled"] }; + } + + const { upsertFreeProxy } = await import("../db/freeProxies"); + const quantity = + parseInt(process.env.FREE_PROXY_PROXIFLY_QUANTITY || "", 10) || DEFAULT_QUANTITY; + const anonymity = process.env.FREE_PROXY_PROXIFLY_ANONYMITY || DEFAULT_ANONYMITY; + + const errors: string[] = []; + let added = 0; + let updated = 0; + let fetched = 0; + + try { + const proxiflyModule = await import("proxifly"); + const proxifly = proxiflyModule.default ?? proxiflyModule; + const result = await (proxifly as { getProxy: (opts: unknown) => Promise }).getProxy({ + protocol: "http", + anonymity: anonymity as "elite" | "anonymous" | "transparent", + speed: "fast", + quantity, + }); + + const proxies: ProxiflyProxy[] = Array.isArray(result) ? result : [result as ProxiflyProxy]; + + for (const p of proxies) { + if (!p.ip || !p.port) continue; + const item: FreeProxyItem = { + source: "proxifly", + host: p.ip, + port: Number(p.port), + type: ((p.protocol || "http").toLowerCase() as FreeProxyItem["type"]), + countryCode: p.country?.slice(0, 2).toUpperCase() || null, + qualityScore: p.speed != null ? Math.min(100, Math.max(0, Math.round(p.speed))) : null, + latencyMs: null, + anonymity: p.anonymity || null, + lastValidated: new Date().toISOString(), + }; + const r = await upsertFreeProxy(item); + if (r.action === "created") added++; + else updated++; + fetched++; + } + } catch (err) { + errors.push(err instanceof Error ? err.message : String(err)); + } + + return { fetched, added, updated, errors }; + } + + async list(filters: { + protocol?: string; + country?: string; + minQuality?: number; + limit?: number; + }): Promise { + const { listFreeProxiesBySource } = await import("../db/freeProxies"); + return listFreeProxiesBySource("proxifly", filters); + } +} diff --git a/src/lib/freeProxyProviders/types.ts b/src/lib/freeProxyProviders/types.ts new file mode 100644 index 000000000000..81bcf86bacfa --- /dev/null +++ b/src/lib/freeProxyProviders/types.ts @@ -0,0 +1,33 @@ +export type FreeProxySourceId = "1proxy" | "proxifly" | "iplocate"; + +export interface FreeProxyItem { + source: FreeProxySourceId; + host: string; + port: number; + type: "http" | "https" | "socks4" | "socks5"; + countryCode: string | null; + qualityScore: number | null; // 0-100, normalizado entre fontes + latencyMs: number | null; + anonymity: string | null; // 'elite' | 'anonymous' | 'transparent' + lastValidated: string | null; // ISO timestamp +} + +export interface FreeProxySyncResult { + fetched: number; + added: number; + updated: number; + errors: string[]; +} + +export interface FreeProxyProvider { + readonly id: FreeProxySourceId; + readonly name: string; + isEnabled(): boolean; + sync(): Promise; + list(filters: { + protocol?: string; + country?: string; + minQuality?: number; + limit?: number; + }): Promise; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 5dab78d70ff7..3edfaf573c50 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -489,3 +489,16 @@ export type { CreateRelayTokenInput, RelayTokenWithSecret, } from "./db/relayProxies"; + +export { + upsertFreeProxy, + listFreeProxies, + listFreeProxiesBySource, + getFreeProxyById, + markFreeProxyInPool, + deleteFreeProxy, + clearFreeProxiesBySource, + getFreeProxyStats, +} from "./db/freeProxies"; + +export type { FreeProxyRecord, FreeProxyStats } from "./db/freeProxies"; diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index 7443df90021c..d6f2bae94710 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -280,20 +280,6 @@ const PROXY_GROUP: SidebarItemGroup = { subtitleKey: "proxySubtitle", icon: "dns", }, - { - id: "mitm-proxy", - href: "/dashboard/system/mitm-proxy", - i18nKey: "mitmProxy", - subtitleKey: "mitmProxySubtitle", - icon: "lan", - }, - { - id: "1proxy", - href: "/dashboard/system/1proxy", - i18nKey: "oneProxy", - subtitleKey: "oneProxySubtitle", - icon: "public", - }, ], }; diff --git a/src/shared/validation/freeProxySchemas.ts b/src/shared/validation/freeProxySchemas.ts new file mode 100644 index 000000000000..6944b884f740 --- /dev/null +++ b/src/shared/validation/freeProxySchemas.ts @@ -0,0 +1,38 @@ +import { z } from "zod"; + +export const freeProxySourceSchema = z.enum(["1proxy", "proxifly", "iplocate"]); + +export const freeProxyListSchema = z.object({ + sources: z + .string() + .optional() + .transform((val) => (val ? val.split(",").filter(Boolean) : undefined)) + .pipe(z.array(freeProxySourceSchema).optional()), + protocol: z.enum(["http", "https", "socks4", "socks5"]).optional(), + country: z.string().max(2).optional().transform((v) => v?.toUpperCase()), + minQuality: z.coerce.number().int().min(0).max(100).optional(), + limit: z.coerce.number().int().min(1).max(1000).optional(), + offset: z.coerce.number().int().min(0).optional(), + onlyNotInPool: z + .string() + .optional() + .transform((v) => v === "1" || v === "true"), +}); + +export const freeProxySyncSchema = z.object({ + sources: z.array(freeProxySourceSchema).optional(), +}); + +export const freeProxyBulkAddSchema = z.object({ + ids: z.array(z.string().uuid()).min(1).max(100), +}); + +export const vercelDeploySchema = z.object({ + token: z.string().min(1), + projectName: z + .string() + .min(3) + .max(52) + .regex(/^[a-z0-9-]+$/, "Project name must be lowercase alphanumeric with hyphens") + .default("omniroute-relay"), +}); diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 86d039886e33..1ebadc459a33 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -1339,7 +1339,7 @@ const proxyRegistryFieldsSchema = z type: z .preprocess( (value) => (typeof value === "string" ? value.trim().toLowerCase() : value), - z.enum(["http", "https", "socks5"]) + z.enum(["http", "https", "socks5", "vercel"]) ) .optional() .default("http"), @@ -1350,7 +1350,7 @@ const proxyRegistryFieldsSchema = z region: z.string().trim().max(64).nullable().optional(), notes: z.string().trim().max(1000).nullable().optional(), status: z.enum(["active", "inactive"]).optional().default("active"), - source: z.enum(["manual", "oneproxy", "dashboard-custom"]).optional(), + source: z.enum(["manual", "oneproxy", "dashboard-custom", "vercel-relay"]).optional(), }) .strict(); diff --git a/tests/unit/free-pool-tab.test.tsx b/tests/unit/free-pool-tab.test.tsx new file mode 100644 index 000000000000..c410c640b75d --- /dev/null +++ b/tests/unit/free-pool-tab.test.tsx @@ -0,0 +1,220 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// Stub localStorage before importing the component +const lsStore: Record = {}; +const localStorageMock = { + getItem: (k: string) => lsStore[k] ?? null, + setItem: (k: string, v: string) => { lsStore[k] = v; }, + removeItem: (k: string) => { delete lsStore[k]; }, + clear: () => { for (const k in lsStore) delete lsStore[k]; }, +}; +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + writable: true, + configurable: true, +}); + +// ── Import component after mocks ───────────────────────────────────────────── + +const { default: FreePoolTab } = await import( + "../../src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab" +); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const defaultStats = { total: 0, inPool: 0, avgQuality: null, lastSyncAt: null }; + +function okJson(data: unknown) { + return Promise.resolve({ ok: true, json: () => Promise.resolve(data) } as Response); +} + +function setupFetch(items: unknown[] = [], stats = defaultStats) { + const mockFetch = vi.fn((url: string) => { + if (String(url).includes("/stats")) return okJson({ stats }); + return okJson({ items }); + }); + vi.stubGlobal("fetch", mockFetch); + return mockFetch; +} + +const containers: Array<{ root: ReturnType; el: HTMLDivElement }> = []; + +function renderTab() { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + act(() => { root.render(); }); + containers.push({ root, el }); + return el; +} + +async function waitForCondition(fn: () => boolean, timeoutMs = 2000) { + const start = Date.now(); + while (!fn()) { + if (Date.now() - start > timeoutMs) throw new Error("waitForCondition timed out"); + await new Promise((r) => setTimeout(r, 20)); + } +} + +// ── Lifecycle ───────────────────────────────────────────────────────────────── + +beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + setupFetch(); +}); + +afterEach(() => { + for (const { root, el } of containers.splice(0)) { + act(() => root.unmount()); + el.remove(); + } + vi.unstubAllGlobals(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("FreePoolTab source toggles", () => { + it("renders a toggle group with exactly 3 buttons", async () => { + const el = renderTab(); + await waitForCondition(() => el.querySelector("[role='group']") !== null); + const bar = el.querySelector("[role='group']")!; + expect(bar).toBeTruthy(); + const buttons = bar.querySelectorAll("button"); + expect(buttons.length).toBe(3); + }); + + it("all toggles start enabled (aria-pressed=true)", async () => { + const el = renderTab(); + await waitForCondition(() => el.querySelector("[role='group']") !== null); + const buttons = Array.from(el.querySelector("[role='group']")!.querySelectorAll("button")); + buttons.forEach((btn) => expect(btn.getAttribute("aria-pressed")).toBe("true")); + }); + + it("clicking a toggle disables it (aria-pressed=false)", async () => { + const el = renderTab(); + await waitForCondition(() => el.querySelector("[role='group']") !== null); + const bar = el.querySelector("[role='group']")!; + const first = bar.querySelectorAll("button")[0]; + act(() => { first.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + expect(first.getAttribute("aria-pressed")).toBe("false"); + }); + + it("clicking a disabled toggle re-enables it", async () => { + const el = renderTab(); + await waitForCondition(() => el.querySelector("[role='group']") !== null); + const bar = el.querySelector("[role='group']")!; + const first = bar.querySelectorAll("button")[0]; + act(() => { first.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + expect(first.getAttribute("aria-pressed")).toBe("false"); + act(() => { first.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + expect(first.getAttribute("aria-pressed")).toBe("true"); + }); + + it("multiple sources can be disabled independently", async () => { + const el = renderTab(); + await waitForCondition(() => el.querySelector("[role='group']") !== null); + const buttons = el.querySelector("[role='group']")!.querySelectorAll("button"); + act(() => { buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + act(() => { buttons[2].dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + expect(buttons[0].getAttribute("aria-pressed")).toBe("false"); + expect(buttons[1].getAttribute("aria-pressed")).toBe("true"); // second still enabled + expect(buttons[2].getAttribute("aria-pressed")).toBe("false"); + }); + + it("disabled source is persisted in localStorage", async () => { + const el = renderTab(); + await waitForCondition(() => el.querySelector("[role='group']") !== null); + const first = el.querySelector("[role='group']")!.querySelectorAll("button")[0]; + act(() => { first.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + const stored = JSON.parse(localStorageMock.getItem("freePool.disabledSources") ?? "[]"); + expect(Array.isArray(stored)).toBe(true); + expect(stored).toContain("1proxy"); + }); + + it("button labels are 1proxy, Proxifly, IPLocate", async () => { + const el = renderTab(); + await waitForCondition(() => el.querySelector("[role='group']") !== null); + const texts = Array.from(el.querySelector("[role='group']")!.querySelectorAll("button")).map( + (b) => b.textContent?.trim() + ); + expect(texts).toContain("1proxy"); + expect(texts).toContain("Proxifly"); + expect(texts).toContain("IPLocate"); + }); +}); + +describe("FreePoolTab data loading", () => { + it("shows 'No proxies found' message when list is empty", async () => { + const el = renderTab(); + await waitForCondition(() => el.textContent?.includes("No proxies found") === true); + expect(el.textContent).toMatch(/No proxies found/i); + }); + + it("calls /api/settings/free-proxies on mount", async () => { + const mockFetch = setupFetch(); + renderTab(); + await waitForCondition(() => + mockFetch.mock.calls.some(([url]) => String(url).includes("/free-proxies")) + ); + expect( + mockFetch.mock.calls.some(([url]) => String(url).includes("/free-proxies")) + ).toBe(true); + }); + + it("calls /api/settings/free-proxies/stats on mount", async () => { + const mockFetch = setupFetch(); + renderTab(); + await waitForCondition(() => + mockFetch.mock.calls.some(([url]) => String(url).includes("/stats")) + ); + expect(mockFetch.mock.calls.some(([url]) => String(url).includes("/stats"))).toBe(true); + }); + + it("disabling a source re-fetches with sources= filter", async () => { + const mockFetch = vi.fn((url: string) => { + if (String(url).includes("/stats")) return okJson({ stats: defaultStats }); + return okJson({ items: [] }); + }); + vi.stubGlobal("fetch", mockFetch); + + const el = renderTab(); + // Wait for initial load + await waitForCondition(() => + mockFetch.mock.calls.some(([url]) => String(url).includes("/free-proxies")) + ); + + const initialCallCount = mockFetch.mock.calls.length; + + const bar = el.querySelector("[role='group']")!; + const first = bar.querySelectorAll("button")[0]; // disable 1proxy + act(() => { first.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + + await waitForCondition(() => mockFetch.mock.calls.length > initialCallCount); + + const proxiesCalls = mockFetch.mock.calls + .slice(initialCallCount) + .map(([url]) => String(url)) + .filter((u) => u.includes("/free-proxies?") && !u.includes("/stats")); + + expect(proxiesCalls.length).toBeGreaterThan(0); + expect(proxiesCalls.some((u) => u.includes("sources="))).toBe(true); + }); + + it("displays stats when available", async () => { + setupFetch([], { total: 7, inPool: 2, avgQuality: null, lastSyncAt: null }); + const el = renderTab(); + await waitForCondition(() => el.textContent?.includes("Total: 7") === true); + expect(el.textContent).toMatch(/Total: 7/); + expect(el.textContent).toMatch(/In pool: 2/); + }); +}); diff --git a/tests/unit/free-proxies-add-to-pool.test.ts b/tests/unit/free-proxies-add-to-pool.test.ts new file mode 100644 index 000000000000..86a873923221 --- /dev/null +++ b/tests/unit/free-proxies-add-to-pool.test.ts @@ -0,0 +1,267 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-add-to-pool-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; + +process.env.DATA_DIR = TEST_DATA_DIR; +delete process.env.OMNIROUTE_API_KEY; + +const core = await import("../../src/lib/db/core.ts"); +const freeProxiesDb = await import("../../src/lib/db/freeProxies.ts"); +const addToPoolRoute = await import( + "../../src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts" +); +const bulkAddRoute = await import( + "../../src/app/api/settings/free-proxies/bulk-add-to-pool/route.ts" +); + +async function reset() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function makeReq(): Request { + return new Request("http://localhost/test", { method: "POST" }); +} + +function makeBulkReq(ids: string[]): Request { + return new Request("http://localhost/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }); +} + +test.beforeEach(async () => { + await reset(); + addToPoolRoute._resetConnectivityTesterForTests(); + bulkAddRoute._resetQuickTesterForTests(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +}); + +// ── /[id]/add-to-pool ──────────────────────────────────────────────────────── + +test("add-to-pool returns 404 for non-existent free proxy", async () => { + const res = await addToPoolRoute.POST(makeReq(), { + params: Promise.resolve({ id: randomUUID() }), + }); + assert.equal(res.status, 404); + const body = await res.json(); + assert.ok(body.error); +}); + +test("add-to-pool returns alreadyInPool:true when proxy is already in pool", async () => { + const { id } = await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", + host: "10.0.0.1", + port: 8080, + type: "http", + countryCode: null, + qualityScore: null, + latencyMs: null, + anonymity: null, + lastValidated: null, + }); + await freeProxiesDb.markFreeProxyInPool(id, "existing-pool-id"); + + const res = await addToPoolRoute.POST(makeReq(), { + params: Promise.resolve({ id }), + }); + const body = await res.json(); + assert.ok(body.success); + assert.ok(body.alreadyInPool); + assert.equal(body.poolProxyId, "existing-pool-id"); +}); + +test("add-to-pool returns success:false when connectivity test fails (unreachable proxy)", async () => { + const { id } = await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", + host: "127.0.0.1", + port: 1, + type: "http", + countryCode: null, + qualityScore: null, + latencyMs: null, + anonymity: null, + lastValidated: null, + }); + + const res = await addToPoolRoute.POST(makeReq(), { + params: Promise.resolve({ id }), + }); + const body = await res.json(); + assert.equal(body.success, false); + assert.ok(body.error); + // Proxy must NOT have been added to pool + const fp = await freeProxiesDb.getFreeProxyById(id); + assert.ok(!fp?.inPool); +}); + +// ── /bulk-add-to-pool ──────────────────────────────────────────────────────── + +test("bulk-add-to-pool returns 400 for invalid body (ids not array)", async () => { + const req = new Request("http://localhost/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: "not-an-array" }), + }); + const res = await bulkAddRoute.POST(req); + assert.equal(res.status, 400); +}); + +test("bulk-add-to-pool returns 400 for empty ids array", async () => { + const res = await bulkAddRoute.POST(makeBulkReq([])); + assert.equal(res.status, 400); +}); + +test("bulk-add-to-pool returns 400 for invalid JSON body", async () => { + const req = new Request("http://localhost/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "not-json", + }); + const res = await bulkAddRoute.POST(req); + assert.equal(res.status, 400); +}); + +test("bulk-add-to-pool: alreadyInPool proxies counted as succeeded", async () => { + const { id: id1 } = await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "10.0.0.1", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + const { id: id2 } = await freeProxiesDb.upsertFreeProxy({ + source: "proxifly", host: "10.0.0.2", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + await freeProxiesDb.markFreeProxyInPool(id1, "pool-1"); + await freeProxiesDb.markFreeProxyInPool(id2, "pool-2"); + + const res = await bulkAddRoute.POST(makeBulkReq([id1, id2])); + const body = await res.json(); + assert.equal(body.succeeded, 2); + assert.equal(body.failed, 0); + assert.equal(body.results.length, 2); + assert.ok(body.results.every((r: { success: boolean }) => r.success === true)); +}); + +test("bulk-add-to-pool: not-found ids counted as failed", async () => { + const res = await bulkAddRoute.POST( + makeBulkReq([randomUUID(), randomUUID()]) + ); + const body = await res.json(); + assert.equal(body.succeeded, 0); + assert.equal(body.failed, 2); + assert.equal(body.results.length, 2); + assert.ok( + body.results.every((r: { success: boolean; error?: string }) => r.success === false && r.error) + ); +}); + +test("bulk-add-to-pool: response shape with mix of already-in-pool and connectivity failures", async () => { + // 2 already in pool (succeed) + const { id: p1 } = await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "10.1.0.1", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + const { id: p2 } = await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "10.1.0.2", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + await freeProxiesDb.markFreeProxyInPool(p1, "pool-aaa"); + await freeProxiesDb.markFreeProxyInPool(p2, "pool-bbb"); + + // 3 unreachable proxies (fail connectivity) + const failProxies = await Promise.all( + [1, 2, 3].map((port) => + freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "127.0.0.1", port, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }) + ) + ); + + const ids = [p1, p2, ...failProxies.map((r) => r.id)]; + const res = await bulkAddRoute.POST(makeBulkReq(ids)); + const body = await res.json(); + + assert.equal(body.succeeded, 2); + assert.equal(body.failed, 3); + assert.equal(body.results.length, 5); + assert.ok(body.results.every((r: { id: string }) => typeof r.id === "string")); + + // Verify ids are preserved in results + for (const id of ids) { + assert.ok(body.results.some((r: { id: string }) => r.id === id)); + } +}); + +test("bulk-add-to-pool respects 100-item limit (Zod validation)", async () => { + const ids = Array.from({ length: 101 }, () => randomUUID()); + const res = await bulkAddRoute.POST(makeBulkReq(ids)); + assert.equal(res.status, 400); +}); + +// ── Happy path (injected connectivity stub) ────────────────────────────────── + +test("add-to-pool happy path: connectivity succeeds → proxy created → marked in pool", async () => { + const { id } = await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "10.5.0.1", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + + addToPoolRoute._setConnectivityTesterForTests(async () => ({ + success: true, latencyMs: 5, publicIp: "1.2.3.4", + })); + + const res = await addToPoolRoute.POST(makeReq(), { params: Promise.resolve({ id }) }); + const body = await res.json(); + + assert.equal(body.success, true); + assert.ok(typeof body.poolProxyId === "string", "response.poolProxyId should be a string"); + assert.ok(typeof body.latencyMs === "number", "response.latencyMs should be a number"); + + const fp = await freeProxiesDb.getFreeProxyById(id); + assert.ok(fp?.inPool, "free proxy should be marked as in pool"); + assert.equal(fp?.poolProxyId, body.poolProxyId, "poolProxyId should match created proxy id"); +}); + +test("bulk-add-to-pool happy path: connectivity succeeds → proxies created and marked in pool", async () => { + const p1 = await freeProxiesDb.upsertFreeProxy({ + source: "proxifly", host: "10.6.0.1", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + const p2 = await freeProxiesDb.upsertFreeProxy({ + source: "iplocate", host: "10.6.0.2", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + + bulkAddRoute._setQuickTesterForTests(async () => ({ ok: true, latencyMs: 3 })); + + const res = await bulkAddRoute.POST(makeBulkReq([p1.id, p2.id])); + const body = await res.json(); + + assert.equal(body.succeeded, 2); + assert.equal(body.failed, 0); + assert.equal(body.results.length, 2); + assert.ok(body.results.every((r: { success: boolean }) => r.success)); + + const fp1 = await freeProxiesDb.getFreeProxyById(p1.id); + const fp2 = await freeProxiesDb.getFreeProxyById(p2.id); + assert.ok(fp1?.inPool, "p1 should be in pool"); + assert.ok(fp2?.inPool, "p2 should be in pool"); +}); diff --git a/tests/unit/free-proxies-db.test.ts b/tests/unit/free-proxies-db.test.ts new file mode 100644 index 000000000000..e4d42322c0a9 --- /dev/null +++ b/tests/unit/free-proxies-db.test.ts @@ -0,0 +1,212 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-free-proxies-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const freeProxiesDb = await import("../../src/lib/db/freeProxies.ts"); + +async function reset() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("upsertFreeProxy creates a new record", async () => { + await reset(); + + const result = await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", + host: "1.2.3.4", + port: 8080, + type: "http", + countryCode: "US", + qualityScore: 75, + latencyMs: 200, + anonymity: "elite", + lastValidated: null, + }); + + assert.equal(result.action, "created"); + assert.ok(result.id); +}); + +test("upsertFreeProxy updates existing record on same source+host+port", async () => { + await reset(); + + await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", + host: "1.2.3.4", + port: 3128, + type: "http", + countryCode: "US", + qualityScore: 60, + latencyMs: 300, + anonymity: null, + lastValidated: null, + }); + + const result = await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", + host: "1.2.3.4", + port: 3128, + type: "https", + countryCode: "US", + qualityScore: 80, + latencyMs: 150, + anonymity: "elite", + lastValidated: null, + }); + + assert.equal(result.action, "updated"); + + const record = await freeProxiesDb.getFreeProxyById(result.id); + assert.equal(record?.qualityScore, 80); + assert.equal(record?.type, "https"); +}); + +test("listFreeProxies filters by source", async () => { + await reset(); + + await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "10.0.0.1", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + await freeProxiesDb.upsertFreeProxy({ + source: "proxifly", host: "10.0.0.2", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + + const results = await freeProxiesDb.listFreeProxies({ sources: ["1proxy"] }); + assert.equal(results.length, 1); + assert.equal(results[0].source, "1proxy"); +}); + +test("listFreeProxies filters by minQuality", async () => { + await reset(); + + await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "10.0.0.1", port: 8080, type: "http", + countryCode: null, qualityScore: 40, latencyMs: null, anonymity: null, lastValidated: null, + }); + await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "10.0.0.2", port: 8080, type: "http", + countryCode: null, qualityScore: 90, latencyMs: null, anonymity: null, lastValidated: null, + }); + + const results = await freeProxiesDb.listFreeProxies({ minQuality: 70 }); + assert.equal(results.length, 1); + assert.equal(results[0].qualityScore, 90); +}); + +test("listFreeProxies filters by onlyNotInPool", async () => { + await reset(); + + const r1 = await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "10.0.0.1", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "10.0.0.2", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + await freeProxiesDb.markFreeProxyInPool(r1.id, "pool-proxy-id-1"); + + const notInPool = await freeProxiesDb.listFreeProxies({ onlyNotInPool: true }); + assert.equal(notInPool.length, 1); + assert.equal(notInPool[0].host, "10.0.0.2"); +}); + +test("markFreeProxyInPool sets inPool=true and stores poolProxyId", async () => { + await reset(); + + const { id } = await freeProxiesDb.upsertFreeProxy({ + source: "iplocate", host: "5.6.7.8", port: 3128, type: "http", + countryCode: "DE", qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + + await freeProxiesDb.markFreeProxyInPool(id, "pool-abc-123"); + + const record = await freeProxiesDb.getFreeProxyById(id); + assert.ok(record?.inPool); + assert.equal(record?.poolProxyId, "pool-abc-123"); +}); + +test("deleteFreeProxy removes record and returns true", async () => { + await reset(); + + const { id } = await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "9.9.9.9", port: 9090, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + + const deleted = await freeProxiesDb.deleteFreeProxy(id); + assert.ok(deleted); + + const record = await freeProxiesDb.getFreeProxyById(id); + assert.equal(record, null); +}); + +test("deleteFreeProxy returns false for non-existent id", async () => { + await reset(); + + const deleted = await freeProxiesDb.deleteFreeProxy("00000000-0000-0000-0000-000000000000"); + assert.equal(deleted, false); +}); + +test("clearFreeProxiesBySource removes only not-in-pool entries for that source", async () => { + await reset(); + + const { id: inPoolId } = await freeProxiesDb.upsertFreeProxy({ + source: "proxifly", host: "11.0.0.1", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + await freeProxiesDb.upsertFreeProxy({ + source: "proxifly", host: "11.0.0.2", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "11.0.0.3", port: 8080, type: "http", + countryCode: null, qualityScore: null, latencyMs: null, anonymity: null, lastValidated: null, + }); + + await freeProxiesDb.markFreeProxyInPool(inPoolId, "pool-xyz"); + + const cleared = await freeProxiesDb.clearFreeProxiesBySource("proxifly"); + assert.equal(cleared, 1); + + const remaining = await freeProxiesDb.listFreeProxies(); + // The in-pool proxifly + the 1proxy remain + assert.equal(remaining.length, 2); + assert.ok(remaining.some((r) => r.inPool && r.source === "proxifly")); + assert.ok(remaining.some((r) => r.source === "1proxy")); +}); + +test("getFreeProxyStats returns correct totals", async () => { + await reset(); + + const { id: id1 } = await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "20.0.0.1", port: 8080, type: "http", + countryCode: "US", qualityScore: 60, latencyMs: null, anonymity: null, lastValidated: null, + }); + await freeProxiesDb.upsertFreeProxy({ + source: "iplocate", host: "20.0.0.2", port: 8080, type: "http", + countryCode: "BR", qualityScore: 80, latencyMs: null, anonymity: null, lastValidated: null, + }); + await freeProxiesDb.markFreeProxyInPool(id1, "pool-yyy"); + + const stats = await freeProxiesDb.getFreeProxyStats(); + assert.equal(stats.total, 2); + assert.equal(stats.inPool, 1); + assert.equal(stats.avgQuality, 70); + assert.equal(stats.bySource.length, 2); +}); diff --git a/tests/unit/free-proxy-providers.test.ts b/tests/unit/free-proxy-providers.test.ts new file mode 100644 index 000000000000..2045fd5d16c8 --- /dev/null +++ b/tests/unit/free-proxy-providers.test.ts @@ -0,0 +1,172 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-free-providers-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +// Providers read process.env at call-time, so we can set flags before import +process.env.FREE_PROXY_1PROXY_ENABLED = "true"; +process.env.FREE_PROXY_PROXIFLY_ENABLED = "false"; +process.env.FREE_PROXY_IPLOCATE_ENABLED = "false"; + +const core = await import("../../src/lib/db/core.ts"); +const { getProvider, getEnabledProviders, getAllProviders } = await import( + "../../src/lib/freeProxyProviders/index.ts" +); + +async function reset() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── Registry ───────────────────────────────────────────────────────────────── + +test("getAllProviders returns exactly 3 providers", () => { + const providers = getAllProviders(); + assert.equal(providers.length, 3); + const ids = providers.map((p) => p.id); + assert.ok(ids.includes("1proxy")); + assert.ok(ids.includes("proxifly")); + assert.ok(ids.includes("iplocate")); +}); + +test("getProvider returns the correct provider by id", () => { + const p = getProvider("1proxy"); + assert.ok(p); + assert.equal(p.id, "1proxy"); +}); + +test("getProvider returns undefined for unknown id", () => { + const p = getProvider("unknown" as any); + assert.equal(p, undefined); +}); + +test("getEnabledProviders respects env flags", () => { + // Only 1proxy is enabled in this test env + const enabled = getEnabledProviders(); + const ids = enabled.map((p) => p.id); + assert.ok(ids.includes("1proxy")); + assert.ok(!ids.includes("proxifly")); + assert.ok(!ids.includes("iplocate")); +}); + +// ── OneproxyProvider ────────────────────────────────────────────────────────── + +test("OneproxyProvider.isEnabled returns false when env is 'false'", () => { + const original = process.env.FREE_PROXY_1PROXY_ENABLED; + process.env.FREE_PROXY_1PROXY_ENABLED = "false"; + const p = getProvider("1proxy")!; + assert.equal(p.isEnabled(), false); + process.env.FREE_PROXY_1PROXY_ENABLED = original; +}); + +test("OneproxyProvider.sync returns disabled error when not enabled", async () => { + const original = process.env.FREE_PROXY_1PROXY_ENABLED; + process.env.FREE_PROXY_1PROXY_ENABLED = "false"; + await reset(); + + const p = getProvider("1proxy")!; + const result = await p.sync(); + assert.equal(result.fetched, 0); + assert.ok(result.errors.length > 0); + assert.ok(result.errors[0].includes("disabled")); + + process.env.FREE_PROXY_1PROXY_ENABLED = original; +}); + +test("OneproxyProvider.sync handles HTTP error and increments failure count", async () => { + await reset(); + const original = process.env.FREE_PROXY_1PROXY_API_URL; + + // Point to a URL that returns non-200 + process.env.FREE_PROXY_1PROXY_ENABLED = "true"; + process.env.FREE_PROXY_1PROXY_API_URL = "http://127.0.0.1:1/nonexistent"; + + const p = getProvider("1proxy")!; + const result = await p.sync(); + + assert.equal(result.fetched, 0); + assert.ok(result.errors.length > 0); + + process.env.FREE_PROXY_1PROXY_API_URL = original ?? ""; +}); + +test("OneproxyProvider.list delegates to listFreeProxiesBySource", async () => { + await reset(); + process.env.FREE_PROXY_1PROXY_ENABLED = "true"; + + const freeProxiesDb = await import("../../src/lib/db/freeProxies.ts"); + await freeProxiesDb.upsertFreeProxy({ + source: "1proxy", host: "99.0.0.1", port: 8080, type: "http", + countryCode: "US", qualityScore: 70, latencyMs: 100, anonymity: null, lastValidated: null, + }); + + const p = getProvider("1proxy")!; + const items = await p.list({ limit: 10 }); + assert.ok(Array.isArray(items)); + assert.ok(items.length >= 1); + assert.equal(items[0].source, "1proxy"); +}); + +// ── ProxiflyProvider ────────────────────────────────────────────────────────── + +test("ProxiflyProvider.isEnabled returns true when not set (enabled by default)", () => { + const original = process.env.FREE_PROXY_PROXIFLY_ENABLED; + delete process.env.FREE_PROXY_PROXIFLY_ENABLED; + const p = getProvider("proxifly")!; + assert.equal(p.isEnabled(), true); + if (original !== undefined) process.env.FREE_PROXY_PROXIFLY_ENABLED = original; +}); + +test("ProxiflyProvider.isEnabled returns true when explicitly set", () => { + const original = process.env.FREE_PROXY_PROXIFLY_ENABLED; + process.env.FREE_PROXY_PROXIFLY_ENABLED = "true"; + const p = getProvider("proxifly")!; + assert.equal(p.isEnabled(), true); + process.env.FREE_PROXY_PROXIFLY_ENABLED = original ?? ""; +}); + +test("ProxiflyProvider.sync returns disabled error when not enabled", async () => { + const original = process.env.FREE_PROXY_PROXIFLY_ENABLED; + process.env.FREE_PROXY_PROXIFLY_ENABLED = "false"; + await reset(); + + const p = getProvider("proxifly")!; + const result = await p.sync(); + assert.equal(result.fetched, 0); + assert.ok(result.errors.some((e) => e.includes("disabled"))); + + process.env.FREE_PROXY_PROXIFLY_ENABLED = original ?? ""; +}); + +// ── IplocateProvider ────────────────────────────────────────────────────────── + +test("IplocateProvider.isEnabled returns false by default", () => { + const original = process.env.FREE_PROXY_IPLOCATE_ENABLED; + delete process.env.FREE_PROXY_IPLOCATE_ENABLED; + const p = getProvider("iplocate")!; + assert.equal(p.isEnabled(), false); + if (original !== undefined) process.env.FREE_PROXY_IPLOCATE_ENABLED = original; +}); + +test("IplocateProvider.sync returns disabled error when not enabled", async () => { + const original = process.env.FREE_PROXY_IPLOCATE_ENABLED; + process.env.FREE_PROXY_IPLOCATE_ENABLED = "false"; + await reset(); + + const p = getProvider("iplocate")!; + const result = await p.sync(); + assert.equal(result.fetched, 0); + assert.ok(result.errors.some((e) => e.includes("disabled"))); + + process.env.FREE_PROXY_IPLOCATE_ENABLED = original ?? ""; +}); diff --git a/tests/unit/proxy-registry.test.ts b/tests/unit/proxy-registry.test.ts index 65d608685107..f1d49f74f8c1 100644 --- a/tests/unit/proxy-registry.test.ts +++ b/tests/unit/proxy-registry.test.ts @@ -11,8 +11,10 @@ const core = await import("../../src/lib/db/core.ts"); const providersDb = await import("../../src/lib/db/providers.ts"); const proxiesDb = await import("../../src/lib/db/proxies.ts"); const settingsDb = await import("../../src/lib/db/settings.ts"); +const proxiesRoute = await import("../../src/app/api/settings/proxies/route.ts"); async function resetStorage() { + delete process.env.INITIAL_PASSWORD; core.resetDbInstance(); fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); @@ -288,3 +290,44 @@ test("resolveProxyForProvider returns null when neither registry nor legacy conf const resolved = await proxiesDb.resolveProxyForProvider("gemini"); assert.equal(resolved, null); }); + +test("createProxyRegistrySchema accepts type:vercel and source:vercel-relay (schema gap-06)", async () => { + // Note: We validate the schema directly using the worktree's absolute path because + // tests run with CWD=/OmniRoute, so `@/` aliases resolve to the main branch's src/. + // The assertion below confirms the worktree's schema accepts the new enum values. + const { createProxyRegistrySchema } = await import( + "../../src/shared/validation/schemas.ts" + ); + + const result = createProxyRegistrySchema.safeParse({ + name: "Vercel Relay Test", + type: "vercel", + host: "omniroute-relay-abc123.vercel.app", + port: 443, + source: "vercel-relay", + notes: JSON.stringify({ relayAuth: "secret-relay-token" }), + }); + + assert.ok(result.success, `schema should accept type:vercel — errors: ${JSON.stringify("error" in result ? result.error : null)}`); + if (result.success) { + assert.equal(result.data.type, "vercel"); + assert.equal(result.data.source, "vercel-relay"); + } +}); + +test("createProxy persists type:vercel and source:vercel-relay to DB (schema gap-06)", async () => { + await resetStorage(); + + const created = await proxiesDb.createProxy({ + name: "Vercel Relay DB Test", + type: "vercel", + host: "omniroute-relay-xyz.vercel.app", + port: 443, + source: "vercel-relay", + notes: JSON.stringify({ relayAuth: "my-token" }), + }); + + assert.ok(created?.id, "created proxy should have an id"); + assert.equal(created?.type, "vercel"); + assert.equal(created?.source, "vercel-relay"); +}); From f9ca142a46705203fe83916eee1bff4c2fabbdc3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 00:24:36 -0300 Subject: [PATCH 23/77] deps: bump typescript-eslint in the development group across 1 directory (#2722) Integrated into release/v3.8.4 --- package-lock.json | 128 +++++++++++++++++++++++----------------------- 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6215fd0a1b11..851f3a16cfb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5705,17 +5705,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", - "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/type-utils": "8.59.4", - "@typescript-eslint/utils": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -5728,7 +5728,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.4", + "@typescript-eslint/parser": "^8.60.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -5744,16 +5744,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", - "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3" }, "engines": { @@ -5769,14 +5769,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", - "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.4", - "@typescript-eslint/types": "^8.59.4", + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", "debug": "^4.4.3" }, "engines": { @@ -5791,14 +5791,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", - "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4" + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5809,9 +5809,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", - "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", "dev": true, "license": "MIT", "engines": { @@ -5826,15 +5826,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", - "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5851,9 +5851,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", - "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", "dev": true, "license": "MIT", "engines": { @@ -5865,16 +5865,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", - "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.4", - "@typescript-eslint/tsconfig-utils": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5932,9 +5932,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -5945,16 +5945,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", - "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4" + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5969,13 +5969,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", - "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/types": "8.60.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -19215,16 +19215,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.4.tgz", - "integrity": "sha512-Rw6+44QNFaXtgHSjPy+Kw8hrJniMYzR85E9yLmOLcfZ91/rz+JXQbDTCmc6ccxMPY6K6PgAq26f0JCBfR7LIPQ==", + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.60.0.tgz", + "integrity": "sha512-9f65qWLZdAW9m1JaxBDUHcqRUfL8bkxxXL7XxEfI+F09q56PkBvIfCjLF3yInsDM/BBmwkqmCQdCZe/RYlIWEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.4", - "@typescript-eslint/parser": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4" + "@typescript-eslint/eslint-plugin": "8.60.0", + "@typescript-eslint/parser": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" From 9678a6404e49900a52d83b9d25e170f7fa065aa2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 00:24:39 -0300 Subject: [PATCH 24/77] deps: bump the production group across 1 directory with 5 updates (#2721) Integrated into release/v3.8.4 --- package-lock.json | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 851f3a16cfb9..c44e14d232b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10712,9 +10712,9 @@ } }, "node_modules/fumadocs-core": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.9.0.tgz", - "integrity": "sha512-L5bXpKsN0m7kK483KqviQ7g0l6PA7M32K5yCXS0KUrVWJDUu038ASb08G59RFwWjxhahCAHf0H0ntwzF91lqnQ==", + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.9.1.tgz", + "integrity": "sha512-8VW4aD1iG5SrCChRq84QpNyjKY69i5WfUFFgtx/LSUuX1RewWb0qps7DhKLSjOWraemhZatzsZ7iceivJmYRTg==", "license": "MIT", "dependencies": { "@orama/orama": "^3.1.18", @@ -10813,9 +10813,9 @@ } }, "node_modules/fumadocs-mdx": { - "version": "15.0.7", - "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.0.7.tgz", - "integrity": "sha512-3ffG5th20eL3CvEH9YSYfmn0MPeaJkdnu1MMXNsR1zduhn6tW5ieQ8YX+EC4+BfnpwcB3y2wwAlGM0PWl/YPNg==", + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-15.0.8.tgz", + "integrity": "sha512-CT9odtkLmpjH/DOTEW8prKY6SYzr7rmHmJj+ZBkvppPMNFstvTLvxxpY0ri8/9RPmCTZQUUMdNBxcPzhsHIcgw==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.1.1", @@ -10877,9 +10877,9 @@ } }, "node_modules/fumadocs-ui": { - "version": "16.9.0", - "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.9.0.tgz", - "integrity": "sha512-z4w93vuhrsNp1cup7mcEoo8GSDQRCgDMkGnIvUEz+YB7IwSDhrUqf3lXuSKDaqPLXPdRvR0Cf+BAqARBW71c0Q==", + "version": "16.9.1", + "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.9.1.tgz", + "integrity": "sha512-2A8wO/RuoV0eOgabKMlcjBEPQKynbE4wkxoTfAj/E6bYCid/zY3DG+hhSI1Ssen5ws/73mFPacIEBsWhdAAsnw==", "license": "MIT", "dependencies": { "@fumadocs/tailwind": "0.0.5", @@ -10908,7 +10908,7 @@ "@takumi-rs/image-response": "*", "@types/mdx": "*", "@types/react": "*", - "fumadocs-core": "16.9.0", + "fumadocs-core": "16.9.1", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0" @@ -11845,9 +11845,9 @@ "license": "ISC" }, "node_modules/ink": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/ink/-/ink-7.0.3.tgz", - "integrity": "sha512-5kxHkIj9+RuqCU3zyvP4qvYWNOSHP2TW/SHayHGHOmk87KwfVcZwvJGemi9ch+ci2gXUqerK/Eh2DGEDt5q45g==", + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.0.4.tgz", + "integrity": "sha512-4wsM/gMKOT2ZANNTJibI6I9IcwBfobqv/CgaDcwvOaCREZIQxo3iGQS7qPHa2hmA67NYltZWCMtBDELB/mcbJQ==", "license": "MIT", "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", @@ -20258,9 +20258,9 @@ ] }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" From f86733874e0f7a130529bbf31d644e93e497f2b5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 May 2026 00:24:41 -0300 Subject: [PATCH 25/77] deps: bump electron-builder from 26.11.0 to 26.11.1 in /electron (#2720) Integrated into release/v3.8.4 --- electron/package-lock.json | 863 ++++++++++++++++++------------------- electron/package.json | 2 +- 2 files changed, 409 insertions(+), 456 deletions(-) diff --git a/electron/package-lock.json b/electron/package-lock.json index a90d4e200253..6e907d9010e9 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -9,17 +9,15 @@ "version": "3.8.3", "license": "MIT", "dependencies": { + "better-sqlite3": "^12.10.0", "electron-updater": "^6.8.6" }, "devDependencies": { - "electron": "^42.2.0", - "electron-builder": "^26.11.0" + "electron": "^41.2.0", + "electron-builder": "^26.11.1" }, "engines": { "node": ">=22.22.2 <23 || >=24.0.0 <27" - }, - "optionalDependencies": { - "better-sqlite3": "^12.10.0" } }, "node_modules/@develar/schema-utils": { @@ -121,48 +119,70 @@ } }, "node_modules/@electron/get": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", - "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^3.0.0", - "graceful-fs": "^4.2.11", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", "progress": "^2.0.3", - "semver": "^7.6.3", + "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=22.12.0" + "node": ">=12" }, "optionalDependencies": { - "undici": "^7.24.4" + "global-agent": "^3.0.0" } }, - "node_modules/@electron/get/node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", "dev": true, "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=6 <7 || >=8" } }, - "node_modules/@electron/get/node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "node_modules/@electron/get/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "optional": true, "engines": { - "node": ">=20.18.1" + "node": ">= 4.0.0" } }, "node_modules/@electron/notarize": { @@ -326,45 +346,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -815,142 +796,6 @@ "dev": true, "license": "MIT" }, - "node_modules/app-builder-lib": { - "version": "26.11.0", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.11.0.tgz", - "integrity": "sha512-coIDM9smVKp2ZRiEu1Xu6NBjN2xdhViox8bHxaWToMCUYVIusqwUxUl9J/5ef8CKZ2CVHgqd7/rFsFazLrzp6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@develar/schema-utils": "~2.6.5", - "@electron/asar": "3.4.1", - "@electron/fuses": "^1.8.0", - "@electron/get": "^3.0.0", - "@electron/notarize": "2.5.0", - "@electron/osx-sign": "1.3.3", - "@electron/rebuild": "4.0.3", - "@electron/universal": "2.0.3", - "@malept/flatpak-bundler": "^0.4.0", - "@types/fs-extra": "9.0.13", - "async-exit-hook": "^2.0.1", - "builder-util": "26.11.0", - "builder-util-runtime": "9.6.1", - "chromium-pickle-js": "^0.2.0", - "ci-info": "4.3.1", - "debug": "^4.3.4", - "dotenv": "^16.4.5", - "dotenv-expand": "^11.0.6", - "ejs": "^3.1.8", - "electron-publish": "26.11.0", - "fs-extra": "^10.1.0", - "hosted-git-info": "^4.1.0", - "isbinaryfile": "^5.0.0", - "jiti": "^2.4.2", - "js-yaml": "^4.1.0", - "json5": "^2.2.3", - "lazy-val": "^1.0.5", - "minimatch": "^10.0.3", - "plist": "3.1.0", - "proper-lockfile": "^4.1.2", - "resedit": "^1.7.0", - "semver": "~7.7.3", - "tar": "^7.5.7", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0", - "unzipper": "^0.12.3", - "which": "^5.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "dmg-builder": "26.11.0", - "electron-builder-squirrel-windows": "26.11.0" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", - "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=14" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/app-builder-lib/node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/app-builder-lib/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/app-builder-lib/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1027,7 +872,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "devOptional": true, "funding": [ { "type": "github", @@ -1050,7 +894,6 @@ "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", "hasInstallScript": true, "license": "MIT", - "optional": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -1064,7 +907,6 @@ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", "license": "MIT", - "optional": true, "dependencies": { "file-uri-to-path": "1.0.0" } @@ -1073,7 +915,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "devOptional": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -1114,7 +955,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "devOptional": true, "funding": [ { "type": "github", @@ -1152,34 +992,6 @@ "dev": true, "license": "MIT" }, - "node_modules/builder-util": { - "version": "26.11.0", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.11.0.tgz", - "integrity": "sha512-T+NH1EatZ7dy14dkk4yJrg/rTW6RDOLpDZOhcTSUsqxild21Mcts7CW8TgNemBwMMpwDAinNNoZQiUVgH/LyJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/debug": "^4.1.6", - "7zip-bin": "~5.2.0", - "app-builder-bin": "5.0.0-alpha.12", - "builder-util-runtime": "9.6.1", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.6", - "debug": "^4.3.4", - "fs-extra": "^10.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", - "js-yaml": "^4.1.0", - "sanitize-filename": "^1.6.3", - "source-map-support": "^0.5.19", - "stat-mode": "^1.0.0", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/builder-util-runtime": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.6.1.tgz", @@ -1356,22 +1168,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -1532,15 +1328,6 @@ "buffer": "^5.1.0" } }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1600,7 +1387,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "devOptional": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -1616,7 +1402,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -1630,7 +1415,6 @@ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "license": "MIT", - "optional": true, "engines": { "node": ">=4.0.0" } @@ -1710,7 +1494,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1763,24 +1546,205 @@ "brace-expansion": "^1.1.7" }, "engines": { - "node": "*" + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "26.11.1", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.11.1.tgz", + "integrity": "sha512-uFE7wiNn/GJ/MCtHz9ZIYj8w+27WTKush6PX6a1rt7RWJ5Ua4WgK6PAgErSBQXk0Q8UM4YMTXUBNFhN1gR+1qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.11.1", + "builder-util": "26.11.1", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/dmg-builder/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/dmg-builder/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/dmg-builder/node_modules/app-builder-lib": { + "version": "26.11.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.11.1.tgz", + "integrity": "sha512-KCZm58CifmTGbzw9PZOyDEecwjY3jY9cQ4uBRIDxZYhaJqqsz0RQFkryoV+EPhjVlkPql7Jsg6SNUkz837Bkhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "4.0.3", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "builder-util": "26.11.1", + "builder-util-runtime": "9.6.1", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.11.1", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.3", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.11.1", + "electron-builder-squirrel-windows": "26.11.1" + } + }, + "node_modules/dmg-builder/node_modules/builder-util": { + "version": "26.11.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.11.1.tgz", + "integrity": "sha512-oWRbAjgN9RdVCLvNVN9ltZwaUkrH/FrH8wdggAwpz426PTdcbGEARp7J1HXad/QijURPxYL+BKykThaRjluPIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.12", + "builder-util-runtime": "9.6.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/dmg-builder/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" } }, - "node_modules/dmg-builder": { - "version": "26.11.0", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.11.0.tgz", - "integrity": "sha512-bOVrG++ewJZ0aneOAlzHCbZmYdYxaANRuUoGxBXVmwmjMUTI6MBk7ekzr5xRLEf33+Vi8KTLW1eGepLSiTb6GQ==", + "node_modules/dmg-builder/node_modules/electron-publish": { + "version": "26.11.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.11.1.tgz", + "integrity": "sha512-aMugkYS7QZtS/PjbyOSh0MlymxoWppj7+lEcqmsO1jM6igedcgAdFtmWms3g/DdMPpCZDVKxzkC4a3UK6HGlmA==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.11.0", - "builder-util": "26.11.0", + "@types/fs-extra": "^9.0.11", + "builder-util": "26.11.1", + "builder-util-runtime": "9.6.1", + "chalk": "^4.1.2", + "form-data": "^4.0.5", "fs-extra": "^10.1.0", - "iconv-lite": "^0.6.2", - "js-yaml": "^4.1.0" - }, + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", "optionalDependencies": { - "dmg-license": "^1.0.11" + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" } }, "node_modules/dmg-license": { @@ -1921,37 +1885,37 @@ } }, "node_modules/electron": { - "version": "42.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.2.0.tgz", - "integrity": "sha512-b2Tc7sIKiZEl0tBVwFM5GJ+FT5KYhmy9QJHjx8BGVZPVW2SctXWEvrE959ElB56qw7H05dBkhlikDA1DmpaAMw==", + "version": "41.7.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.7.0.tgz", + "integrity": "sha512-U6KAKivjk6YQ0Z5+eloJBjwhbHRE206gvy1UBMw2bSluWtMh5waeXMvX6AT/Ujm5ymYXVJOp7g9N7vOFw16wBQ==", "dev": true, + "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^5.0.0", + "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { - "electron": "cli.js", - "install-electron": "install.js" + "electron": "cli.js" }, "engines": { - "node": ">= 22.12.0" + "node": ">= 12.20.55" } }, "node_modules/electron-builder": { - "version": "26.11.0", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.11.0.tgz", - "integrity": "sha512-iAbfODrWHrbWTrwI2IVNNfq88VViMBxBNaKcwozXm4fOlf91hj5yDKVq++XP4iAOHcPWHVZ5m+ngx54MLpFPeQ==", + "version": "26.11.1", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.11.1.tgz", + "integrity": "sha512-CMG8PQAnUBWIx9THpwWysrHfJQs9Q1E1VDayK+ZkNqvih+ao8mywMP5v2ohLkzlg1ZeVyvkQP6oICJGzyeCAUQ==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.11.0", - "builder-util": "26.11.0", + "app-builder-lib": "26.11.1", + "builder-util": "26.11.1", "builder-util-runtime": "9.6.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", - "dmg-builder": "26.11.0", + "dmg-builder": "26.11.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", @@ -1965,112 +1929,203 @@ "node": ">=14.0.0" } }, - "node_modules/electron-builder-squirrel-windows": { - "version": "26.11.0", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.11.0.tgz", - "integrity": "sha512-7Id4jZ2SkunTbxk89L19yLG4s7UoE4KiGXuxAY5idPJP/dJuWfOV2NmMXODQf+CzSWLDV3w1OcT8BtCH9VoqDA==", + "node_modules/electron-builder/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "app-builder-lib": "26.11.0", - "builder-util": "26.11.0", - "electron-winstaller": "5.4.0" + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" } }, - "node_modules/electron-publish": { - "version": "26.11.0", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.11.0.tgz", - "integrity": "sha512-FeGZ3wLrQv9zIat00WhRg/7Pcboe3B9zwkQojdBgtt6pEQ4MgxA2xll5Cnrtgn0c65ENRWOr5TQ8YBIx7j5xSA==", + "node_modules/electron-builder/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", "dev": true, "license": "MIT", "dependencies": { - "@types/fs-extra": "^9.0.11", - "builder-util": "26.11.0", - "builder-util-runtime": "9.6.1", - "chalk": "^4.1.2", - "form-data": "^4.0.5", - "fs-extra": "^10.1.0", - "lazy-val": "^1.0.5", - "mime": "^2.5.2" + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" } }, - "node_modules/electron-updater": { - "version": "6.8.6", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.6.tgz", - "integrity": "sha512-taWDzUH44Ax0gi2Zx+107nCfLxky2UfVYnkMlcpcu7WBGrkY8GcSdJIYa3ACHe3L6kMfcXWBIT6tjY5kt/cVFg==", + "node_modules/electron-builder/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/electron-builder/node_modules/app-builder-lib": { + "version": "26.11.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.11.1.tgz", + "integrity": "sha512-KCZm58CifmTGbzw9PZOyDEecwjY3jY9cQ4uBRIDxZYhaJqqsz0RQFkryoV+EPhjVlkPql7Jsg6SNUkz837Bkhw==", + "dev": true, "license": "MIT", "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "4.0.3", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "builder-util": "26.11.1", "builder-util-runtime": "9.6.1", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.11.1", "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", "js-yaml": "^4.1.0", + "json5": "^2.2.3", "lazy-val": "^1.0.5", - "lodash.escaperegexp": "^4.1.2", - "lodash.isequal": "^4.5.0", + "minimatch": "^10.0.3", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", "semver": "~7.7.3", - "tiny-typed-emitter": "^2.1.0" + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.11.1", + "electron-builder-squirrel-windows": "26.11.1" } }, - "node_modules/electron-winstaller": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", - "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "node_modules/electron-builder/node_modules/builder-util": { + "version": "26.11.1", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.11.1.tgz", + "integrity": "sha512-oWRbAjgN9RdVCLvNVN9ltZwaUkrH/FrH8wdggAwpz426PTdcbGEARp7J1HXad/QijURPxYL+BKykThaRjluPIQ==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { - "@electron/asar": "^3.2.1", - "debug": "^4.1.1", - "fs-extra": "^7.0.1", - "lodash": "^4.17.21", - "temp": "^0.9.0" + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.12", + "builder-util-runtime": "9.6.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" }, "engines": { - "node": ">=8.0.0" - }, - "optionalDependencies": { - "@electron/windows-sign": "^1.1.2" + "node": ">=14.0.0" } }, - "node_modules/electron-winstaller/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "node_modules/electron-builder/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, "engines": { - "node": ">=6 <7 || >=8" + "node": ">=8" + } + }, + "node_modules/electron-builder/node_modules/electron-publish": { + "version": "26.11.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.11.1.tgz", + "integrity": "sha512-aMugkYS7QZtS/PjbyOSh0MlymxoWppj7+lEcqmsO1jM6igedcgAdFtmWms3g/DdMPpCZDVKxzkC4a3UK6HGlmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "26.11.1", + "builder-util-runtime": "9.6.1", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" } }, - "node_modules/electron-winstaller/node_modules/jsonfile": { + "node_modules/electron-builder/node_modules/jsonfile": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, - "node_modules/electron-winstaller/node_modules/universalify": { + "node_modules/electron-builder/node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4.0.0" } }, + "node_modules/electron-updater": { + "version": "6.8.6", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.6.tgz", + "integrity": "sha512-taWDzUH44Ax0gi2Zx+107nCfLxky2UfVYnkMlcpcu7WBGrkY8GcSdJIYa3ACHe3L6kMfcXWBIT6tjY5kt/cVFg==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.6.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2093,7 +2148,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "devOptional": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -2202,7 +2256,6 @@ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "license": "(MIT OR WTFPL)", - "optional": true, "engines": { "node": ">=6" } @@ -2292,8 +2345,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/filelist": { "version": "1.0.6", @@ -2386,8 +2438,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/fs-extra": { "version": "10.1.0", @@ -2502,8 +2553,7 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/glob": { "version": "7.2.3", @@ -2803,7 +2853,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "devOptional": true, "funding": [ { "type": "github", @@ -2846,15 +2895,13 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "devOptional": true, "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/ip-address": { "version": "10.2.0", @@ -3231,7 +3278,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3369,26 +3415,11 @@ "node": ">= 18" } }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", @@ -3400,8 +3431,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/negotiator": { "version": "1.0.0", @@ -3520,7 +3550,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -3712,43 +3741,12 @@ "node": ">=18" } }, - "node_modules/postject": { - "version": "1.0.0-alpha.6", - "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", - "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "commander": "^9.4.0" - }, - "bin": { - "postject": "dist/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/postject/node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^12.20.0 || >=14" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", "license": "MIT", - "optional": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -3775,7 +3773,6 @@ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.90.0.tgz", "integrity": "sha512-pZNQT7UnYlMwMBy5N1lV5X/YLTbZM5ncytN3xL7CHEzhDN8uVe0u55yaPUJICIJjaCW8NrM5BFdqr7HLweStNA==", "license": "MIT", - "optional": true, "dependencies": { "semver": "^7.3.5" }, @@ -3840,7 +3837,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "devOptional": true, "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", @@ -3875,7 +3871,6 @@ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", @@ -3903,7 +3898,6 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "devOptional": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -3986,21 +3980,6 @@ "node": ">= 4" } }, - "node_modules/rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, "node_modules/roarr": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", @@ -4024,7 +4003,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "devOptional": true, "funding": [ { "type": "github", @@ -4152,8 +4130,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/simple-get": { "version": "4.0.1", @@ -4174,7 +4151,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -4307,7 +4283,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "devOptional": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -4376,7 +4351,6 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=0.10.0" } @@ -4429,7 +4403,6 @@ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", "license": "MIT", - "optional": true, "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", @@ -4441,15 +4414,13 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "license": "MIT", - "optional": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -4471,21 +4442,6 @@ "node": ">=18" } }, - "node_modules/temp": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", - "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mkdirp": "^0.5.1", - "rimraf": "~2.6.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/temp-file": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", @@ -4575,7 +4531,6 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", - "optional": true, "dependencies": { "safe-buffer": "^5.0.1" }, @@ -4689,7 +4644,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "devOptional": true, "license": "MIT" }, "node_modules/verror": { @@ -4775,7 +4729,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "devOptional": true, "license": "ISC" }, "node_modules/xmlbuilder": { diff --git a/electron/package.json b/electron/package.json index da4f470eb2db..244f59f7b04d 100644 --- a/electron/package.json +++ b/electron/package.json @@ -30,7 +30,7 @@ }, "devDependencies": { "electron": "^41.2.0", - "electron-builder": "^26.11.0" + "electron-builder": "^26.11.1" }, "overrides": { "@xmldom/xmldom": "^0.9.10", From 1a88b75d55a031db17a514e3cdedaf533808a180 Mon Sep 17 00:00:00 2001 From: df4p <38404+df4p@users.noreply.github.com> Date: Tue, 26 May 2026 00:24:46 -0300 Subject: [PATCH 26/77] Feat/inner ai provider (#2704) Integrated into release/v3.8.4 --- open-sse/config/providerRegistry.ts | 42 ++ open-sse/executors/index.ts | 4 + open-sse/executors/inner-ai.ts | 584 ++++++++++++++++++ public/providers/inner-ai.png | Bin 0 -> 24921 bytes .../dashboard/providers/[id]/page.tsx | 75 ++- src/app/api/providers/[id]/models/route.ts | 107 ++++ src/lib/providers/validation.ts | 83 +++ src/shared/components/ProviderIcon.tsx | 1 + src/shared/constants/providers.ts | 12 + 9 files changed, 907 insertions(+), 1 deletion(-) create mode 100644 open-sse/executors/inner-ai.ts create mode 100644 public/providers/inner-ai.png diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 0ef47ab2b04e..11a5d11578ea 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -2762,6 +2762,48 @@ export const REGISTRY: Record = { ], }, + "inner-ai": { + id: "inner-ai", + alias: "in-ai", + format: "openai", + executor: "inner-ai", + baseUrl: "https://chatapi.innerai.com/chat", + authType: "apikey", + authHeader: "bearer", + models: [ + // OpenAI + { id: "gpt-4o", name: "GPT-4o (via Inner.ai)" }, + { id: "gpt-4.1", name: "GPT-4.1 (via Inner.ai)" }, + { id: "gpt-4.1-mini", name: "GPT-4.1 Mini (via Inner.ai)" }, + { id: "o3", name: "o3 (via Inner.ai)", supportsReasoning: true }, + { id: "o4-mini", name: "o4-mini (via Inner.ai)", supportsReasoning: true }, + // Anthropic + { id: "claude-opus-4-5", name: "Claude Opus 4.5 (via Inner.ai)" }, + { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (via Inner.ai)" }, + { id: "claude-3-7-sonnet-20250219", name: "Claude 3.7 Sonnet (via Inner.ai)" }, + { id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet (via Inner.ai)" }, + // Google + { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro (via Inner.ai)" }, + { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash (via Inner.ai)" }, + { id: "gemini-2.0-flash", name: "Gemini 2.0 Flash (via Inner.ai)" }, + // DeepSeek + { + id: "deepseek-r1", + name: "DeepSeek R1 (via Inner.ai)", + supportsReasoning: true, + }, + { id: "deepseek-v3", name: "DeepSeek V3 (via Inner.ai)" }, + // xAI + { id: "grok-3", name: "Grok 3 (via Inner.ai)" }, + { id: "grok-3-mini", name: "Grok 3 Mini (via Inner.ai)", supportsReasoning: true }, + // Meta + { id: "llama-4-maverick", name: "Llama 4 Maverick (via Inner.ai)" }, + { id: "llama-3.3-70b-instruct", name: "Llama 3.3 70B (via Inner.ai)" }, + // Mistral + { id: "mistral-large-2411", name: "Mistral Large (via Inner.ai)" }, + ], + }, + "adapta-web": { id: "adapta-web", alias: "adp-web", diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 4caab2c49d79..ab849deda06a 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -33,6 +33,7 @@ import { ClaudeWebWithAutoRefresh } from "./claude-web-with-auto-refresh.ts"; import { CopilotWebExecutor } from "./copilot-web.ts"; import { VeoAIFreeWebExecutor } from "./veoaifree-web.ts"; import { T3ChatWebExecutor } from "./t3-chat-web.ts"; +import { InnerAiExecutor } from "./inner-ai.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -94,6 +95,8 @@ const executors = { "veo-free": new VeoAIFreeWebExecutor(), // Alias "t3-web": new T3ChatWebExecutor(), t3chat: new T3ChatWebExecutor(), // Alias + "inner-ai": new InnerAiExecutor(), + "in-ai": new InnerAiExecutor(), // Alias }; const defaultCache = new Map(); @@ -144,3 +147,4 @@ export { DeepSeekWebExecutor } from "./deepseek-web.ts"; export { DeepSeekWebWithAutoRefreshExecutor } from "./deepseek-web-with-auto-refresh.ts"; export { AdaptaWebExecutor } from "./adapta-web.ts"; export { T3ChatWebExecutor } from "./t3-chat-web.ts"; +export { InnerAiExecutor } from "./inner-ai.ts"; diff --git a/open-sse/executors/inner-ai.ts b/open-sse/executors/inner-ai.ts new file mode 100644 index 000000000000..95b85f534479 --- /dev/null +++ b/open-sse/executors/inner-ai.ts @@ -0,0 +1,584 @@ +import { BaseExecutor, type ExecuteInput } from "./base.ts"; +import { sanitizeErrorMessage } from "../utils/error.ts"; + +const INNER_AI_CHAT_URL = "https://chatapi.innerai.com/chat"; +const INNER_AI_PROFILE_URL = "https://platformapi.innerai.com/api/v1/users/profile"; +const INNER_AI_MODELS_URL = "https://platformapi.innerai.com/api/v1/ai_models"; + +const INNER_AI_USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; + +const MODELS_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface InnerAiModel { + id: string; // UUID from platformapi + llm_model: string; + name?: string; + enable?: boolean; + visible?: boolean; + unavailable_api?: boolean; + pro_only?: boolean; + ultra_only?: boolean; +} + +interface CredentialCache { + email: string; + deviceId: string; +} + +// ── In-memory caches ────────────────────────────────────────────────────────── + +// Keyed by first 32 chars of the token JWT +const credentialCache = new Map(); +const modelsCache = new Map(); + +function tokenCacheKey(token: string): string { + return token.slice(0, 32); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Decode JWT payload without verifying signature. */ +function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split("."); + if (parts.length < 2) return null; + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); + return JSON.parse(atob(padded)); + } catch { + return null; + } +} + +/** Parse the credential string. + * + * Accepted formats: + * "eyJhbG..." — token only (no email, chat will try without USER-EMAIL) + * "eyJhbG... user@example.com" — token + email (recommended) + * "token=eyJhbG... user@example.com" — same with token= prefix + */ +function parseCredential(rawApiKey: string): { token: string; credEmail: string } { + const trimmed = rawApiKey.trim(); + // Strip "token=" prefix if present + const eqIdx = trimmed.indexOf("="); + const stripped = + eqIdx > 0 && !trimmed.startsWith("eyJ") ? trimmed.slice(eqIdx + 1).trim() : trimmed; + + // Split by the LAST space; if the last part looks like an email it's the credential email + const lastSpace = stripped.lastIndexOf(" "); + if (lastSpace > 0) { + const possibleEmail = stripped.slice(lastSpace + 1).trim(); + if (possibleEmail.includes("@")) { + return { token: stripped.slice(0, lastSpace).trim(), credEmail: possibleEmail }; + } + } + return { token: stripped, credEmail: "" }; +} + +function makeErrorResult(status: number, message: string, body: unknown) { + return { + response: new Response( + JSON.stringify({ + error: { + message: sanitizeErrorMessage(message), + type: "upstream_error", + code: `HTTP_${status}`, + }, + }), + { status, headers: { "Content-Type": "application/json" } } + ), + url: INNER_AI_CHAT_URL, + headers: {} as Record, + transformedBody: body, + }; +} + +/** Build request headers for Inner.ai API calls. */ +function buildHeaders(token: string, email: string, deviceId: string): Record { + const headers: Record = { + "Content-Type": "application/json", + "User-Agent": INNER_AI_USER_AGENT, + // Cookie-based auth — the token cookie is scoped to .innerai.com so all + // *.innerai.com subdomains expect it via Cookie header. + Cookie: `token=${token}`, + "USER-TOKEN": token, + "DEVICE-ID": deviceId, + Origin: "https://app.innerai.com", + Referer: "https://app.innerai.com/", + }; + if (email) headers["USER-EMAIL"] = email; + return headers; +} + +// ── Credential resolution (email + deviceId from JWT + profile API) ─────────── + +async function resolveCredentials( + token: string, + credEmail: string, + signal?: AbortSignal | null +): Promise { + const key = tokenCacheKey(token); + const cached = credentialCache.get(key); + if (cached) return cached; + + // Decode device_id from JWT payload (accept multiple field names) + const payload = decodeJwtPayload(token); + const deviceId = String( + payload?.device_id ?? payload?.deviceId ?? payload?.["device-id"] ?? payload?.did ?? "" + ).trim(); + + // Build profile request headers — include cookie auth + custom headers + const profileHeaders: Record = { + Cookie: `token=${token}`, + "USER-TOKEN": token, + "User-Agent": INNER_AI_USER_AGENT, + Origin: "https://app.innerai.com", + Referer: "https://app.innerai.com/", + }; + if (deviceId) profileHeaders["DEVICE-ID"] = deviceId; + + // Attempt to fetch email from profile API — non-fatal if it fails + let email = ""; + try { + const profileResp = await fetch(INNER_AI_PROFILE_URL, { + headers: profileHeaders, + signal: signal ?? undefined, + }); + + if (profileResp.ok) { + const body = await profileResp.json().catch(() => null); + const b = body as Record | null; + email = String( + (b?.data as Record)?.email ?? + (b?.user as Record)?.email ?? + (b?.profile as Record)?.email ?? + b?.email ?? + "" + ).trim(); + } + } catch { + // Profile fetch failed — proceed without email + } + + // Fallback 1: use the email provided directly in the credential string + if (!email && credEmail) email = credEmail; + + // Fallback 2: extract email from JWT sub if it looks like one + if (!email && typeof payload?.sub === "string" && payload.sub.includes("@")) { + email = payload.sub; + } + + const creds: CredentialCache = { email, deviceId }; + credentialCache.set(key, creds); + return creds; +} + +// ── Model resolution (dynamic fetch + cache) ────────────────────────────────── + +async function resolveModels( + token: string, + deviceId: string, + email: string, + signal?: AbortSignal | null +): Promise { + const key = tokenCacheKey(token); + const cached = modelsCache.get(key); + if (cached && Date.now() < cached.expiresAt) return cached.models; + + const resp = await fetch(INNER_AI_MODELS_URL, { + headers: buildHeaders(token, email, deviceId), + signal: signal ?? undefined, + }); + + if (!resp.ok) return []; + + const body = await resp.json().catch(() => null); + let raw: InnerAiModel[] = []; + if (Array.isArray(body)) { + raw = body as InnerAiModel[]; + } else if (Array.isArray((body as Record)?.data)) { + raw = (body as Record).data as InnerAiModel[]; + } else if (Array.isArray((body as Record)?.ai_models)) { + raw = (body as Record).ai_models as InnerAiModel[]; + } + + // Keep only text/chat models that are enabled and available for this account. + // Prefer the ai_model_categories field; fall back to llm_model heuristic. + const nonTextPattern = + /image|video|audio|img|vid|sound|music|voice|tts|stt|track|clip|avatar|cartoon|flux|stable.diff|recraft|ideogram|leonardo|magnific|bria|seedream|luma|kling|pika|veo|wan-|heygen|did-|vidu|pixverse|sora-|gen-[0-9]|playground|gemini-fal|gamma|lyria|clothes|whisper/i; + const models = raw.filter((m) => { + if (m.enable === false || m.unavailable_api) return false; + const cats = Array.isArray((m as Record).ai_model_categories) + ? ((m as Record).ai_model_categories as Array>) + : null; + if (cats && cats.length > 0) { + return cats.some((c) => String(c.unique_identifier ?? c.name ?? "").toLowerCase() === "text"); + } + return !nonTextPattern.test(m.llm_model); + }); + + modelsCache.set(key, { models, expiresAt: Date.now() + MODELS_CACHE_TTL_MS }); + return models; +} + +/** Find the Inner.ai model entry matching the requested OmniRoute model ID. + * + * Matching strategy (first match wins): + * 1. Exact `llm_model` match + * 2. Case-insensitive `llm_model` match + * 3. `llm_model` contains the requested ID + * 4. Fallback: first model in list + */ +function findModel(models: InnerAiModel[], requestedId: string): InnerAiModel | null { + if (models.length === 0) return null; + const lower = requestedId.toLowerCase(); + return ( + models.find((m) => m.llm_model === requestedId) ?? + models.find((m) => m.llm_model.toLowerCase() === lower) ?? + models.find((m) => m.llm_model.toLowerCase().includes(lower)) ?? + models[0] + ); +} + +// ── Message building ─────────────────────────────────────────────────────────── + +/** Convert an OpenAI messages array to Inner.ai's single message string. + * + * Inner.ai accepts a single `message` field. For multi-turn conversations we + * include previous turns with labelled prefixes. + */ +function buildMessageContent(messages: Array>): string { + const parts: string[] = []; + + for (const msg of messages) { + const content = + typeof msg.content === "string" + ? msg.content + : Array.isArray(msg.content) + ? (msg.content as Array>) + .filter((c) => c?.type === "text") + .map((c) => String(c.text ?? "")) + .join("") + : ""; + if (!content.trim()) continue; + + if (msg.role === "system") { + parts.push(`[Instructions]\n${content}`); + } else if (msg.role === "assistant") { + parts.push(`[Assistant]\n${content}`); + } else { + parts.push(content); + } + } + + return parts.join("\n\n"); +} + +// ── SSE transformation ───────────────────────────────────────────────────────── + +/** Transform Inner.ai SSE stream to OpenAI-compatible SSE stream. + * + * Inner.ai format: `data: {"type":"text","item":"chunk"}` + * `data: {"type":"end_stream","item":"end"}` + * + * Error event types: `missing_credits`, `reached_limit`, `rate_limit_reached`, + * `rate_limit_longer_reached` + * Ignored event types: `status` (e.g. `code: "provider_timeout_retry"`) + */ +function transformInnerAiSSE(upstream: ReadableStream, model: string): ReadableStream { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const id = `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const created = Math.floor(Date.now() / 1000); + let buffer = ""; + let emittedRole = false; + + const chunkEvent = (delta: Record, finishReason?: string | null) => + `data: ${JSON.stringify({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta, finish_reason: finishReason ?? null }], + })}\n\n`; + + return new ReadableStream({ + async start(controller) { + const reader = upstream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data:")) continue; + const jsonStr = line.slice(5).trim(); + if (!jsonStr || jsonStr === "[DONE]") continue; + + let data: Record; + try { + data = JSON.parse(jsonStr) as Record; + } catch { + continue; + } + + const type = String(data.type ?? ""); + const item = String(data.item ?? ""); + + if (type === "text") { + if (!item) continue; + if (!emittedRole) { + emittedRole = true; + controller.enqueue(encoder.encode(chunkEvent({ role: "assistant", content: "" }))); + } + controller.enqueue(encoder.encode(chunkEvent({ content: item }))); + } else if (type === "end_stream") { + if (!emittedRole) { + emittedRole = true; + controller.enqueue(encoder.encode(chunkEvent({ role: "assistant", content: "" }))); + } + controller.enqueue(encoder.encode(chunkEvent({}, "stop"))); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + return; + } else if ( + type === "missing_credits" || + type === "reached_limit" || + type === "rate_limit_reached" || + type === "rate_limit_longer_reached" + ) { + const errorMsg = + type === "missing_credits" + ? "Inner.ai: not enough credits" + : type === "reached_limit" + ? "Inner.ai: usage limit reached" + : "Inner.ai: rate limit reached — try again later"; + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + error: { message: errorMsg, type: "rate_limit_error", code: type }, + })}\n\n` + ) + ); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + return; + } + // type === "status" (e.g. provider_timeout_retry) → ignore + } + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err || "Stream error"); + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + error: { message: sanitizeErrorMessage(message), type: "upstream_error" }, + })}\n\n` + ) + ); + } + + // Stream ended without explicit end_stream + if (!emittedRole) { + controller.enqueue(encoder.encode(chunkEvent({ role: "assistant", content: "" }))); + } + controller.enqueue(encoder.encode(chunkEvent({}, "stop"))); + controller.enqueue(encoder.encode("data: [DONE]\n\n")); + controller.close(); + }, + }); +} + +/** Collect Inner.ai SSE stream into a single content string (non-streaming path). */ +async function collectContent(upstream: ReadableStream): Promise { + const decoder = new TextDecoder(); + const reader = upstream.getReader(); + let buffer = ""; + let content = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data:")) continue; + const jsonStr = line.slice(5).trim(); + if (!jsonStr || jsonStr === "[DONE]") continue; + + let data: Record; + try { + data = JSON.parse(jsonStr) as Record; + } catch { + continue; + } + + if (data.type === "text" && typeof data.item === "string") { + content += data.item; + } + } + } + return content; +} + +// ── Executor ────────────────────────────────────────────────────────────────── + +export class InnerAiExecutor extends BaseExecutor { + async execute(input: ExecuteInput) { + const { body, credentials, signal, stream: wantStream } = input; + const bodyObj = (body || {}) as Record; + + const rawToken = String(credentials?.apiKey ?? "").trim(); + if (!rawToken) { + return makeErrorResult( + 401, + "Missing Inner.ai token — paste your token cookie from DevTools → Application → Cookies → .innerai.com", + body + ); + } + const { token, credEmail } = parseCredential(rawToken); + + // Resolve email + deviceId (decoded from JWT + profile API) + let creds: CredentialCache; + try { + creds = await resolveCredentials(token, credEmail, signal); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to authenticate with Inner.ai"; + credentialCache.delete(tokenCacheKey(token)); + return makeErrorResult(401, message, body); + } + const { email, deviceId } = creds; + + // Resolve model from Inner.ai models API (dynamic, cached 1h) + const requestedModel = String(bodyObj.model ?? "").trim() || "gpt-4o"; + let models: InnerAiModel[] = []; + try { + models = await resolveModels(token, deviceId, email, signal); + } catch { + // Non-fatal: proceed with empty list; synthetic model entry will be used + } + + const modelEntry: InnerAiModel = findModel(models, requestedModel) ?? { + id: "", + llm_model: requestedModel, + }; + + // Build message content from OpenAI messages array + const rawMessages = Array.isArray(bodyObj.messages) ? bodyObj.messages : []; + const messages = rawMessages as Array>; + const messageContent = buildMessageContent(messages); + if (!messageContent.trim()) { + return makeErrorResult(400, "No message content to send", body); + } + + const innerAiBody = { + message: messageContent, + session_id: crypto.randomUUID(), + context_type: "no_context", + ai_model: { + id: modelEntry?.id || undefined, + llm_model: modelEntry?.llm_model ?? requestedModel, + }, + is_extension: false, + env: "production", + temporary: true, + use_web_search: false, + knowledge_list: [], + }; + + const reqHeaders = buildHeaders(token, email, deviceId); + + // POST to Inner.ai chat API + let upstream: Response; + try { + upstream = await fetch(INNER_AI_CHAT_URL, { + method: "POST", + headers: reqHeaders, + body: JSON.stringify(innerAiBody), + signal: signal ?? undefined, + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Request failed"; + return makeErrorResult( + 502, + `Inner.ai request failed: ${sanitizeErrorMessage(message)}`, + body + ); + } + + if (upstream.status === 401 || upstream.status === 403) { + credentialCache.delete(tokenCacheKey(token)); + return makeErrorResult( + upstream.status, + "Inner.ai authentication failed — re-paste your token cookie", + body + ); + } + + if (!upstream.ok) { + const errText = await upstream.text().catch(() => ""); + return makeErrorResult( + upstream.status, + `Inner.ai returned HTTP ${upstream.status}: ${sanitizeErrorMessage(errText)}`, + body + ); + } + + if (!upstream.body) { + return makeErrorResult(502, "Inner.ai returned an empty response", body); + } + + const resolvedModel = modelEntry?.llm_model ?? requestedModel; + + if (wantStream !== false) { + return { + response: new Response(transformInnerAiSSE(upstream.body, resolvedModel), { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }), + url: INNER_AI_CHAT_URL, + headers: reqHeaders, + transformedBody: innerAiBody, + }; + } + + // Non-streaming: collect content and return as JSON + const content = await collectContent(upstream.body); + const completionId = `chatcmpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return { + response: new Response( + JSON.stringify({ + id: completionId, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: resolvedModel, + choices: [ + { + index: 0, + message: { role: "assistant", content }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }), + { headers: { "Content-Type": "application/json" } } + ), + url: INNER_AI_CHAT_URL, + headers: reqHeaders, + transformedBody: innerAiBody, + }; + } +} diff --git a/public/providers/inner-ai.png b/public/providers/inner-ai.png new file mode 100644 index 0000000000000000000000000000000000000000..31b12e263b792014b259811b96d8ea22b72e5e3b GIT binary patch literal 24921 zcmV)CK*GO?P)PyA07*naRCr$Po$0TwTT|8dI(&e8-}~5z7G{zW>(L`g6#%R=NjKtV?{2%W<=9#lb)o;|SXP=9bAj%EAXYI9~ z^|WJ-8a1lsT#t|bwr_d!r2np0Z{B};dh_)3cz^%<^!}Uu@0&MIkN2;?@!qw~_4k`M zdprIAwf#rlJU#S%Y_s-pZU2$I--q`9zQ4Xc+}q-KPuDj4y|wQ+^T(Soktz4y32i_iHw-g|niZ|!Za*N<0ty}$d&?_IBTy#4y|bZviqb^p8n!#>+PPjCFQ zx|Zu2;Bd@cE9m#)-%v^z5cy6yq<@itB!d+v-hv(5|~)e z_|1Bp^&GFyh=lb23{*A={`vJW`7sK*98PE%a z|6c%ljpP_X|5q|J*)=$H)9rT_Z`B} zaWXmL;>1Yv3>D+a7l{4DBaG#DC}U3fu_VqqmO^H1BSy;58;ly^xnzcz_WFLj?(CcJ zm5hKuSaL(4jMtZpxY>ucT}N8mKn7u$91`TZ*ST%U43irvEv|hk?1t=$&m_Tk&$eWc zY=cbG`yY|4pp4Xg#j6-bwg7tcW{S(?Yde_Ou$ZZ zgWy~OIe;49+u)T(mM*_QFJN}KlDEa;Cfb-2W0NBv||WI-y}rKlDiXf{GeIo&O-@Z< z2dD}3BN4nw3~v+L0P_MpNC-*cI;sY^-d~`^Giy<f z48Wc7nTWou400gEpjgisiEm@*Z!W$j`jtI)mxC8+H=u;O0Zv_gDG-1-B7?tog1G~EOQy!g4cI9Wa{!m*NXCVwxuRodY?E(D z*zt@jTFM#~a21^!kdK6}Z5J$Fq!m{1BEpg-E;R=xPvFLRhpi}wMTzMmh6QN-H|Z1e zG{v<1cS;08a#-lmdkl5F0q`c{CVNuo{`2;m<;TKrrY40K%_PkdJ25MDb;^+q-~ze4 z>xW1nK6)nIUY>D5}W|>by_NR3HTStQdS1}^hA)BYz~-W*#%}RqItr2I2?KG zL3%7^uHuSGk4g&3&`iD>8^3g*NmnoMlf6~7DlM0l#|%vxmCD4)8e-%4Okr6prF1Z) zN2*v#5i;gf?Q6^|O)M6^1-7(vV`OLN6dUErkeMfzHizR(jICmNX>Q7gd>!|_0h$%s z9sb4UW%W@qnk&JoZc2ydVjvngLj#b|}j4%iCl$jt<&8VAJi$s3CTSbqDK_;O#(OXD82@ z-ZS32bvd%AGWAZ-i}s4HC5P15COKP9bokKs)~x`V$7%MUktZ$0HMkwzW@)aZumd{m zx_uLyp*`rZsuZ0GHV7U0APcu#TyMS%sq^?rm{YRv_1g!W~9rN04N4LUl@-5b~5cgPMEe%$e}Yfynj*GuOk-&4*}$?%d1i@|nMT!~@194_m`oXC$7 zh#(j|=VI#CNY=b&t0zEi9Q6oDSCbDTaZ-X_W?ac3Ivi;b9DvpcNLKjv0J{5%!2l*9 zx;`VIyIw}OUFxUJ!o|)Nx)+l%(F!Pw@wy#HnNa?5cO_IEOhtY&Ihi`FJCilnd8CeS z(kMZwZItkaYeRrHU?+ehl6nG&kPlLriYIdyt{=4F>b(vkj@qh^x;W=&;<>oHkSqEvh#Qapwm(*M^&oixc2-?j9fgK>^JJ$3ZJ8 z^fIaeHnsC*-xO$Fj-c0?Yz(QvL?~I80B!{e`MjqKENM}R92r%8%+=S7?Xwc-3E&Z143;%#Bg)+?LoAP3AWrELdY#89Z#gt>PhbtCq5Y@H3lo?VT<**J zeXo0(j)V4Pav)qMf}gf49SZ?D!B#Fr${r?&&6*osvPnPZeSl0AbE~4v1TkHX|NesH z$S+nHFkwzRWg7J{k`6yQLrP`WhXu$|E5KVAqqGIok*RvYaFKcbN3(ea$MSxF$b3 zBx4_4es=GAY!kf(j_KsV6i_R%&eg2EVEF$XK$m^gi!Lo0KsMUj6g#*j_|5lB7KyX8 zjxy=ubBfZsF-Qy(R7U}tlA+{+F))C>3o7MSSf8|HMOt(&JvK9Fn7C&ukCwq6z$G5n;+$cQ4GXS>R<9YMyks za;2gF+sV*%Qz5>SFC*#yBSEdprg~inOaA|oa(3O zI+>NgGW*cCVac8kip|C~gx0=8-ep9QTDvEGZp|HK?JhyA8Of`FVopo? zI;h9O2$-7BlJh^a5Yu(CdOYZcb=eyG&*$zTYKeX+0k7vm!lOwXz68AH+9k_prNa+cv4d1 zU}cj=ceWg)6IogVnrvkqiCq$iX?qVlC4^6G?S8c||)0K{41-H;Ga)a>ZBa1QGKa)daXjq6x`JPXRm$g@OfBGH5=VXJiDn-d$+&bojws&Sg{2PYY5f$aq@{gG;qvy%zZw zEJw5HPzLYUZ}|f={t=|jQ6p3OvQB9_3pBGr;IMs@Zc2|qO zZPKgyQIt$ZL{FtaX4gGhSUADFp3%qr=RZ*bq+V5vY1hxz?c}WLrg`Z#lOcDy8u~7+ zE@@Cn4|b^OTrD|leG>8tg?vxuS2nAT>C|O>x9w0zZMouyS!&-Ko~Y zLqh8lW7A4bhW-x?&`Jc;B_OT4eNsC=TSZfM^~Ms`=p$Usqq8SSHNbLvmWSbLl+yub z4zON_T(!KurUnWlz0^$)+mbybqnY#xgu0KAL2faUVr&hG&uCpj?e&`$Ik*z+@#m>~yPzw1e0`N*-) zoA1foTib)l^EI4|s`Qm~c=hIr09650Uw45V#g+xwRuaz~BOa(N6z<1w1B;kq}ZdvstUlhjb%wkHFTGl&DFu-F{=jc{(ENdq>+sG4v)t zO{JU>3hR)JA85(piU0k*bShv}DZrinq z15>gyF*Ghn8o81wT_|UKxU(?#Dbu#A!ic#Q?3lCH05lr+i&0rf(V#t@4Kj0Hm#)80 zse(Q>J)J9l_UTcO9`K?$)7wBU@Mo*%2DNgcN*IwZVcTQY_}brW+X)kDbj#}z2@=3Z z5e3-oxm+?d0G*=TL0_ft4s7*YaJ0neO9r@*KN;_xh0~90AqG+-vwyzFQnv~bXd+6- z;aWKq4D8Yay&3SUDKk>GFma5Ar^V6fObEOz22~k*4W-MMRy2&)E-|<5Bbu< zlav_sG(70OY(eSP&76l zr0*^}5Z*gWyQrt@(aPss-7Z8$0bcNDp0yRRfVwE{3k; zF#Y82H=$11=+J|=9(XrrSFAQ~XnnzzU zbRlty`WCTEML*v8*MDTz&{W!ou6{e1vm1&hB+bB>QuUg;Nr&@4jr)dQvFq2nO$Rg9D&_MjC#+^RF79DJ{DE(5P73Kx6YY z8nM&UUL&kspt1PC!~yc{4KuqHdv36ooKVj(T1Jm@Hi0iW5%1Z)I5@P#S4@5t*byz1 z2(E9La}l>1$(?kuY3>Lfrngg(JB}Z67L%69{%cipk zXeMA0;_m?+V&Rw^JJ@d7Q|+@$TyE50NLH2Gk%Qq?R0$DT7FgYruAT?N7C_aIZm zy^nYP#S4ILpjI&;bxpVB5~yudW$BVtMGrGGpoa$k2BJGam))kK$y7X%!=WA!2mAU9 zmY!yEPwk$vsHW zsRFEl)QQZbW+yU5?Lkj>pIT^MKJ-8TL-!2b0o_>}QeuO=*jlB{xkMgkAl)2n>n=w~ z3fepaE35ivZJaOp&#dmXMx#>~!{u0PfNEm^b#ggUda!zk;>iY#%M0t}=H zxLofEsQGZD^NXFu*qbHkKZ~v7`jXL*D7Nr#j!0wTEg7Jb14IT)+onksR1nSK7$U~e z(1eK@lqI9|I4oM_7Xy%qC9*mR^#;_BcmC-I0CeogOuY`=^}wm8p?y+vc=(u-y)w_6 z4$X@x@CcrgD_;1hrlZZGZT)6~8wp|AcqpZZ^w^(q)(n8GL39D(S_WYyW+j=@*-+xR zKnO?UgGi4|1iOpjvgB=EvRsaIqKtVxVJt1)epxG7xRvDvG(}(STid^s6bfgJ{3P+R zsjd}cuFZwb$2;>xS8dvf84u{VOWI7W^Y1@)Z z_qcN(U9POZksCFwY)L30_n8cO^1E{&$QnNHo9NB&2rm*Ik9Yp@?{`4A+6ooQ>2L@Z zuAgTx3&3u}mHbd*R>?qtqRyf_44b7rxg)^oA@3WpxAoU#TJCEng(+vqn2 zLrMfh?Bf|X_Vp+pG5JLe{f`=;6Y7LAfs6Npkxi^mn#WF{Sh^BM6jd-{FBPkX`DE@} zm!o{;0yt%dO9&;C$>3sWs{&TzzRloP6F<(STn=ee2Q-}y^K~zMJz8d`C!KnEN)iVU zFL0A-BQeXuN_^IwNh%lW@D^?JzimxFrCidLoh}Aljw_76vR>z6 z%o{*Axv&A)I_}18=0q;#$G1Z>%jqy?r$f<;GQr#yp-5K!wa6~W%G(^u@gPx-Nnv8^ z+ELN%X(PQ?7|!d<1(B zi^0^f1$O$*V0{Czm?MVBV%=~nk+@AO~9evn#902YMQ$A3-ESE(gpDc~; zx>@S9LQWq*WN3Y+<1o2@5y)h5wla8fSkW=gsS{-4tu)8u-M>db2Olqd0Nl0xzDbiv zAv+7GFEKPnDr)xvd|G5#B)I`}GAx>r4z?~(r*<|u10YkPWa8)5zf3fPnXM@at)J#J zU z?zC^Z90Dgx-*84)G||9#bE&sYoKq1kBAo_K5@&0F=pb(7`gr&6 z{LZGK!`52=xZF5wJ3}*UH?nwW(7Wt3StVGV5ugKg9S-4t|BdcCTeS#ML;6`#=DIEc zrS)s9sqH%Xn}LR_mQ$Qv+&W~47Q+ZpXjW+^P!#Xdq-R_ z^mQ*^>?awtJSQx4DFQ|x`*bow7jBB|8sL+tye`6gPEdTj`?mq;gP|SNqts|}#6tSP z%s6?3qtw++s-#z8gF?xqYiZZoE+Hx*L=>37zXk_ZBu(a-O50`WWff5QK>izL?_{}~ z${+>LR5UHRKf+-+cGM7h_}wxW~iXRq!iIT7r7#+`{X50bR3 zcHksK%wpPPc52_pyMOC<%#tIfC?SEIwe#9@fa?ec0$rVCZcX=qQs&Ua-J{l^&@nN) z#3YDYcUF9{W6Q13{;me37y{DSU2^2-@R|uqV`+dIl|eZe4!YRixUAQZIQGuF_&c@r zl+aBTMz|P9Q70rs?lYJ?TL1*sv?PawFP}?QjJr87>t|ZFt^kakf$PV+fAhDWlEc6Z zOQf?g{syG66fdC8ivoblO2ds{h=JpAF2Usz#5FhUwJtewpYWUWn2;QceL}j(%`nz> z=1!0%2&!!~Ei}pyPP+XhwsMrpGq|T1?nJ%(;D~dk9{WFA6u5D4Zml zuPb@09u3-doBvaOq(kCHUa$J8<8EE`Dza4a*pt63aWB?}kKGcyVo4=@N(wvEBIMtK zn$^TgAhS?{Ma{d8wjPTZATCdo#L9^{BY}WwU33_XN5wED#)jl80X%gxFrj&@CP5h4 zk-spKPp_BD@z)9H(9B8*$E45`#U?lk)p9TlnCRzd@+3#5F8U78a5C2DlSE0!B*aAy zl1dJ@rfb?a!7EV`fHpncTn-gj#2m)7cfA?X&&?%ZqkjT=pKn6n0000W07*naRA*{v zc-`rpOF9g*!Cy=cbESP%6HC`yUxk>vd*bP}V;-osksQhU9Q=r`!>xU%a&~KpK;O-d zyzYYCYv{lB^@E0%2Ypj5?7A6$7ou^}1Ziw=0$4hmdc1V7CkC|N8i2b#mamM4r1H31 zi8~lNc%^<1F}1WF27T$~QTH}b`&&%wHr*$LaGZtMLVvgJ&=bTMPLH zazzrw&fe$A)ydjty;zfSr3OXGmD0v(*qLKd62zPLqaQjFIE?fZ~IY z%xHj?)=pm+vS7Uj+0iV#si(u6gYFr9>BsIzH{aNj&g(NWbQXb1ZmjL;T%`W)8XNww zf!TfI>W*xEIYQmxZqNw>s1ea!t`>!^^M3cQ0MP55*kQ;HOZI@*^L>ID<;>JNuC*;0 zj8aAk{1eEgzXOCF(A3UcVU8VR@WimrLG7p^^={7(4OMV8axg$kX6d50@&GIgLn<>YKzSzk>2>=Y}7h`!TMn11E^r&TApIx_mZ2H zgISXtZH;&T(r+0wbTE^!329*vmpmb}Lw@Yh3z;#pwQFW>tc+I2fL zv94Ici^gJ|1&jc_vZe--qU{adI08P;Zu8WsRGFWSy9gt(jz10es9abLcnC@ZNiHIlCMFc z@BYQ#+!#8f0i{PUX%R7GN%ojF{hW4RWv`Se8LAX2u)RqKTr|IvQUD<;IhQ*5B|uZF zb3M7v)E59(xf}_oiYH`h18VbY7qI6fZ%dWlQ3sc!RT_IDSe43iU5OB-;_ic5E9u;|RQ&Vk%#3@XntgCSa}eLy*;8_ihZ-ZA9xp4frgJXTS2g zMyW2#~E5BLjsI7ZK};mp>tvK0)UprO*bPnu_cOUrSWB! z?@dO-WC4O439h1qfVM5YHV2YmPQZ3xGnz^#H*mLhhn0v6c9RQ|10jDca>xD;{aFG! zz`H;kx)Dsf-rN6E|Bb+|0Z{x5NpU2KO>Ru(>jw0=wf4Iy-fpTR2aq8-%JgqHRgxp@ zx-5H(`L8KeX;1l27jLF~WSQ4B@zk8}fTz{Qk&1)VK#sYMcvclJg{`7Bj$suN5(b^?QvGXuJD~}XE#1r7;+(eG9)WZ$L*U8ix zM>|w-v^GfFk6@P6fW>B|Z!j{dfywOZtg|9YIUBkEqm*3oMY?m_W8J9;$0rlL56IGT zK{9C^z85peD=D1xabK2q=sRJetB8=UPq-dgO`H?F^r;0@YH38@)wDE%Ii50Xzl_Ks zH5k*O7oHv;`cnk7elfUd>BZXJ&g;}%nr9bZsJnY&$kU6PLfbAuj%YD@%^`u7-V*Tl zzh(W^pvd=FG-*{qCW72PnVQ80hV*m*+JxEOr(qTy^|Nc!<0mU92LRp`Nm!m{HBptj zNs}WYm=~SZz8IuP2>ucwQnZYH-n%{N_uZ}$iUhYs2}fr%WrTWy0P+N z*!9f->B7g`BnQDCRmV7C8%<*I}4r?a9x9#5U{<>Uq$+c+l&XPANAJ_)y>DuO7GV3o z3&$~Wwu4$IO*vd_nR=W^E)fC2(U`h1Zc3Dt3KT71XN5OO4I~eXWvR!Hl6MH4Ea`@= z7n%L~E*p)K0Si#6zb7Wo@;0h_EPbcLfHE^dM^D;TTsm7*Pdg9An;3AMN@dBe6^)>W zGa*Lrv=vOs23=1lfn3tUie>=1J$+;30yZtX1wly`VC(VTH-6Qjp*I$SnS-4#J-uII zHu#`AT$+fT6Y0sxH3sUl6ea-k9uS)u6~;C@bXgH2)--cG#9nA=`Z@Opt(bP5W#%ZJ zET9#TXldm`%l>BRSrkEnwa=t*=uaN|JjlkEop`MR>Yny`jIF!RtT@XBJPEX-5u`)v zW0l#Bp+x}kuu|GNV>9uiGgZ$aFfkNV1n-nHFn-r(?|s9s|6qW=K$akn0}AYj4jc0V zebe2P*$VrEcFsW|aOx-6l6`|AOjobR7u;Woq*%EIL2S1kL;zszhp*8O5*A+ea_Eo=jU>51n}l13wW#!>fS1frM5=D1(-a^ zCczJxA(_B|F(iX$!hgz++=f^%hW?|!uGx0Mbn6+5r6({?W$*fK7}pH>?{ zS=Gc~Tq~$>fUw6pq7XgmcFZi(t2)^LOe@lv8AT@n8pV^kuk2!+G&3Xzf!qNuw(Ver zv`GARJ+;ta&38%eoRmaQ) zyvxqY*0L6tJV7Iwe0Ws{)5TGAMADn~T-lfEo)dHP-@5;Zw0B_cOb+Q8BGeH5c<&E< z#UMGtE}MEgLSLIL*Gl8*ifsL%Q;3%(ydyZL_$W})hU2?~q%i4H1r@P$9)k%;u(yTD z81Zh^F(HzT)x2!X3O|^Tl0=O}Z}F6?mtz98znA*>rBb>;ZH-dwgo{S7WNkPQmK?K) z65mH}MgmsJumQAvcjZ8IGfrniI$O!2VPk#2<0#<$-tYVJk)a=E-<9-ONt${zUcK5y zM@tl?uOTkj(-CYwpJ+)CV_a)=Dp^wDr!;e)sZzg-(xzbX#>>8=f8Bc;tl)qyI{={< zHOfnFw%T>1!5Ga=}F$&yrCUI>5}qw5%BJ^I{PN$tkWYnG@b~4$_t|r#yq! z-k=^N2Xmc6PfKefa+~BZ|0A=o$>CUgc<=Z8+NPmf5yc!2xE4$h)6Od}Z=AyhQDJ7= z7I2;imm@P*UQ8W(-Gc5@>n5b{T&3=P^O+1b=EV)qa2(&UL`|ADKO?XqJ-Q4)n`hZ+ zD2TLK<2lk=09DgyUBN}zRlHuoYqnZc4Gn0Gan0-2KGR?3z(9u_ z0S%T<4;lb{QD&I*a1iHUzpVl`5T~50WK!Uye3<-YVq{_WMn5m~eE92s^=(opmNu6o zOX1hI1?K(lmKaqk7keirGA`ATXm3g3TfZLx@+>}=#56Qv%+zL zwg!XPD+@48_N2cXQ(6Ol*UTw9n!U$_uO)cBp`X>k9H>n8U^%Y)%}Kw#;pkxPDw?cL zLNYaMQIi`exX=a&#z~CLGQy~_p6W;Ud$UC2LmsVD!Q-t z;_q}e1ZFVx7SQMc3Amc$0X>wOxiPerzXf>!c`Z5M@0SNH1CLs|nAdxvi%qg}Wyuo4 zJj=rDpYBOy185aLnuiSAj=dRV(~=OA1xOz{?ga)5<;A*6>hUezkYk%d-ww-C@XerfuO7OatPL@{HumPH- z?P(+~*>YNR)ut2-aj<)_2ra>q8?Lw45A(ESF&S46zxmY3=9sBcWpegQKyI|@R{ZT0 z&I>$&Tl-+Ih3vYTP|y~$X;L8pX~mbOt?`}O9@_-ztbv&pC!PcPWXf_o0gqn%>c-mt|EVt36piO8Uv znuEY)NTv?Dxw(A_4C28952Iu_l}t?qS@d3lA)!o-0qc0uEInnF8P2?Jj$hB z`FqKa4uHyg&WLAj2fzqTy`DAiue3ocZ?jb)pt;d8mA#Fv9n_Bxf7P!jKra~#jXYR) z-OekZ$_(u*199w_&pA%qu06#Zciy z*kHXKdeziK@YVNwaf6P9)^%p_<){*(o6nXcpmTv_)a*Jd1hoXPgSY$7DL>$|-`+dq zB>;ZuUQ}=CP6%}z6baOlhN+DkPKv}_iaefw{VyL2D-K*qk-?izX9MN(HV`)Gp`-J) zPNso57@vY7Ld2-8JMe3?$1y2Ez!v#EK74^&$;x8Wt~aX;UddaWZEWve^kCNslw3}C zawM$EhrvS6dPyDhR^$MLm1h7)`ChV$j18B^txDX_&cZD!vZ+78mr}1lSV=vw&@?l> zE&)9K=$;flf8{T`NeXXd#sOB%J!~C$vgWerLR&9R5v~VohkKx&mfh;fPH>({;{@#7 zi-6q9+ZiaWK;R%gs)ckktgv#;`fk<3WZ70IF-Z~tYZXItI%2D1@Ba@i{baO>aJGW@FlE|O}TbB+4$E-Lc*m&QG z!)pQMA2*+w31SWkuy@O`x{;u$4LmTJr->go6^~UuHHYxFxY7gxYlc7+qXVv zfVfsP8k&}3U|zp5cpcv(Mgt?OoZae^9=6EkIY{LsaygtNl8g??7inK=YH8*Z%xCet zNH6w6JU>02zwC>V6ox%^{j;16_CCjJfSMh7Q_nO3zg0x-GkwL@v}Dqwm&{dZw2I?d zTq(eVtvj2T2E;yYwPqxKrdOdw+W^RQIhyyIjNfN}zeR48O?M+VmL{)(C+%C4b|W=r zROz_^@(0Ts2=0~~7HA#RnV2UF*I?p6IHYD{?K6*J64=50^z?ZCRRFXBnTg;{GDv-# z)?Ixslfg(FjnSDX;`M$RK5xp7S{6a`3s1I&J%mt|l|Sthl`Us#HY~X%w>{^0MAE+W zdj~trmIPy82ONq@WatwB$se5Nm}M$dF75A5X`G07;AhXZN6uVRNtr1k)i=XB}Ea z#r8IG0eI78u|ErYdLT`XWOWeUvOH&oVguDMqop?~WzrUv#q03Ll&#Lt#lq!U(3-0V zl2t)y;Lggd`t@HSV4p_yx75HTU>&?ea%gTxJrhd`;W81>O!!icvf>ENKDBcdS02w_ z@=M;V(Ufunk|BZolKhYyKxHu`#FiYrdKipO7N#$q@+8vW3#Fm;kWXPL92oB@|pqELaf*(}h+*T9Kn&8mXEQkVY2HTC(EJb|iY2UC5Aig2$}z>etu~$zb1wrp^(@7?qWRuelD8Y@QATHfh^^M-Wp-

KU{*1i0Wch+;0=gyY{=R2BC!7n^otbMmNpM^m`U}3mZMy(s z0WyHuWymNu0w(mQ!N!%?#Yvi*@~Q?wNR+hVnzrsBt_#&A;wRjam9|J2lxP$+84Gye{mwV$*u z-57oy$9}g(y&-3@rx$n1?Wi9Vm44niLk-5AYlH)piCt{L{31cxs;b%-0gT+mwRD$^ z9&dXoyo0+wC)D@(ENDG{9sw=g+ZbBle5q+kU^J^QP6tWhI{ls$E~c(YwkM$L0i126 z0`2U2WDskf==v5}JHZtD$wEcb*OnA&pb%(NRq)NzvJ#KZI59Nz@+#cKaMK`bdVCFI zzNhQ3t~o`Vk`)Q)CNHX0v@H}rfIGpzB#7i&@2+cLqwQBiDa=#?nqVh%EA6{y?-(}E zpZod86s&Y2l=uZJ9}Aw7ndN3cTif<6sgqP$r}|3zSqWhLopxV=HW^>uvbh>eNRopQ z`usNw31}7|vNWCx6}IxRwBk{yIIS|95UW9AlK^(u-&)s;ISIi0Re;uG0*{%VcO{1b zx;UL1x-62MN^0~%P{#N|TCQ)Gm19KE^x;t{XF#|*AfQf&Fc=Z;gbc9G7T{de^+-iMZ^ z4)*mp+{#Plm+U}NsD0$W=0)q0&hoc=n-ww-Zxix@GNfKt1<__}NDDIgN+7hJ2Mh!O zunH>mGZUM&FJ?YLot<(7ACu0UArk=V=XDfUCdt9RW=ID~2;D1mCax!->!y~Ld^y~t z0Ax!HPlxJWa@4>mNDI)XwwC1h>|byII)MxO?Y8@lU37i>mH?*y6=P$@xIE|?&~bBa z*Xr1;?Fq$AC==sYQs_SF)aYL>K(D;Tkm~&!OAB|l9vE7hIe*ks#SF}PnOaFz=s09g zC3x1@)a6X(5CpWG2{;sazhV&TE+=y%zu~swj5z1&Iq6-Gw&ZVTVglNAwm>i0@p%5M zpKpM6wyj40t#w<||B@$fTbHAHLe8baATctjY*HgFyP;CpTo02RM^CLnV(aq&+b%Y} z_L=<+L%S%MQw*DlB4$@NE6*m=E(`Z`QU2gsVU z5lfHaw^ptv?uC=ACZ_)kUpkY)Dvite*q79*hmkqE{18K9L=S8uePa(e-5 z=y0n94VE7g!Lrywa;(qvo8+a@tE$&4nbh3Z`IUsBedkFg5?c*Q7FfcySa+-IW^;u4 zG^_w(cZ`N-Qh>(b$aUs0Fc52RZMw{xm=ZocJzoDT0lF+ZYd|`>?s^wqoXAiL-*TG_ z9evHx&SLm1vQ)xY)*aq6wwCiT-H5Ks;F5rGEPE$Y6VTFt3hlwh@^V$V%Zgk}Ak)Es zUN+D8eJxBA=V%jc1wybYS8Yl__M{RLDhJ+X-y9^P%*O4nKlA6hhJJ-vB?5Ezp96^7WN2=N7+fr?B+_QUpobcEUo90D z;Ya{p_2`S?aT7*8-EF&Ib6R}I6s%&H%-pjB?`&N1vNDsxo1`-PJXndG9S+%T)@^tA z(s2)N7{6EUr2hZeC zni~a}-oRu&u)xjAVThnCf>1gH>o&K8EL!%Uwhd{bqK3&GA0+8Yv=X*AFnL~4U2?MR zTF=EOE9#zUFcr`UMNisYe&`#{z8%QL#ICm~Iv%fo+Gly`J3vIoc0pEFjWl9<~81TLHp<`W+VCY@(>^H7%(4pIsp<(QaRVXW9T_p%C#H)9rmNq!n z@CfPhz)vc7Ky~XbeQujj0I+7+0UROY-W~dz?**rOT+{#nAOJ~3K~x(vl61|!f#>eV zmKYrHEs36(Ilf~6zGdji(Woca^B+(DW@n=qy&(N~{TTpsHu48svI%KeNM<`_^NL9a zpmP?Am^l(f=U!Ob~sNFl;{R>RW)fdpgJwXVM`;QUV@! zYiDbwg|4&7)9G@&{`8;omMK_WX1J`_c)XJ$kpy14yaO|2iuG`0MX_t>4a)t0T}LZn zaGtI$*C;g(=flp>JZ1MU0-EwcnSU#^BohYvv9XDE9dz=R94&$4Kw~lHx);{>0ft2% zMu8fo@U-oml~1<^Qo%g@V)(qjEWF^3_q5O@iA#}C!uf4;Ij;RHCkvOOv-IQjPa&WS ztjU%av%Y%7tn^8K$XWx?C0nf5L-rrQt%YO=c=^!*;B`U0xhb0Uck*zv8fk#|WM~Ip z0ea{lGiQRVi=P@EG-`IUjm}2v3n4(GSdu<;1FT60`pD^rLu;EfiR8G=A3S6RtzRQz;nAXdYU3VJ=LlP_Mgp315k-79Xp|Iw>d*G1FeE^hzL^vXWOq$T;d(5f zL)v1VNa^Pqk{z$_TkC1**z%G0S!qG_Z16`FG0V@bQn(=1*;{=l($(~n^?fCn z_OH7aR!HH#DCrvDkEqzB1+$*6pD9^3;Sk^faVx4(Lw^bYEe)Gi8s){^>o~k=?2reG zd0Wz_6UoY81MWJ9JIQg%3YQ^FCBol5G<4mdb*Q6X!6yW)HR8Z_Rz>W~YGIXoFN&~# zn*Gl;2ncqs6gI~pot2PM>2$>PCR_Io!5{(vS>1K~aZ*LBN*sF*!u6n`rw_RiGV##M z&e|{KKuJ3W=psWt?b6Uc`DcyINr3}fk4{OTDuMZv6gM+itc-4sMh${wVzccqdG?ST z&G~S3485$BkV&R|WqQWqhqI3%ndzpDG#`F~s4T#dq2;19nXv~}%^oR0S4rCDJT9iK z?K^8%n`lXasa$P9PVb%a0ad}q#&cfM62^*ymd8x&?TKVDa=yO?44C?sp+EU&p8e3> zN=xQ$$)BV}$P}B7C6`@8}kfubGC@)XM6G2 z0%#iqouT10R7tw}GWLG9{!h?aveyP$PY#pGOB0_BNC0os*z4X+&V{)hO>ZZU@|S)o zTj*2?upbc6WN0)$m4d$dp+AX$7TCID5%2c|G2ILY?mlkwr>~m@X_Cxo)x)?zde)L0 zNDw!u+4~$fX+3=0Bm=axF?gXTgyzGRV<0_gjZ6ZklB5WU2|^ZTvasT%W%GzvPlwxn zloItiqE?R*o!JA7uhc;ovr#BL$l3(&zFta?Cf|&WPg2CoDj&B>g5P#rOTmZ!Jw&q zt-A<0Y+IZdI;(*Gu9o5*OgoUhM0G5x1BU}fz|J6mnRXXO%p4UV(rluUA52s+%Dx7Pn-n( z^mzUWKm91I(1Wfr_fQcG(A&&z3*}ah78>v{FE@Uf4H%=|0=*!+HglDFTYnw-U8N8EYbOFE3Z2rm`EU*v2Mt# zqr0w{xaJV1q>ppX80&R^>Zae$tDW(0DiI6*=?7f85moS z@M7jTmp+3#{bNat4N(74OL)|BKqMi^xM{(iJv9G6DoD-n)y{iiFQ30v2 z=5(#*IMKH2@>?rAZ7*j^>P* z%usSkt8VQ|ebfPh*6-o0+;0*SL&*%VLe6aQ1SlngB}%QK#{ktD;}6zm|3%4-O7_~^ zKROy+mMBR~ImE$HNo1ECsf`=!YT$41E2gP`? z7rz-A#dt7w1AFNkfR~JrYns*;m^y(rfSU;-5>6(H>#U5mOCMK{Nwt=)D_sMpKzmdR z;j-+=g9J03isoq#%)I1`SXTRpq*6f3sJ8~ic6BBYDk;<;%58R-HZJGi=j;AF3&<%U z3(rrF*FW~BrX*SDCnzNu1TuXVj5}%LE!`raX#1k3UbljaIxbd*hPM8V8W`&~K3>Ib zq&Izz33;dCDAtx{>k<+C~^SY z0Q#`^;FwsYuNb=n+FAJSxl(d`{AZw2ICRq`8~3idk{$R6kQkZ}-vF2J zS)aWAF#vQj>J31X^&w3fAlc`!!MDWpR6|Ayy&u{^-0++@#dmypi!x zHrPt0*z0`QF$B!Z?T$uC5SGW4FjeuS`ZaREya`)PEO~VIK^B-h0(oIcD|L3(-q~;R#?dwvIW;1099Za9TL%PCAN($TJA@HMF{vqX+i zyPJe6={i_721@zZO}1#w=U_$a$^+xBb7r+pa}Q&%ZNRnCI3x(Gnw1xv)2#OON`^R- z7X-JhxPR3fasw<}mBH>g2k6q}sPuAw;+9^(GLy#5h>|oDOFO6^uYc4}aX?#QC;-Za zo59Y4Nl+iHS}}^Z~|{S$ok-T`njHzb}V z&G4an7v$RkExfUhW0D*Mbiy17-gTnkepE?(kPcH)7?LCtM(XHwE^X-oI3+nE(T^d9 z*F3}n^ck8eEOxz@+-&9S(rp>wv@BSFI^UNCW(i?Dj;rD)K*s~FRg}PN7lXqp+tNab zVGM(i7#k3@_ri$)+x8v|_Hs;3M(KCIO={$^o9<1meSe?x`ANgKgzyCPshtUO-T#xB z_a_P6=O6KDSv*`CDIEnd^hGUNdZ7E%aS}=k|l z6Ug=Y@%o2?4j$0bQ50kqvwa3N4_|3fh)D~Sl%>-e51?o(>om|EKs zn8q-nbMw}v^HI7v6?tmx)0<7J4L~u5&dxseT1a|K-92@BO2?m`S2Dc_oFo zN5QyxH%x{QU>r>Tz6Q1=NB{SThy$QYM+Y%2NSAi5eHNspol8qoZV=Ev41iAX#|tv= z!qeLr8cAc$=+=pb&6fQdkQ@Z(;D}TLh#B6Eg>{p7GwSA+L`pl2zH_khVhL($mcmu| zko%B!m?u-S1ggac zWTa@GbXt!_BKsT!y46s0Id0^OzH}uY{s56U_8BS(06n-`2BA-BO2w%J%z8iK z7{;*Pb+?WXjJdsPI1rtgAweoptiFt+S}3NTWJh}Ts6I9@pZ(7z8|q$43N_7~NuRdF zETkYECWe5lG;mVWextU?uV3%J{=q-_UPGsT4u-ywBHoc#VqNds+IMEdA^aRHj45AW z=49ENWC=D(rGeQ5Z;aN~s-Tk*@?dM|-GA&XtaG8~+~yVzI$7cW zWOPXY6lYT7=Fo}~y0oy*9o+YGX7Rm;w&3|B@8~EYQX1%Tn-285(s#KSrKQQpe6G~c zKZt-XEAEzondCuVzX{N&cl0Z^MwSL2NnXE*y-GUpyNB}@mW zYw;~RZW6)fh}75&hPss)7xV1GP}gpgo~{a7>1vlHmB>_r#d(JToI_G*F7w&qS(2q( zf}9W=fRzuA9a_ba)^=^%wOhv(}4^< zqjIfcadET*`i5}@+6}s5*#dJ<6a#XtNK>=JV{oLTiHjC6uSTOav&UCUQldCBH7{Ji zbB0X`)DoJ!q2@sstJR*H<&_}ebgYm-U|?rE7FL+>71#^F93U4Q{w=r{ zTYgk)^Iy#P}iWqXR_v4{X?Qj8DR{Xg9EZ9uB|4% zs;oXGhhpX`qNK*XR(*>5?VXD~f?>aR5pdMfX98b?V+QrkttdCNk83Hg?@xw?6zGn| zAUCSe5;kAA>nKlb2FeXfGpWlH_LJYT2N&AGeUpX11pGsj+f0@wUydpp%gym9Zk7qq zAh5x3=xqU|fZiiQ2XjdpNQPFV2zXWPZjFb|ciMxvt$4$AyxrpcK6TY)WZeLmw{i;5;+^ud;l7nmrR(JoJkUh^TFjvMmzxQ z1BK=E3L^t6=dmS6psUH)$fU(83}G55#W;S0oekMsvm5BSuMIdd9G4OZpoc|8b|xTu zVP;nfH#6C6>=#*I=jGbF_EGX+AFrJ=OXgbdL_qCKy@+6t7j#i_;;L)zjlHg;#mINk z!`VE)f2X0z(AO@nwK6I^!*(N>0sO6J#OUs0C)2;c$~o#%OXC6+T_n_L&-Em5V%!W_ zr@r=u9%GOk*?M1KM(0+*vBARRa7YMku$bQ+=a-)HX8Jt{lm`ST4`35qXYVs-boYdj zfA8yXn{Qt&BU_wPGBuo(QKDegt7g+tY6$2>K;+_zkpgppx(0}d`tkg6lpOK4>D?Pp zn^c$zGQKJjWl9c$7#NHOlsl%8dJ+j)8JiNI zWapE)wPnG(H1!>*AI~56Nm`2&5rK{O1@e?DlY7w;z>*v;F^=Mifx5=6Cw>S=mJs?) z=)g|)e2XNp?{)AR)5iH_*p6F`a4WD#HYnJiEu3+7X5WeVAqzU|Lob(XViASicOegK zA6ODBf!b_9OZZ^JHK}387trxtXW_fD^*xa97`rod;&N%|1hjw`xNe{g9a7&-nE_4P zwDgn|=BR1X#_TkXXu25onw)xO!iUv&$HoYS)YZ9EL7NDWgTxpU>&a;~BC=ZdoO6)) zfDkjLicWn_Z+F(eDH-g%ytTCU4`vUUpG~Ht?$@b9;IY%%^!@1MUV#@uM0ig3~aB;kXYY5Yydg6Txltj z4hgD?5@h2pGb#Z31Gk45UhxPHfS3y(9t}R`W^|U{Y?}-}*lWr9NB?v4lUZ=-(nmja zVR8Z*T1Aaum6AMxC=K3M=qa7Owu$Ngb@NINoMH8@__D7_GgdV7y1-W~ zPL0YX1+oYFNGMMR>;;p#^2a2Pe!0#{0~en)AdlC7XzEHROD3FBg5XRdG^Q>}ad4jz z_zu*M=a20SEvz-?BH1;@LQ0Yk0_uH2E6-|Mphe#<$4!0mbMj8YD&}wr8^fdG#<67UW_`l;kbX7 z7f4oHr`$dhw>0}bOcKlJ`*!W@Nnt%-fw>4P^~2NS`C~uvob~+@Qxn)s1Q&P|+AGA{ zNX!V)LCT~L-)$FP0F`c!hpO%FMtg#q4A3-qFLRp)Cj6lh5BVV=Z?2{+p+2Vs+1teS zEqSY1BdyWM+!ZEteIPX#qkHC)AKloO49!99ul=|V=DU99R`CrIIwfO^ri$)~VV50` z=kL=2O_@P}_Sg8)dNS$+>I0ZAWpbpYt|s7Jdi0qq9Hm?06v(@d^T5Ty5I5Z#l6qqk zZftM?mZcp6=MUnY3^iN4aog(-M3SkxW?5z z#nNse#Qa&*Ut4;f#5A(AR*)GQo=QV^K#QH5q)4TJ;>#xM-covMU(?H}rwi1nwIN3+ zO$5}u4R%50cbjI;EcH%iaO8`1OF~q*z0q~u7f$(5`qV($6R8{o?DTudavOB64Xn`1 zR9*og8PqwuY$&$upss6_lsK83pl*P@Fp&K+lB|(eA^9^<=Wx!`AMXee5NdW^0gw9wKTe=-@972^80Y_?;HF84_ifZnJ||czJIUMxyE<7vw0Tf@dNZOO*-rDr&yr2 zeT7uucZv*(efKqA=djmE3izd1bU8_|_FuaK=JMF7Kb{^2Xn<6<8i;N`b5gb3hr_cz z9SnYtjH`iB@)~TJwjA`<3x$_zFZhQ=XZ z2)=8JR?bdfoeAR}YUj+A1begbJi#o$pHi)4k`L$o9RXe-G$h~yjB!UeHG)VbCiHWz#raEH>zDEl-HN{{U zHLt^X>Wl6X%vd_*RxmV)D&aZ}jKTt+W&*ln^E&r~p_7E&i5Qt$j2-{F1xH{G%&xHw zz#kI=B4NmzWed!3bjIse;_f$ry!=+sDtT%Qv0+k4WXXqm==E?z_i{{E)H+q`AJaA)QME+*<%# zjY=xM2-r9-QVo|KuHOz`A1x#HK@Sf~$=+!54eSlsp$FZ&-|BA%{4vwy@QxvdQjQcr zdqS%I?S1DuLx)px$QH7+4}fCMvN==M9gf8&6+HtZn<-Jm`gal}h2ReDuy9ru2n`HJrLtJrD}Oz~Y&yHKbuno z)dDp#;9k-$5ZAzSrqEGifW*G)T@P=?$P|G0tugqV+Zx) z`Flb`Cl=^(#GCw{dB*+oxs$Gr&*0J839UU;P@wMay%_{43pH_0(5?Bv;izo610`%a zSCPRb$NH;#)$J#hCl>49F)47^e3dZnDEaj4hF%NWZUt?Z<Pwtc6S zBi!w?4@mjc8W{e09|SHtDr$87>-Xq7vEIW`!^#IdW;W%$D)58WHU?h=_NAdaLsOF6 z0JWqGB}M_cl00`cq=o5>7(fRJvH`ArBZM&t9DPlkPc#4-8)qmZGeA@%3-_Lm4v@Iz zO~(ps>)*-A==NO}L8zB&8ibz3A}nefg0oY z7S#1KV6|G(W{^^O4b+Xs z4b&DHroIj8YDx`f^L@l>fa*N~%p?E+0PjgeK~!vQ`Z<{T90Y{6Fa@&My~eJ?33!q% zNO%VH2x^GvEOi6%gT~&+v=5S`^}>vGoa8(u%R^D_BcN#mK^Ko-yxOOOn>JW>8+05z z1@@|t-GyHMu3@vWMwSksH(O85`!(c3RwS7F_ul40#PYt&ry)2?u2@8Ax_J-Wml8#t zPyL&qPM3=J(O*}7qdQM#zXA0jJ%V0GhW`H*(5(aP(0ZI-*tBu-4S!iG18BGHD)=8h zajwTvrIpml6QHm6Qm@`Q5#JGjW@P9C=(_>kyyX~$-`NcPT@ld9rZd}17Huo<|Ae8x cYXJIx0WT9xb~zgu#sB~S07*qoM6N<$f_u*JeE >({}); const [importingModels, setImportingModels] = useState(false); + const [showTutorialModal, setShowTutorialModal] = useState(false); const [importingZed, setImportingZed] = useState(false); const [showZedManual, setShowZedManual] = useState(false); const [zedManualProvider, setZedManualProvider] = useState("openai"); @@ -3497,7 +3498,7 @@ export default function ProviderDetailPage() { {t("connectionCountLabel", { count: connections.length })}

- {providerId === "adapta-web" && ( + {(providerId === "inner-ai" || providerId === "adapta-web") && (
+ {/* Inner.ai Tutorial Modal */} + {providerId === "inner-ai" && ( + setShowTutorialModal(false)} + title="Como conectar o Inner.ai" + > +
+

+ O Inner.ai requer o token de sessão e o seu e-mail de login. Cole os + dois no campo de autenticação separados por um espaço. +

+
    + {[ + { + step: 1, + title: "Acesse o Inner.ai e faça login", + desc: "Abra o Chrome e vá para app.innerai.com. Faça login com sua conta.", + }, + { + step: 2, + title: "Abra o DevTools", + desc: "Pressione F12 (Windows/Linux) ou Cmd+Option+I (Mac) para abrir as ferramentas de desenvolvedor.", + }, + { + step: 3, + title: "Navegue até Application → Cookies → .innerai.com", + desc: 'No painel do DevTools, clique na aba "Application". No painel esquerdo, expanda "Cookies" e clique em ".innerai.com".', + }, + { + step: 4, + title: 'Copie o valor do cookie "token"', + desc: 'Procure na lista o cookie de nome "token". Clique duas vezes no campo "Value" e copie o valor completo (começa com "eyJ…").', + }, + { + step: 5, + title: "Cole token + email no campo de autenticação", + desc: "No OmniRoute, cole o token, adicione um espaço e depois o seu e-mail de login do Inner.ai. Exemplo: eyJhbGc... seuemail@exemplo.com", + }, + ].map(({ step, title, desc }) => ( +
  1. + + {step} + +
    +

    {title}

    +

    {desc}

    +
    +
  2. + ))} +
+
+ Formato:{" "} + eyJhbGc... seuemail@exemplo.com +
+ + O token tem validade de ~360 dias. Se expirar, repita o processo acima. + +
+
+
+ )} + {/* Adapta Web — Tutorial Modal */} {providerId === "adapta-web" && ( 0 && !raw.startsWith("eyJ") ? raw.slice(eqIdx + 1).trim() : raw; + const lastSpace = stripped.lastIndexOf(" "); + let innerAiToken = stripped; + let innerAiEmail = ""; + if (lastSpace > 0) { + const possibleEmail = stripped.slice(lastSpace + 1).trim(); + if (possibleEmail.includes("@")) { + innerAiToken = stripped.slice(0, lastSpace).trim(); + innerAiEmail = possibleEmail; + } + } + + // Decode device_id from JWT payload + let innerAiDeviceId = ""; + try { + const parts = innerAiToken.split("."); + if (parts.length >= 2) { + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const payload = JSON.parse(Buffer.from(b64, "base64").toString("utf8")); + innerAiDeviceId = String( + payload?.device_id ?? + payload?.deviceId ?? + payload?.["device-id"] ?? + payload?.did ?? + "" + ).trim(); + } + } catch { + /* ignore */ + } + + const innerAiHeaders: Record = { + "USER-TOKEN": innerAiToken, + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + Origin: "https://app.innerai.com", + Referer: "https://app.innerai.com/", + }; + if (innerAiEmail) innerAiHeaders["USER-EMAIL"] = innerAiEmail; + if (innerAiDeviceId) innerAiHeaders["DEVICE-ID"] = innerAiDeviceId; + + const modelsResp = await safeOutboundFetch( + "https://platformapi.innerai.com/api/v1/ai_models", + { headers: innerAiHeaders }, + getProviderOutboundGuard(provider) + ); + if (!modelsResp.ok) { + throw new Error(`Inner.ai models API returned HTTP ${modelsResp.status}`); + } + + const modelsBody = await modelsResp.json().catch(() => null); + const rawModels: Array> = Array.isArray(modelsBody?.ai_models) + ? modelsBody.ai_models + : Array.isArray(modelsBody) + ? modelsBody + : []; + + // Filter: enabled, available, text/chat category only. + // Use ai_model_categories[].unique_identifier === "text" when available; + // fall back to llm_model name heuristic for models without categories. + const nonTextPattern = + /image|video|audio|img|vid|sound|music|voice|tts|stt|track|clip|avatar|cartoon|flux|stable.diff|recraft|ideogram|leonardo|magnific|bria|seedream|luma|kling|pika|veo|wan-|heygen|did-|vidu|pixverse|sora-|gen-[0-9]|playground|gemini-fal|gamma|lyria|clothes|whisper/i; + const textModels = rawModels.filter((m) => { + if (m.enable === false || m.unavailable_api) return false; + if (typeof m.llm_model !== "string") return false; + const cats = Array.isArray(m.ai_model_categories) ? m.ai_model_categories : null; + if (cats && cats.length > 0) { + return cats.some( + (c: Record) => + String(c.unique_identifier ?? c.name ?? "").toLowerCase() === "text" + ); + } + // No categories field — fall back to name heuristic + return !nonTextPattern.test(m.llm_model as string); + }); + + const models = textModels.map((m) => ({ + id: String(m.llm_model), + name: String(m.name || m.llm_model), + })); + + return buildApiDiscoveryResponse(models); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const fallback = buildDiscoveryFallbackResponse({ + cacheWarning: `Inner.ai models unavailable (${message}) — using cached catalog`, + localWarning: `Inner.ai models unavailable (${message}) — using local catalog`, + }); + if (fallback) return fallback; + return NextResponse.json( + { error: `Failed to fetch Inner.ai models: ${message}` }, + { status: 502 } + ); + } + } + if (provider === "glm" || provider === "glm-cn" || provider === "glmt") { const cachedResponse = maybeReturnCachedDiscovery(); if (cachedResponse) return cachedResponse; diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index ef8ad00e395c..cce285d1686c 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -3309,6 +3309,88 @@ async function validateJulesProvider({ apiKey }: { apiKey: string }) { } } +async function validateInnerAiProvider({ apiKey, providerSpecificData = {} }: any) { + try { + const raw = typeof apiKey === "string" ? apiKey.trim() : ""; + if (!raw) { + return { + valid: false, + error: "Paste your token cookie and email — format: eyJ... user@example.com", + }; + } + + // Parse token and optional email (format: "TOKEN EMAIL") + const eqIdx = raw.indexOf("="); + const stripped = eqIdx > 0 && !raw.startsWith("eyJ") ? raw.slice(eqIdx + 1).trim() : raw; + const lastSpace = stripped.lastIndexOf(" "); + let token = stripped; + let credEmail = ""; + if (lastSpace > 0) { + const possibleEmail = stripped.slice(lastSpace + 1).trim(); + if (possibleEmail.includes("@")) { + token = stripped.slice(0, lastSpace).trim(); + credEmail = possibleEmail; + } + } + + if (!credEmail) { + return { + valid: false, + error: + "Email is required — paste token followed by a space and your email: eyJ... user@example.com", + }; + } + + // Validate JWT structure (3 parts separated by dots) + const parts = token.split("."); + if (parts.length < 3) { + return { + valid: false, + error: + "Invalid token format — paste only the token cookie value from .innerai.com (starts with eyJ…)", + }; + } + + // Decode payload and check expiry + const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + let payload: Record = {}; + try { + payload = JSON.parse(Buffer.from(b64, "base64").toString("utf8")); + } catch { + return { valid: false, error: "Could not parse Inner.ai token — re-paste from DevTools" }; + } + + if (typeof payload.exp === "number" && payload.exp * 1000 < Date.now()) { + return { + valid: false, + error: + "Inner.ai token has expired — re-login at app.innerai.com and re-paste the token cookie", + }; + } + + // Verify the token carries at least one known Inner.ai identity field + const hasIdentity = + payload.device_id ?? + payload.deviceId ?? + payload["device-id"] ?? + payload.did ?? + payload.user_id ?? + payload.userId ?? + payload.sub; + if (!hasIdentity) { + return { + valid: false, + error: + "Token does not look like an Inner.ai session token — re-paste from DevTools → Cookies → .innerai.com", + }; + } + + return { valid: true, error: null }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} + export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) { const requiresApiKey = !providerAllowsOptionalApiKey(provider); const isLocal = isLocalProvider(provider); @@ -3409,6 +3491,7 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi "perplexity-web": validatePerplexityWebProvider, "blackbox-web": validateBlackboxWebProvider, "muse-spark-web": validateMuseSparkWebProvider, + "inner-ai": validateInnerAiProvider, "adapta-web": validateAdaptaWebProvider, "azure-openai": validateAzureOpenAIProvider, "azure-ai": validateAzureAiProvider, diff --git a/src/shared/components/ProviderIcon.tsx b/src/shared/components/ProviderIcon.tsx index b6619061339f..60cb9bb5e36b 100644 --- a/src/shared/components/ProviderIcon.tsx +++ b/src/shared/components/ProviderIcon.tsx @@ -38,6 +38,7 @@ function GenericProviderIcon({ size }: { size: number }) { const KNOWN_PNGS = new Set([ "agentrouter", + "inner-ai", "aimlapi", "anthropic-m", "blackbox", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index b6c96824d1ff..5d74a1124114 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -375,6 +375,17 @@ export const WEB_COOKIE_PROVIDERS = { "Copy the value of 'convex-session-id'. Also open DevTools → Network, copy the Cookie header from any request. " + "Paste both values here. See provider setup docs for a step-by-step guide.", }, + "inner-ai": { + id: "inner-ai", + alias: "in-ai", + name: "Inner.ai (Subscription)", + icon: "auto_awesome", + color: "#1A56DB", + textIcon: "IA", + website: "https://app.innerai.com", + authHint: + "Paste your token cookie and email separated by a space: open DevTools → Application → Cookies → .innerai.com, copy the token value, then append a space and your Inner.ai login email. Example: eyJhbG... user@example.com", + }, "adapta-web": { id: "adapta-web", alias: "adp-web", @@ -2757,6 +2768,7 @@ const BULK_API_KEY_EXCLUDED = new Set([ "blackbox-web", "muse-spark-web", "deepseek-web", + "inner-ai", "qoder", "google-pse-search", "command-code", From db9cc272e7d45b62b6becde59af5b8e469607da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20=C3=87etinkaya?= Date: Tue, 26 May 2026 06:24:54 +0300 Subject: [PATCH 27/77] fix(antigravity): default exhausted quota to 0% instead of 100% (#2700) Integrated into release/v3.8.4 From 8d0f751b85a638b2246d1c68a54026baf0aeced9 Mon Sep 17 00:00:00 2001 From: Hernan Javier Ardila Sanchez Date: Tue, 26 May 2026 05:24:57 +0200 Subject: [PATCH 28/77] fix(reasoning): inject thinking blocks into Claude-format messages for Kimi K2 to prevent infinite loop (#2699) Integrated into release/v3.8.4 --- open-sse/translator/index.ts | 48 ++++++- tests/unit/translator-helper-branches.test.ts | 121 ++++++++++++++++++ 2 files changed, 165 insertions(+), 4 deletions(-) diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index b37ccb28af8f..244c5963d7ee 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -1,6 +1,6 @@ import { FORMATS } from "./formats.ts"; import { ensureToolCallIds, fixMissingToolResponses } from "./helpers/toolCallHelper.ts"; -import { prepareClaudeRequest } from "./helpers/claudeHelper.ts"; +import { NON_ANTHROPIC_THINKING_PLACEHOLDER, prepareClaudeRequest } from "./helpers/claudeHelper.ts"; import { filterToOpenAIFormat } from "./helpers/openaiHelper.ts"; import { coerceToolSchemas, @@ -290,12 +290,52 @@ export function translateRequest( for (const [messageIndex, msg] of result.messages.entries()) { if (msg.role !== "assistant") continue; + // Detect tool calls in either format const hasToolCalls = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0; - const shouldReplayReasoningOnly = - !hasToolCalls && canReplayReasoningOnly && hasReasoningContentField(msg); + // Claude format: tool_use lives in content[] blocks, not msg.tool_calls + const hasToolUseBlocks = !hasToolCalls && Array.isArray(msg.content) && + msg.content.some((b) => b?.type === "tool_use"); - if (!hasToolCalls && !shouldReplayReasoningOnly) continue; + const shouldReplayReasoningOnly = + !hasToolCalls && !hasToolUseBlocks && canReplayReasoningOnly && hasReasoningContentField(msg); + + if (!hasToolCalls && !hasToolUseBlocks && !shouldReplayReasoningOnly) continue; + + if (hasToolUseBlocks) { + // ── Claude-format message ── + // Has tool_use blocks but no thinking block yet. + // Reasoning models (Kimi K2, etc.) require a thinking block before tool_use + // on multi-turn or they regenerate the same tool call infinitely. + const hasThinkingBlock = msg.content.some( + (b) => b?.type === "thinking" || b?.type === "redacted_thinking" + ); + if (hasThinkingBlock) continue; + + const toolUseBlocks = msg.content.filter((b) => b?.type === "tool_use"); + const firstToolUseId = toolUseBlocks[0]?.id; + const firstToolUseIdx = msg.content.findIndex((b) => b?.type === "tool_use"); + + // Try reasoning cache first + if (firstToolUseId) { + const cached = lookupReasoning(firstToolUseId); + if (cached) { + msg.content.splice(firstToolUseIdx, 0, { + type: "thinking", + thinking: cached, + }); + recordReplay(); + continue; + } + } + // Fallback: inject placeholder (must be non-empty for kimi-coding) + msg.content.splice(firstToolUseIdx, 0, { + type: "thinking", + thinking: NON_ANTHROPIC_THINKING_PLACEHOLDER, + }); + continue; + } + // ── OpenAI-format message ── // Skip if client already provided real reasoning_content if (hasNonEmptyReasoningContent(msg)) { continue; diff --git a/tests/unit/translator-helper-branches.test.ts b/tests/unit/translator-helper-branches.test.ts index b0cfddd0e983..d206ea0b3ec8 100644 --- a/tests/unit/translator-helper-branches.test.ts +++ b/tests/unit/translator-helper-branches.test.ts @@ -547,4 +547,125 @@ test("translateRequest does not replay reasoning-only messages for non-DeepSeek assert.equal(result.messages[1].reasoning_content, ""); assert.equal(getReasoningCacheServiceStats().replays, 0); clearReasoningCacheAll(); + +test("translateRequest injects thinking block into Claude-format messages for Kimi K2 reasoning models", () => { + clearReasoningCacheAll(); + cacheReasoningByKey( + "toolu_kimi_claude", + "kimi-coding", + "kimi-k2.5", + "cached thinking for Kimi tool call" + ); + + // Claude-format request: assistant has tool_use in content[] but NO thinking block + // This simulates the scenario that causes infinite loops + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "kimi-k2.5", + { + thinking: { type: "enabled", budget_tokens: 2000 }, + messages: [ + { role: "user", content: "read the file" }, + { + role: "assistant", + content: [ + { type: "tool_use", id: "toolu_kimi_claude", name: "read_file", input: { path: "test.ts" } } + ] + }, + { role: "tool", tool_call_id: "toolu_kimi_claude", content: "file data" } + ] + }, + false, + null, + "kimi-coding" + ); + + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistantMsg, "assistant message should exist"); + assert.ok(Array.isArray(assistantMsg.content), "content should be array"); + + // Should have a thinking block injected before tool_use + const thinkingBlock = assistantMsg.content.find((b) => b?.type === "thinking"); + assert.ok(thinkingBlock, "thinking block should be injected"); + assert.equal(thinkingBlock.thinking, "cached thinking for Kimi tool call", "should use cached reasoning"); + + // Thinking block should appear before tool_use + const thinkingIdx = assistantMsg.content.indexOf(thinkingBlock); + const toolUseIdx = assistantMsg.content.findIndex((b) => b?.type === "tool_use"); + assert.ok(thinkingIdx < toolUseIdx, "thinking block should be before tool_use"); + + assert.equal(getReasoningCacheServiceStats().replays, 1); + clearReasoningCacheAll(); +}); + +test("translateRequest injects placeholder thinking block for Claude-format Kimi K2 on cache miss", () => { + clearReasoningCacheAll(); + + // No cache seeded - should fall back to placeholder + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "kimi-k2.6", + { + thinking: { type: "enabled", budget_tokens: 2000 }, + messages: [ + { role: "user", content: "do it" }, + { + role: "assistant", + content: [ + { type: "tool_use", id: "toolu_miss", name: "bash", input: { command: "ls" } } + ] + }, + { role: "tool", tool_call_id: "toolu_miss", content: "output" } + ] + }, + false, + null, + "kimi-coding" + ); + + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + assert.ok(assistantMsg, "assistant message should exist"); + + const thinkingBlock = Array.isArray(assistantMsg.content) && assistantMsg.content.find((b) => b?.type === "thinking"); + assert.ok(thinkingBlock, "thinking block should be injected on cache miss"); + // Must be non-empty for kimi-coding + assert.ok(thinkingBlock.thinking && thinkingBlock.thinking.length > 0, "placeholder must be non-empty"); + + clearReasoningCacheAll(); +}); + +test("translateRequest does NOT inject duplicate thinking for Claude-format messages with existing thinking block", () => { + clearReasoningCacheAll(); + + const result = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "kimi-k2.5", + { + messages: [ + { role: "user", content: "hi" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "I already have this" }, + { type: "tool_use", id: "toolu_existing", name: "read", input: {} } + ] + }, + { role: "tool", tool_call_id: "toolu_existing", content: "data" } + ] + }, + false, + null, + "kimi-coding" + ); + + const assistantMsg = result.messages.find((m) => m.role === "assistant"); + const thinkingBlocks = Array.isArray(assistantMsg.content) && assistantMsg.content.filter((b) => b?.type === "thinking"); + assert.equal(thinkingBlocks?.length, 1, "should have exactly one thinking block (no duplicate)"); + assert.equal(thinkingBlocks[0].thinking, "I already have this", "original thinking should be preserved"); + + clearReasoningCacheAll(); +}); }); From a4787f853e31cec6b05a490485d1d8c725929ae1 Mon Sep 17 00:00:00 2001 From: terence71-glitch Date: Tue, 26 May 2026 05:25:00 +0200 Subject: [PATCH 29/77] fix(proxy): atomically create and assign custom proxies (#2697) Integrated into release/v3.8.4 From 94b3566a7a79168bda4084576312e47eca589e72 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 26 May 2026 00:27:22 -0300 Subject: [PATCH 30/77] =?UTF-8?q?feat(webhooks):=20wizard=203-step=20com?= =?UTF-8?q?=20Slack/Telegram/Discord/Custom=20+=20reorganiza=C3=A7=C3=A3o?= =?UTF-8?q?=20de=20componentes=20(#2703)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.4 --- CHANGELOG.md | 10 + docs/i18n/ar/CHANGELOG.md | 2 +- docs/i18n/az/CHANGELOG.md | 2 +- docs/i18n/bg/CHANGELOG.md | 2 +- docs/i18n/bn/CHANGELOG.md | 2 +- docs/i18n/cs/CHANGELOG.md | 2 +- docs/i18n/da/CHANGELOG.md | 2 +- docs/i18n/de/CHANGELOG.md | 2 +- docs/i18n/es/CHANGELOG.md | 2 +- docs/i18n/fa/CHANGELOG.md | 2 +- docs/i18n/fi/CHANGELOG.md | 2 +- docs/i18n/fr/CHANGELOG.md | 2 +- docs/i18n/gu/CHANGELOG.md | 2 +- docs/i18n/he/CHANGELOG.md | 2 +- docs/i18n/hi/CHANGELOG.md | 2 +- docs/i18n/hu/CHANGELOG.md | 2 +- docs/i18n/id/CHANGELOG.md | 2 +- docs/i18n/in/CHANGELOG.md | 2 +- docs/i18n/it/CHANGELOG.md | 2 +- docs/i18n/ja/CHANGELOG.md | 2 +- docs/i18n/ko/CHANGELOG.md | 2 +- docs/i18n/mr/CHANGELOG.md | 2 +- docs/i18n/ms/CHANGELOG.md | 2 +- docs/i18n/nl/CHANGELOG.md | 2 +- docs/i18n/no/CHANGELOG.md | 2 +- docs/i18n/phi/CHANGELOG.md | 2 +- docs/i18n/pl/CHANGELOG.md | 2 +- docs/i18n/pt-BR/CHANGELOG.md | 2 +- docs/i18n/pt/CHANGELOG.md | 2 +- docs/i18n/ro/CHANGELOG.md | 2 +- docs/i18n/ru/CHANGELOG.md | 2 +- docs/i18n/sk/CHANGELOG.md | 2 +- docs/i18n/sv/CHANGELOG.md | 2 +- docs/i18n/sw/CHANGELOG.md | 2 +- docs/i18n/ta/CHANGELOG.md | 2 +- docs/i18n/te/CHANGELOG.md | 2 +- docs/i18n/th/CHANGELOG.md | 2 +- docs/i18n/tr/CHANGELOG.md | 2 +- docs/i18n/uk-UA/CHANGELOG.md | 2 +- docs/i18n/ur/CHANGELOG.md | 2 +- docs/i18n/vi/CHANGELOG.md | 2 +- docs/i18n/zh-CN/CHANGELOG.md | 2 +- .../dashboard/webhooks/WebhooksPageClient.tsx | 259 ++++++++ .../__tests__/webhook-wizard.test.tsx | 270 ++++++++ .../webhooks/components/AddWebhookWizard.tsx | 267 ++++++++ .../webhooks/components/HowItWorksSidebar.tsx | 45 ++ .../webhooks/components/WebhookCard.tsx | 164 +++++ .../components/WebhookDeliveriesPanel.tsx | 92 +++ .../webhooks/components/WebhooksList.tsx | 55 ++ .../components/shared/DeliveryStatusBadge.tsx | 24 + .../components/shared/EventChecklist.tsx | 65 ++ .../components/shared/HmacRecipeBlock.tsx | 71 ++ .../components/shared/IntegrationCard.tsx | 78 +++ .../components/shared/PayloadPreview.tsx | 20 + .../steps/Step1ChooseIntegration.tsx | 43 ++ .../steps/Step2ConfigureIntegration.tsx | 44 ++ .../components/steps/Step3EventsAndTest.tsx | 159 +++++ .../steps/integrations/CustomConfigForm.tsx | 113 ++++ .../steps/integrations/DiscordConfigForm.tsx | 96 +++ .../steps/integrations/SlackConfigForm.tsx | 96 +++ .../steps/integrations/TelegramConfigForm.tsx | 58 ++ .../(dashboard)/dashboard/webhooks/page.tsx | 616 +----------------- src/app/api/webhooks/[id]/deliveries/route.ts | 34 + src/app/api/webhooks/[id]/route.ts | 31 +- src/app/api/webhooks/[id]/test/route.ts | 130 +++- src/app/api/webhooks/route.ts | 19 +- src/app/api/webhooks/validate-url/route.ts | 46 ++ src/i18n/messages/en.json | 105 ++- src/i18n/messages/pt-BR.json | 105 ++- .../migrations/068_webhooks_kind_metadata.sql | 3 + .../db/migrations/069_webhook_deliveries.sql | 15 + src/lib/db/webhookDeliveries.ts | 66 ++ src/lib/db/webhooks.ts | 34 +- src/lib/localDb.ts | 5 +- src/lib/webhookDispatcher.ts | 128 +++- src/lib/webhooks/eventDescriptions.ts | 85 +++ src/lib/webhooks/integrations/discord.ts | 49 ++ src/lib/webhooks/integrations/slack.ts | 45 ++ src/lib/webhooks/integrations/telegram.ts | 47 ++ tests/unit/webhook-deliveries-db.test.ts | 113 ++++ tests/unit/webhook-discord-dispatcher.test.ts | 41 ++ tests/unit/webhook-dispatcher-routing.test.ts | 31 + tests/unit/webhook-slack-dispatcher.test.ts | 62 ++ .../unit/webhook-telegram-dispatcher.test.ts | 62 ++ vitest.config.ts | 1 + 85 files changed, 3282 insertions(+), 702 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/webhooks/WebhooksPageClient.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/__tests__/webhook-wizard.test.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/AddWebhookWizard.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/HowItWorksSidebar.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/WebhookCard.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/WebhookDeliveriesPanel.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/WebhooksList.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/shared/DeliveryStatusBadge.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/shared/EventChecklist.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/shared/HmacRecipeBlock.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/shared/IntegrationCard.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/shared/PayloadPreview.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/steps/Step1ChooseIntegration.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/steps/Step2ConfigureIntegration.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/steps/Step3EventsAndTest.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/CustomConfigForm.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/DiscordConfigForm.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/SlackConfigForm.tsx create mode 100644 src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/TelegramConfigForm.tsx create mode 100644 src/app/api/webhooks/[id]/deliveries/route.ts create mode 100644 src/app/api/webhooks/validate-url/route.ts create mode 100644 src/lib/db/migrations/068_webhooks_kind_metadata.sql create mode 100644 src/lib/db/migrations/069_webhook_deliveries.sql create mode 100644 src/lib/db/webhookDeliveries.ts create mode 100644 src/lib/webhooks/eventDescriptions.ts create mode 100644 src/lib/webhooks/integrations/discord.ts create mode 100644 src/lib/webhooks/integrations/slack.ts create mode 100644 src/lib/webhooks/integrations/telegram.ts create mode 100644 tests/unit/webhook-deliveries-db.test.ts create mode 100644 tests/unit/webhook-discord-dispatcher.test.ts create mode 100644 tests/unit/webhook-dispatcher-routing.test.ts create mode 100644 tests/unit/webhook-slack-dispatcher.test.ts create mode 100644 tests/unit/webhook-telegram-dispatcher.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cc544ee02ef..77153168a0ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,16 @@ --- +## [3.8.4] — 2026-05-25 + +### ✨ New Features + +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + +### 🔧 Bug Fixes + +--- + ## [3.8.3] — 2026-05-24 ### ✨ New Features diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 3857b3064a74..7f5eadf519e3 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 1cfb50551938..7ccad1a04f62 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 1cfb50551938..7ccad1a04f62 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 0d135eedb16d..56e97d677b81 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index c404fafffe13..ff3afb623607 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index b335b25bd5e0..becf8320bebd 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 685cb2567d0a..1060a4b9e280 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 61258fd22a1d..7d89ed8e7026 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index c72c23d1626e..15161e23d7a0 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index da038e62e8c0..c12a1819dc83 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 3444f73a51b1..225db1bca568 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 934b46419f67..c18b3f317f43 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index 37bbf4119fe4..a95d4f5717ce 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 7be893db5dc2..a1ed48b2b667 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index 4f791c1d2048..c35f498b5ea8 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 93a5f92f5c7b..7c61efb9899d 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 0b8576e899c1..4b11d0199b60 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index 875603cb90a1..23596ad3e5f7 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index e4adf9265215..ab372bff7570 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 6a7e4af64e84..29a4dac26b8a 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 200636334fb5..13668777a3e8 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index f854326ecc8d..e061ed8e1b96 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index fe55a2f1561a..af2930648d5a 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index dac0d0c8fe83..5d6faf2fd59c 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index d056d0bd90e3..ede728986fdf 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index e9b6691063ec..3c42e6de0bb6 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 6561012350a0..677d8340a683 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index 5173385f0557..c1ba3ce5d285 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index ddcbdb31fa03..aa31bf930ba5 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 0066f04703fb..2c4fb6c7b955 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 09178904744b..2b80a63eee9e 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index b53dd53dd346..08ee640ce8e9 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index bab15f107b1d..8c7cecd37439 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 48e287f86f39..9f7b148a32ef 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 304de6a70a09..08db0d4df924 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index b064d9612c67..49ce0af0eaea 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 615046c9e3b7..ef48775adedc 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 93dea837eef9..df747a4299fe 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index 465f44cfaf95..dadb5b84027c 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 31d58dff8840..7a4afd208a24 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 9e18951b806c..59c208fab970 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -12,7 +12,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-25 ### ✨ New Features diff --git a/src/app/(dashboard)/dashboard/webhooks/WebhooksPageClient.tsx b/src/app/(dashboard)/dashboard/webhooks/WebhooksPageClient.tsx new file mode 100644 index 000000000000..1753fe958aed --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/WebhooksPageClient.tsx @@ -0,0 +1,259 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Card, ConfirmModal } from "@/shared/components"; +import { AddWebhookWizard } from "./components/AddWebhookWizard"; +import { HowItWorksSidebar } from "./components/HowItWorksSidebar"; +import { WebhooksList } from "./components/WebhooksList"; +import type { WebhookItem } from "./components/WebhookCard"; + +type FeedbackState = { type: "success" | "error"; message: string } | null; + +function getStatus(wh: WebhookItem): "active" | "inactive" | "errored" { + if (!wh.enabled) return "inactive"; + if (wh.failure_count > 0 || (wh.last_status !== null && wh.last_status >= 400)) return "errored"; + return "active"; +} + +export function WebhooksPageClient() { + const t = useTranslations("webhooks"); + + const [webhooks, setWebhooks] = useState([]); + const [loading, setLoading] = useState(true); + const [testingId, setTestingId] = useState(null); + const [feedback, setFeedback] = useState(null); + const [wizardOpen, setWizardOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await fetch("/api/webhooks"); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("loadFailed")); + setWebhooks(Array.isArray(data.webhooks) ? data.webhooks : []); + } catch (err) { + setFeedback({ + type: "error", + message: err instanceof Error ? err.message : t("loadFailed"), + }); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + void load(); + }, [load]); + + const stats = useMemo( + () => + webhooks.reduce( + (acc, wh) => { + acc.total += 1; + acc[getStatus(wh)] += 1; + return acc; + }, + { total: 0, active: 0, inactive: 0, errored: 0 } + ), + [webhooks] + ); + + const handleTest = async (wh: WebhookItem) => { + setTestingId(wh.id); + setFeedback(null); + try { + const res = await fetch(`/api/webhooks/${wh.id}/test`, { method: "POST" }); + const data = await res.json().catch(() => ({})); + if (!res.ok || data.delivered === false) throw new Error(data.error || t("testFailed")); + setFeedback({ type: "success", message: t("testSuccess") }); + await load(); + } catch (err) { + setFeedback({ + type: "error", + message: err instanceof Error ? err.message : t("testFailed"), + }); + } finally { + setTestingId(null); + } + }; + + const handleToggleEnabled = async (wh: WebhookItem) => { + setFeedback(null); + try { + const res = await fetch(`/api/webhooks/${wh.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !wh.enabled }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("saveFailed")); + setWebhooks((prev) => + prev.map((item) => (item.id === wh.id ? { ...item, enabled: !wh.enabled } : item)) + ); + } catch (err) { + setFeedback({ + type: "error", + message: err instanceof Error ? err.message : t("saveFailed"), + }); + } + }; + + const handleDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); + setFeedback(null); + try { + const res = await fetch(`/api/webhooks/${deleteTarget.id}`, { method: "DELETE" }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("deleteFailed")); + setWebhooks((prev) => prev.filter((w) => w.id !== deleteTarget.id)); + setDeleteTarget(null); + setFeedback({ type: "success", message: t("deleteSuccess") }); + } catch (err) { + setFeedback({ + type: "error", + message: err instanceof Error ? err.message : t("deleteFailed"), + }); + } finally { + setDeleting(false); + } + }; + + const tFn = (key: string, opts?: Record) => + opts ? t(key as any, opts as any) : t(key as any); + + return ( +
+
+
+

{t("title")}

+

{t("description")}

+
+ +
+ + {feedback && ( +
+ {feedback.message} +
+ )} + +
+
+
+ {[ + { label: t("total"), value: stats.total, icon: "webhook", tone: "text-primary" }, + { + label: t("active"), + value: stats.active, + icon: "check_circle", + tone: "text-emerald-500", + }, + { + label: t("inactive"), + value: stats.inactive, + icon: "pause_circle", + tone: "text-text-muted", + }, + { label: t("errored"), value: stats.errored, icon: "error", tone: "text-red-500" }, + ].map((stat) => ( + +
+
+

+ {stat.label} +

+

{stat.value}

+
+ + {stat.icon} + +
+
+ ))} +
+ + +
+
+
+

+ {t("configuredWebhooks")} +

+

{t("configuredWebhooksDesc")}

+
+ +
+
+
+ void handleTest(wh)} + onToggleEnabled={(wh) => void handleToggleEnabled(wh)} + onEdit={() => setWizardOpen(true)} + onDelete={setDeleteTarget} + /> +
+
+
+ + +
+ + setWizardOpen(false)} + onCreated={async () => { + await load(); + }} + t={tFn} + /> + + setDeleteTarget(null)} + onConfirm={handleDelete} + title={t("delete")} + message={t("deleteConfirm")} + confirmText={t("delete")} + cancelText={t("wizard.cancel")} + loading={deleting} + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/__tests__/webhook-wizard.test.tsx b/src/app/(dashboard)/dashboard/webhooks/__tests__/webhook-wizard.test.tsx new file mode 100644 index 000000000000..09ff7c9ea0c0 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/__tests__/webhook-wizard.test.tsx @@ -0,0 +1,270 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Step1ChooseIntegration } from "../components/steps/Step1ChooseIntegration"; +import { Step3EventsAndTest } from "../components/steps/Step3EventsAndTest"; +import { AddWebhookWizard } from "../components/AddWebhookWizard"; + +// Minimal t() mock that returns the key, letting tests assert on key presence. +function t(key: string): string { + const overrides: Record = { + "wizard.step1Desc": "Select the platform", + "wizard.step1Title": "Choose Integration", + "wizard.step2Title": "Configure", + "wizard.step3Title": "Events & Test", + "wizard.next": "Next", + "wizard.back": "Back", + "wizard.finish": "Add Webhook", + "wizard.cancel": "Cancel", + addWebhook: "Add Webhook", + "kinds.slack": "Slack", + "kinds.slackDesc": "Slack desc", + "kinds.telegram": "Telegram", + "kinds.telegramDesc": "Telegram desc", + "kinds.discord": "Discord", + "kinds.discordDesc": "Discord desc", + "kinds.custom": "Custom", + "kinds.customDesc": "Custom desc", + "kinds.email": "Email", + "kinds.emailDesc": "Email desc", + "kinds.pagerduty": "PagerDuty", + "kinds.pagerdutyDesc": "PagerDuty desc", + "kinds.teams": "Microsoft Teams", + "kinds.teamsDesc": "Teams desc", + "kinds.comingSoon": "Coming soon", + "slack.webhookUrl": "Slack Webhook URL", + "slack.webhookUrlPlaceholder": "https://hooks.slack.com/services/…", + "slack.webhookUrlHint": "Create one in your Slack App.", + "slack.tutorial": "How to get a Slack Webhook URL", + "slack.tutorialStep1": "step1", + "slack.tutorialStep2": "step2", + "slack.tutorialStep3": "step3", + "slack.tutorialStep4": "step4", + "howItWorks.title": "How It Works", + "howItWorks.step1": "step1", + "howItWorks.step2": "step2", + "howItWorks.step3": "step3", + "howItWorks.step4": "step4", + "howItWorks.hmacRecipeTitle": "Verify Signature", + "howItWorks.hmacRecipe": "const sig = req.headers['x-webhook-signature'];", + name: "Name", + namePlaceholder: "Production monitoring", + events: "Events", + allEvents: "All events", + enabled: "Enabled", + enabledDesc: "Disabled webhooks remain saved.", + testWebhook: "Send Test", + testSuccess: "delivery confirmed", + testFailed: "Test failed", + testPayloadSent: "Payload sent", + testResponse: "Endpoint response", + saveFailed: "Failed to save", + }; + return overrides[key] ?? key; +} + +function getButton(text: string): HTMLButtonElement | null { + return ( + (Array.from(document.querySelectorAll("button")).find((b) => b.textContent?.includes(text)) as + | HTMLButtonElement + | undefined) ?? null + ); +} + +const roots: Array<{ unmount: () => void }> = []; + +afterEach(() => { + roots.forEach((r) => { + act(() => r.unmount()); + }); + roots.length = 0; + document.body.innerHTML = ""; + vi.restoreAllMocks(); +}); + +function renderIntoBody(element: React.ReactElement) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(element); + }); + roots.push({ unmount: () => root.unmount() }); + return container; +} + +// --------------------------------------------------------------------------- +// Scenario 1: Step1 renders 7 cards (4 active + 3 greyed) +// --------------------------------------------------------------------------- +describe("Step1ChooseIntegration", () => { + it("renders 4 active and 3 coming-soon cards", () => { + const onSelect = vi.fn(); + renderIntoBody(); + + const allButtons = Array.from(document.querySelectorAll("button")); + expect(allButtons).toHaveLength(7); + + const disabledButtons = allButtons.filter((b) => b.disabled); + expect(disabledButtons).toHaveLength(3); + + const activeButtons = allButtons.filter((b) => !b.disabled); + expect(activeButtons).toHaveLength(4); + }); + + it("greyed cards show 'Coming soon' badge", () => { + renderIntoBody(); + const badges = document.body.textContent?.match(/Coming soon/g) ?? []; + expect(badges).toHaveLength(3); + }); + + // --------------------------------------------------------------------------- + // Scenario 2: Clicking "Slack" → calls onSelect("slack") + // --------------------------------------------------------------------------- + it("clicking a kind card calls onSelect with that kind", () => { + const onSelect = vi.fn(); + renderIntoBody(); + + const slackButton = Array.from(document.querySelectorAll("button")).find((b) => + b.textContent?.includes("Slack") + ); + expect(slackButton).toBeTruthy(); + act(() => slackButton!.click()); + expect(onSelect).toHaveBeenCalledWith("slack"); + }); + + it("clicking a disabled coming-soon card does NOT call onSelect", () => { + const onSelect = vi.fn(); + renderIntoBody(); + + const emailButton = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.includes("Email") && b.disabled + ); + expect(emailButton).toBeTruthy(); + act(() => emailButton!.click()); + expect(onSelect).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 3: Step3 "Send test ping" calls /api/webhooks/[id]/test +// --------------------------------------------------------------------------- +describe("Step3EventsAndTest — test ping", () => { + it("clicking Send Test calls the test endpoint and shows status + payload", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + delivered: true, + status: 200, + latencyMs: 42, + payloadSent: { event: "test.ping" }, + responseBody: '{"ok":true}', + error: null, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + renderIntoBody( + + ); + + const sendBtn = getButton("Send Test"); + expect(sendBtn).toBeTruthy(); + await act(async () => sendBtn!.click()); + + expect(mockFetch).toHaveBeenCalledWith( + "/api/webhooks/wh-test-123/test", + expect.objectContaining({ method: "POST" }) + ); + + // Status + latency line should appear + expect(document.body.textContent).toContain("200"); + expect(document.body.textContent).toContain("42ms"); + expect(document.body.textContent).toContain("delivery confirmed"); + }); + + it("displays error message on failed test ping", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + json: async () => ({ delivered: false, error: "Connection refused", status: 0 }), + }); + vi.stubGlobal("fetch", mockFetch); + + renderIntoBody( + + ); + + const sendBtn = getButton("Send Test"); + await act(async () => sendBtn!.click()); + + expect(document.body.textContent).toContain("Connection refused"); + }); +}); + +// --------------------------------------------------------------------------- +// Scenario 4: Wizard flow — entering step 3 POSTs with kind: "slack" +// --------------------------------------------------------------------------- +describe("AddWebhookWizard — step 2→3 creates webhook with correct kind", () => { + it("Next on step 2 posts kind:slack and saves createdId", async () => { + const postResponse = { id: "wh-created-789", url: "https://hooks.slack.com/test" }; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ webhook: postResponse }), + }); + vi.stubGlobal("fetch", mockFetch); + + renderIntoBody(); + + // Step 1: default is "slack", click Next + const nextBtn1 = getButton("Next"); + expect(nextBtn1).toBeTruthy(); + await act(async () => nextBtn1!.click()); + + // Now in step 2 (Slack config), fill in the URL using the native setter + // so React's synthetic onChange fires properly on a controlled input. + const input = document.querySelector( + 'input[placeholder="https://hooks.slack.com/services/…"]' + ) as HTMLInputElement | null; + expect(input).toBeTruthy(); + await act(async () => { + const nativeSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )?.set; + nativeSetter?.call(input, "https://hooks.slack.com/services/T00/B00/xxx"); + input!.dispatchEvent(new Event("input", { bubbles: true })); + }); + + // Click Next to go to step 3 (the button becomes enabled after value is set) + const nextBtn2 = getButton("Next"); + expect(nextBtn2?.disabled).toBe(false); + await act(async () => nextBtn2!.click()); + + // Verify the POST was called with kind: "slack" + const calls = mockFetch.mock.calls; + const postCall = calls.find( + ([url, opts]) => url === "/api/webhooks" && opts?.method === "POST" + ); + expect(postCall).toBeTruthy(); + const body = JSON.parse(postCall![1].body as string); + expect(body.kind).toBe("slack"); + }); +}); diff --git a/src/app/(dashboard)/dashboard/webhooks/components/AddWebhookWizard.tsx b/src/app/(dashboard)/dashboard/webhooks/components/AddWebhookWizard.tsx new file mode 100644 index 000000000000..9875c7346ac9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/AddWebhookWizard.tsx @@ -0,0 +1,267 @@ +"use client"; + +import { useState } from "react"; +import { Modal } from "@/shared/components"; +import { Step1ChooseIntegration } from "./steps/Step1ChooseIntegration"; +import { + Step2ConfigureIntegration, + type SlackConfig, + type TelegramConfig, + type DiscordConfig, + type CustomConfig, +} from "./steps/Step2ConfigureIntegration"; +import { Step3EventsAndTest } from "./steps/Step3EventsAndTest"; +import { HowItWorksSidebar } from "./HowItWorksSidebar"; +import type { WebhookKind } from "./shared/IntegrationCard"; + +interface AddWebhookWizardProps { + isOpen: boolean; + onClose: () => void; + onCreated: () => void; + t: (key: string, opts?: Record) => string; +} + +const STEPS = [1, 2, 3] as const; + +interface WizardState { + kind: WebhookKind; + slack: SlackConfig; + telegram: TelegramConfig; + discord: DiscordConfig; + custom: CustomConfig; + events: string[]; + enabled: boolean; + description: string; +} + +const INITIAL: WizardState = { + kind: "slack", + slack: { webhookUrl: "" }, + telegram: { botToken: "", chatId: "" }, + discord: { webhookUrl: "" }, + custom: { endpointUrl: "", secretKey: "" }, + events: ["*"], + enabled: true, + description: "", +}; + +function step2Valid(state: WizardState): boolean { + const { kind } = state; + if (kind === "slack") return state.slack.webhookUrl.trim().length > 0; + if (kind === "telegram") + return state.telegram.botToken.trim().length > 0 && state.telegram.chatId.trim().length > 0; + if (kind === "discord") return state.discord.webhookUrl.trim().length > 0; + if (kind === "custom") return state.custom.endpointUrl.trim().length > 0; + return false; +} + +export function AddWebhookWizard({ isOpen, onClose, onCreated, t }: AddWebhookWizardProps) { + const [step, setStep] = useState(1); + const [state, setState] = useState(INITIAL); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [createdId, setCreatedId] = useState(null); + + const handleClose = () => { + if (saving) return; + setStep(1); + setState(INITIAL); + setError(null); + setCreatedId(null); + onClose(); + }; + + // Builds the config payload (URL + kind + metadata) — sent when entering step 3. + const buildConfigPayload = () => { + const { kind } = state; + if (kind === "slack") return { kind, url: state.slack.webhookUrl }; + if (kind === "discord") return { kind, url: state.discord.webhookUrl }; + if (kind === "telegram") { + return { kind, url: state.telegram.chatId, metadata: { botToken: state.telegram.botToken } }; + } + const payload: Record = { kind, url: state.custom.endpointUrl }; + if (state.custom.secretKey.trim()) payload.secret = state.custom.secretKey.trim(); + return payload; + }; + + // Called when Next is clicked on step 2: create (or update) the webhook so the + // test button is available at step 3 before the user clicks Finish. + const handleNextFromStep2 = async () => { + setSaving(true); + setError(null); + try { + if (createdId) { + const res = await fetch(`/api/webhooks/${createdId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildConfigPayload()), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("saveFailed")); + } else { + const res = await fetch("/api/webhooks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildConfigPayload()), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("saveFailed")); + setCreatedId(data.webhook?.id ?? null); + } + setStep(3); + } catch (err) { + setError(err instanceof Error ? err.message : t("saveFailed")); + } finally { + setSaving(false); + } + }; + + // Finish: update events/enabled/description on the already-created webhook. + const finish = async () => { + if (!createdId) return; + setSaving(true); + setError(null); + try { + const res = await fetch(`/api/webhooks/${createdId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + events: state.events, + enabled: state.enabled, + description: state.description, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("saveFailed")); + onCreated(); + handleClose(); + } catch (err) { + setError(err instanceof Error ? err.message : t("saveFailed")); + } finally { + setSaving(false); + } + }; + + const canGoNext = step === 1 ? true : step === 2 ? step2Valid(state) : true; + + const stepTitle = + step === 1 + ? t("wizard.step1Title") + : step === 2 + ? t("wizard.step2Title") + : t("wizard.step3Title"); + + return ( + +
+ {STEPS.map((s) => ( + + ))} +
+
+ + {step > 1 && ( + + )} + {step < 3 ? ( + + ) : ( + + )} +
+
+ } + > +
+
+ {error && ( +
+ {error} +
+ )} + {step === 1 && ( + setState((s) => ({ ...s, kind }))} + t={t} + /> + )} + {step === 2 && ( + setState((s) => ({ ...s, slack: v }))} + onChangeTelegram={(v) => setState((s) => ({ ...s, telegram: v }))} + onChangeDiscord={(v) => setState((s) => ({ ...s, discord: v }))} + onChangeCustom={(v) => setState((s) => ({ ...s, custom: v }))} + t={t} + /> + )} + {step === 3 && ( + setState((s) => ({ ...s, events }))} + onChangeEnabled={(enabled) => setState((s) => ({ ...s, enabled }))} + onChangeDescription={(description) => setState((s) => ({ ...s, description }))} + t={t} + /> + )} +
+
+ +
+
+ + ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/HowItWorksSidebar.tsx b/src/app/(dashboard)/dashboard/webhooks/components/HowItWorksSidebar.tsx new file mode 100644 index 000000000000..f9f52d6bd7da --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/HowItWorksSidebar.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { HmacRecipeBlock } from "./shared/HmacRecipeBlock"; + +interface HowItWorksSidebarProps { + t: (key: string) => string; + showCustomNote?: boolean; +} + +export function HowItWorksSidebar({ t, showCustomNote }: HowItWorksSidebarProps) { + const steps = [ + t("howItWorks.step1"), + t("howItWorks.step2"), + t("howItWorks.step3"), + t("howItWorks.step4"), + ]; + + return ( + + ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/WebhookCard.tsx b/src/app/(dashboard)/dashboard/webhooks/components/WebhookCard.tsx new file mode 100644 index 000000000000..4d906790f402 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/WebhookCard.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useState } from "react"; +import { WebhookDeliveriesPanel } from "./WebhookDeliveriesPanel"; + +export type WebhookKind = "slack" | "telegram" | "discord" | "custom"; + +export interface WebhookItem { + id: string; + url: string; + events: string[]; + secret: string | null; + enabled: boolean; + description: string; + created_at: string; + last_triggered_at: string | null; + last_status: number | null; + failure_count: number; + kind: WebhookKind; + metadata_encrypted?: string | null; +} + +const KIND_ICONS: Record = { + slack: "chat", + telegram: "send", + discord: "forum", + custom: "webhook", +}; + +const KIND_COLORS: Record = { + slack: "text-emerald-500", + telegram: "text-blue-500", + discord: "text-violet-500", + custom: "text-amber-500", +}; + +function getStatus(wh: WebhookItem): "active" | "inactive" | "errored" { + if (!wh.enabled) return "inactive"; + if (wh.failure_count > 0 || (wh.last_status !== null && wh.last_status >= 400)) return "errored"; + return "active"; +} + +interface WebhookCardProps { + webhook: WebhookItem; + t: (key: string, opts?: Record) => string; + testingId: string | null; + onTest: (wh: WebhookItem) => void; + onToggleEnabled: (wh: WebhookItem) => void; + onEdit: (wh: WebhookItem) => void; + onDelete: (wh: WebhookItem) => void; +} + +export function WebhookCard({ + webhook, + t, + testingId, + onTest, + onToggleEnabled, + onEdit, + onDelete, +}: WebhookCardProps) { + const [expanded, setExpanded] = useState(false); + const status = getStatus(webhook); + const isTesting = testingId === webhook.id; + + return ( +
+
+ + {KIND_ICONS[webhook.kind]} + +
+

+ {webhook.description || t("unnamedWebhook")} +

+

{webhook.url}

+
+ + {t(status)} + +
+ + + + + +
+
+ + {expanded && ( +
+
+ {webhook.events.map((ev) => ( + + {ev === "*" ? t("allEvents") : ev} + + ))} +
+

+ {t("deliveries.title")} +

+ +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/WebhookDeliveriesPanel.tsx b/src/app/(dashboard)/dashboard/webhooks/components/WebhookDeliveriesPanel.tsx new file mode 100644 index 000000000000..cae5c3f9bff1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/WebhookDeliveriesPanel.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { DeliveryStatusBadge } from "./shared/DeliveryStatusBadge"; + +interface Delivery { + id: number; + event_type: string; + status: string; + http_status: number | null; + latency_ms: number | null; + error: string | null; + created_at: string; +} + +interface WebhookDeliveriesPanelProps { + webhookId: string; + t: (key: string) => string; +} + +export function WebhookDeliveriesPanel({ webhookId, t }: WebhookDeliveriesPanelProps) { + const [deliveries, setDeliveries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/webhooks/${webhookId}/deliveries?limit=5`); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || t("deliveries.loadFailed")); + setDeliveries(Array.isArray(data.deliveries) ? data.deliveries : []); + } catch (err) { + setError(err instanceof Error ? err.message : t("deliveries.loadFailed")); + } finally { + setLoading(false); + } + }, [webhookId, t]); + + useEffect(() => { + void load(); + }, [load]); + + if (loading) { + return

{t("loading")}

; + } + + if (error) { + return

{error}

; + } + + if (deliveries.length === 0) { + return

{t("deliveries.empty")}

; + } + + return ( +
+ + + + + + + + + + + {deliveries.map((d) => ( + + + + + + + ))} + +
{t("deliveries.status")}{t("deliveries.event")}{t("deliveries.latency")}{t("deliveries.at")}
+ + {d.event_type} + {d.latency_ms != null ? `${d.latency_ms}ms` : "—"} + + {new Date(d.created_at).toLocaleString()} + {d.error && ( +

+ {d.error} +

+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/WebhooksList.tsx b/src/app/(dashboard)/dashboard/webhooks/components/WebhooksList.tsx new file mode 100644 index 000000000000..476311fce045 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/WebhooksList.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { WebhookCard, type WebhookItem } from "./WebhookCard"; + +interface WebhooksListProps { + webhooks: WebhookItem[]; + loading: boolean; + testingId: string | null; + t: (key: string, opts?: Record) => string; + onTest: (wh: WebhookItem) => void; + onToggleEnabled: (wh: WebhookItem) => void; + onEdit: (wh: WebhookItem) => void; + onDelete: (wh: WebhookItem) => void; +} + +export function WebhooksList({ + webhooks, + loading, + testingId, + t, + onTest, + onToggleEnabled, + onEdit, + onDelete, +}: WebhooksListProps) { + if (loading) { + return

{t("loading")}

; + } + + if (webhooks.length === 0) { + return ( +
+ webhook +

{t("noWebhooks")}

+
+ ); + } + + return ( +
+ {webhooks.map((wh) => ( + + ))} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/shared/DeliveryStatusBadge.tsx b/src/app/(dashboard)/dashboard/webhooks/components/shared/DeliveryStatusBadge.tsx new file mode 100644 index 000000000000..8d090f23419f --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/shared/DeliveryStatusBadge.tsx @@ -0,0 +1,24 @@ +"use client"; + +interface DeliveryStatusBadgeProps { + status: string; + httpStatus?: number | null; +} + +export function DeliveryStatusBadge({ status, httpStatus }: DeliveryStatusBadgeProps) { + const isSuccess = status === "success"; + return ( + + + {isSuccess ? "check_circle" : "error"} + + {httpStatus ? httpStatus : isSuccess ? "OK" : "ERR"} + + ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/shared/EventChecklist.tsx b/src/app/(dashboard)/dashboard/webhooks/components/shared/EventChecklist.tsx new file mode 100644 index 000000000000..bfd55b71d008 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/shared/EventChecklist.tsx @@ -0,0 +1,65 @@ +"use client"; + +const WEBHOOK_EVENTS = [ + "request.completed", + "request.failed", + "provider.error", + "provider.recovered", + "quota.exceeded", + "combo.switched", + "test.ping", +] as const; + +interface EventChecklistProps { + selected: string[]; + onChange: (events: string[]) => void; + allEventsLabel?: string; +} + +export function EventChecklist({ + selected, + onChange, + allEventsLabel = "All events", +}: EventChecklistProps) { + const isAll = selected.includes("*"); + + const toggle = (event: string) => { + if (event === "*") { + onChange(["*"]); + return; + } + const without = selected.filter((e) => e !== "*"); + const next = without.includes(event) ? without.filter((e) => e !== event) : [...without, event]; + onChange(next.length > 0 ? next : ["*"]); + }; + + return ( +
+ + {WEBHOOK_EVENTS.map((ev) => ( + + ))} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/shared/HmacRecipeBlock.tsx b/src/app/(dashboard)/dashboard/webhooks/components/shared/HmacRecipeBlock.tsx new file mode 100644 index 000000000000..129b80ccb14f --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/shared/HmacRecipeBlock.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState } from "react"; + +interface Snippet { + label: string; + code: string; +} + +interface HmacRecipeBlockProps { + title?: string; + /** Single code block (backward compat) */ + code?: string; + /** Multiple language snippets rendered with tab switcher */ + snippets?: Snippet[]; +} + +export function HmacRecipeBlock({ code, title, snippets }: HmacRecipeBlockProps) { + const tabs: Snippet[] = snippets ?? (code ? [{ label: "Node.js", code }] : []); + const [active, setActive] = useState(0); + const [copied, setCopied] = useState(false); + + const copy = async () => { + await navigator.clipboard.writeText(tabs[active]?.code ?? ""); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (tabs.length === 0) return null; + + return ( +
+ {title && ( +

{title}

+ )} + {tabs.length > 1 && ( +
+ {tabs.map((s, i) => ( + + ))} +
+ )} +
+
+          {tabs[active]?.code}
+        
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/shared/IntegrationCard.tsx b/src/app/(dashboard)/dashboard/webhooks/components/shared/IntegrationCard.tsx new file mode 100644 index 000000000000..df4846c06cdd --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/shared/IntegrationCard.tsx @@ -0,0 +1,78 @@ +"use client"; + +export type WebhookKind = "slack" | "telegram" | "discord" | "custom"; +export type ComingSoonKind = "email" | "pagerduty" | "teams"; +type AnyKind = WebhookKind | ComingSoonKind; + +const KIND_ICONS: Record = { + slack: "chat", + telegram: "send", + discord: "forum", + custom: "webhook", + email: "email", + pagerduty: "notifications_active", + teams: "groups", +}; + +const KIND_COLORS: Record = { + slack: "text-emerald-500", + telegram: "text-blue-500", + discord: "text-violet-500", + custom: "text-amber-500", + email: "text-text-muted", + pagerduty: "text-text-muted", + teams: "text-text-muted", +}; + +interface IntegrationCardProps { + kind: AnyKind; + name: string; + description: string; + selected: boolean; + onSelect?: (kind: WebhookKind) => void; + disabled?: boolean; + comingSoonLabel?: string; +} + +export function IntegrationCard({ + kind, + name, + description, + selected, + onSelect, + disabled, + comingSoonLabel, +}: IntegrationCardProps) { + return ( + + ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/shared/PayloadPreview.tsx b/src/app/(dashboard)/dashboard/webhooks/components/shared/PayloadPreview.tsx new file mode 100644 index 000000000000..c45b7ac84bf9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/shared/PayloadPreview.tsx @@ -0,0 +1,20 @@ +"use client"; + +interface PayloadPreviewProps { + payload: Record | null; + label?: string; +} + +export function PayloadPreview({ payload, label }: PayloadPreviewProps) { + if (!payload) return null; + return ( +
+ {label && ( +

{label}

+ )} +
+        {JSON.stringify(payload, null, 2)}
+      
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/Step1ChooseIntegration.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/Step1ChooseIntegration.tsx new file mode 100644 index 000000000000..2bd26bfabebd --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/Step1ChooseIntegration.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { IntegrationCard, type WebhookKind, type ComingSoonKind } from "../shared/IntegrationCard"; + +interface Step1Props { + selected: WebhookKind; + onSelect: (kind: WebhookKind) => void; + t: (key: string) => string; +} + +const KINDS: WebhookKind[] = ["slack", "telegram", "discord", "custom"]; +const COMING_SOON: ComingSoonKind[] = ["email", "pagerduty", "teams"]; + +export function Step1ChooseIntegration({ selected, onSelect, t }: Step1Props) { + return ( +
+

{t("wizard.step1Desc")}

+
+ {KINDS.map((kind) => ( + + ))} + {COMING_SOON.map((kind) => ( + + ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/Step2ConfigureIntegration.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/Step2ConfigureIntegration.tsx new file mode 100644 index 000000000000..4589a3d39de9 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/Step2ConfigureIntegration.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { SlackConfigForm, type SlackConfig } from "./integrations/SlackConfigForm"; +import { TelegramConfigForm, type TelegramConfig } from "./integrations/TelegramConfigForm"; +import { DiscordConfigForm, type DiscordConfig } from "./integrations/DiscordConfigForm"; +import { CustomConfigForm, type CustomConfig } from "./integrations/CustomConfigForm"; +import type { WebhookKind } from "../shared/IntegrationCard"; + +interface Step2ConfigureIntegrationProps { + kind: WebhookKind; + slack: SlackConfig; + telegram: TelegramConfig; + discord: DiscordConfig; + custom: CustomConfig; + onChangeSlack: (v: SlackConfig) => void; + onChangeTelegram: (v: TelegramConfig) => void; + onChangeDiscord: (v: DiscordConfig) => void; + onChangeCustom: (v: CustomConfig) => void; + t: (key: string, opts?: Record) => string; + isEditing?: boolean; +} + +export function Step2ConfigureIntegration({ + kind, + slack, + telegram, + discord, + custom, + onChangeSlack, + onChangeTelegram, + onChangeDiscord, + onChangeCustom, + t, + isEditing, +}: Step2ConfigureIntegrationProps) { + if (kind === "slack") return ; + if (kind === "telegram") + return ; + if (kind === "discord") + return ; + return ; +} + +export type { SlackConfig, TelegramConfig, DiscordConfig, CustomConfig }; diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/Step3EventsAndTest.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/Step3EventsAndTest.tsx new file mode 100644 index 000000000000..f96594f816e5 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/Step3EventsAndTest.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState } from "react"; +import { EventChecklist } from "../shared/EventChecklist"; +import { PayloadPreview } from "../shared/PayloadPreview"; + +interface Step3Props { + webhookId?: string; + events: string[]; + enabled: boolean; + description: string; + onChangeEvents: (events: string[]) => void; + onChangeEnabled: (enabled: boolean) => void; + onChangeDescription: (desc: string) => void; + t: (key: string, opts?: Record) => string; +} + +interface TestResult { + delivered: boolean; + status: number; + latencyMs: number; + payloadSent: Record | null; + responseBody: string; + error?: string | null; +} + +export function Step3EventsAndTest({ + webhookId, + events, + enabled, + description, + onChangeEvents, + onChangeEnabled, + onChangeDescription, + t, +}: Step3Props) { + const [testState, setTestState] = useState<"idle" | "sending" | "ok" | "fail">("idle"); + const [testResult, setTestResult] = useState(null); + + const sendTest = async () => { + if (!webhookId) return; + setTestState("sending"); + setTestResult(null); + try { + const res = await fetch(`/api/webhooks/${webhookId}/test`, { method: "POST" }); + const data: TestResult & { error?: string } = await res.json().catch(() => ({})); + if (!res.ok || data.delivered === false) { + throw new Error(data.error || t("testFailed")); + } + setTestResult(data); + setTestState("ok"); + } catch (err) { + setTestState("fail"); + setTestResult((prev) => ({ + delivered: false, + status: 0, + latencyMs: 0, + payloadSent: prev?.payloadSent ?? null, + responseBody: prev?.responseBody ?? "", + error: err instanceof Error ? err.message : t("testFailed"), + })); + } + }; + + const parseResponseBody = (raw: string): Record | null => { + try { + return JSON.parse(raw) as Record; + } catch { + return raw ? { raw } : null; + } + }; + + return ( +
+
+ + onChangeDescription(e.target.value)} + placeholder={t("namePlaceholder")} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> +
+ +
+ + +
+ + + + {webhookId && ( +
+

{t("testWebhook")}

+ + + {testState === "ok" && testResult && ( +
+

+ ✅ {testResult.status} · {testResult.latencyMs}ms · {t("testSuccess")} +

+ {testResult.payloadSent && ( + + )} + {testResult.responseBody && ( + + )} +
+ )} + + {testState === "fail" && testResult && ( +
+

{testResult.error ?? t("testFailed")}

+ {testResult.payloadSent && ( + + )} +
+ )} +
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/CustomConfigForm.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/CustomConfigForm.tsx new file mode 100644 index 000000000000..62f7a41565be --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/CustomConfigForm.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { HmacRecipeBlock } from "../../shared/HmacRecipeBlock"; + +export interface CustomConfig { + endpointUrl: string; + secretKey: string; +} + +interface CustomConfigFormProps { + value: CustomConfig; + onChange: (v: CustomConfig) => void; + t: (key: string) => string; + isEditing?: boolean; +} + +type UrlState = "idle" | "checking" | "ok" | "blocked" | "invalid"; + +export function CustomConfigForm({ value, onChange, t, isEditing }: CustomConfigFormProps) { + const [urlState, setUrlState] = useState("idle"); + + useEffect(() => { + const url = value.endpointUrl.trim(); + const controller = new AbortController(); + const delay = url ? 600 : 0; + const timer = setTimeout(async () => { + if (!url) { + setUrlState("idle"); + return; + } + setUrlState("checking"); + try { + const res = await fetch("/api/webhooks/validate-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + signal: controller.signal, + }); + const data = await res.json().catch(() => ({})); + setUrlState(data.valid ? "ok" : data.reason === "blocked_private" ? "blocked" : "invalid"); + } catch { + if (!controller.signal.aborted) setUrlState("invalid"); + } + }, delay); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [value.endpointUrl]); + + const urlHint = + urlState === "checking" + ? t("validateUrl.checking") + : urlState === "ok" + ? t("validateUrl.ok") + : urlState === "blocked" + ? t("validateUrl.blockedPrivate") + : urlState === "invalid" && value.endpointUrl.trim() + ? t("validateUrl.invalidUrl") + : ""; + + return ( +
+
+ + onChange({ ...value, endpointUrl: e.target.value })} + placeholder={t("custom.endpointUrlPlaceholder")} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> + {urlHint && ( +

+ {urlHint} +

+ )} +
+
+ + onChange({ ...value, secretKey: e.target.value })} + placeholder={isEditing ? t("secretEditPlaceholder") : t("custom.secretKeyPlaceholder")} + autoComplete="new-password" + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> +

{t("custom.secretKeyHint")}

+
+ +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/DiscordConfigForm.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/DiscordConfigForm.tsx new file mode 100644 index 000000000000..9be285c93364 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/DiscordConfigForm.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export interface DiscordConfig { + webhookUrl: string; +} + +interface DiscordConfigFormProps { + value: DiscordConfig; + onChange: (v: DiscordConfig) => void; + t: (key: string) => string; +} + +type UrlState = "idle" | "checking" | "ok" | "blocked" | "invalid"; + +export function DiscordConfigForm({ value, onChange, t }: DiscordConfigFormProps) { + const [urlState, setUrlState] = useState("idle"); + + useEffect(() => { + const url = value.webhookUrl.trim(); + const controller = new AbortController(); + const delay = url ? 600 : 0; + const timer = setTimeout(async () => { + if (!url) { + setUrlState("idle"); + return; + } + setUrlState("checking"); + try { + const res = await fetch("/api/webhooks/validate-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + signal: controller.signal, + }); + const data = await res.json().catch(() => ({})); + setUrlState(data.valid ? "ok" : data.reason === "blocked_private" ? "blocked" : "invalid"); + } catch { + if (!controller.signal.aborted) setUrlState("invalid"); + } + }, delay); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [value.webhookUrl]); + + const urlHint = + urlState === "checking" + ? t("validateUrl.checking") + : urlState === "ok" + ? t("validateUrl.ok") + : urlState === "blocked" + ? t("validateUrl.blockedPrivate") + : urlState === "invalid" && value.webhookUrl.trim() + ? t("validateUrl.invalidUrl") + : ""; + + return ( +
+
+ + onChange({ webhookUrl: e.target.value })} + placeholder={t("discord.webhookUrlPlaceholder")} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> + {urlHint && ( +

+ {urlHint} +

+ )} +

{t("discord.webhookUrlHint")}

+
+
+ + {t("discord.tutorial")} + +
    + {[1, 2, 3].map((n) => ( +
  1. + {n}. + {t(`discord.tutorialStep${n}`)} +
  2. + ))} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/SlackConfigForm.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/SlackConfigForm.tsx new file mode 100644 index 000000000000..ee1857023d9c --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/SlackConfigForm.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export interface SlackConfig { + webhookUrl: string; +} + +interface SlackConfigFormProps { + value: SlackConfig; + onChange: (v: SlackConfig) => void; + t: (key: string) => string; +} + +type UrlState = "idle" | "checking" | "ok" | "blocked" | "invalid"; + +export function SlackConfigForm({ value, onChange, t }: SlackConfigFormProps) { + const [urlState, setUrlState] = useState("idle"); + + useEffect(() => { + const url = value.webhookUrl.trim(); + const controller = new AbortController(); + const delay = url ? 600 : 0; + const timer = setTimeout(async () => { + if (!url) { + setUrlState("idle"); + return; + } + setUrlState("checking"); + try { + const res = await fetch("/api/webhooks/validate-url", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + signal: controller.signal, + }); + const data = await res.json().catch(() => ({})); + setUrlState(data.valid ? "ok" : data.reason === "blocked_private" ? "blocked" : "invalid"); + } catch { + if (!controller.signal.aborted) setUrlState("invalid"); + } + }, delay); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [value.webhookUrl]); + + const urlHint = + urlState === "checking" + ? t("validateUrl.checking") + : urlState === "ok" + ? t("validateUrl.ok") + : urlState === "blocked" + ? t("validateUrl.blockedPrivate") + : urlState === "invalid" && value.webhookUrl.trim() + ? t("validateUrl.invalidUrl") + : ""; + + return ( +
+
+ + onChange({ webhookUrl: e.target.value })} + placeholder={t("slack.webhookUrlPlaceholder")} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> + {urlHint && ( +

+ {urlHint} +

+ )} +

{t("slack.webhookUrlHint")}

+
+
+ + {t("slack.tutorial")} + +
    + {[1, 2, 3, 4].map((n) => ( +
  1. + {n}. + {t(`slack.tutorialStep${n}`)} +
  2. + ))} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/TelegramConfigForm.tsx b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/TelegramConfigForm.tsx new file mode 100644 index 000000000000..cbcd37a9a278 --- /dev/null +++ b/src/app/(dashboard)/dashboard/webhooks/components/steps/integrations/TelegramConfigForm.tsx @@ -0,0 +1,58 @@ +"use client"; + +export interface TelegramConfig { + botToken: string; + chatId: string; +} + +interface TelegramConfigFormProps { + value: TelegramConfig; + onChange: (v: TelegramConfig) => void; + t: (key: string) => string; +} + +export function TelegramConfigForm({ value, onChange, t }: TelegramConfigFormProps) { + return ( +
+
+ + onChange({ ...value, botToken: e.target.value })} + placeholder={t("telegram.botTokenPlaceholder")} + autoComplete="new-password" + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> +

{t("telegram.botTokenHint")}

+
+
+ + onChange({ ...value, chatId: e.target.value })} + placeholder={t("telegram.chatIdPlaceholder")} + className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" + /> +

{t("telegram.chatIdHint")}

+
+
+ + {t("telegram.tutorial")} + +
    + {[1, 2, 3, 4].map((n) => ( +
  1. + {n}. + {t(`telegram.tutorialStep${n}`)} +
  2. + ))} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/webhooks/page.tsx b/src/app/(dashboard)/dashboard/webhooks/page.tsx index 5205fdd07fe7..5012429cefea 100644 --- a/src/app/(dashboard)/dashboard/webhooks/page.tsx +++ b/src/app/(dashboard)/dashboard/webhooks/page.tsx @@ -1,617 +1,5 @@ -"use client"; - -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useTranslations } from "next-intl"; -import { Card, ConfirmModal, Modal } from "@/shared/components"; - -type WebhookItem = { - id: string; - url: string; - events: string[]; - secret: string | null; - enabled: boolean; - description: string; - created_at: string; - last_triggered_at: string | null; - last_status: number | null; - failure_count: number; -}; - -type WebhookFormState = { - url: string; - name: string; - secret: string; - events: string[]; - enabled: boolean; -}; - -type FeedbackState = { - type: "success" | "error"; - message: string; -} | null; - -const WEBHOOK_EVENTS = [ - "request.completed", - "request.failed", - "provider.error", - "provider.recovered", - "quota.exceeded", - "combo.switched", -] as const; - -const EMPTY_FORM: WebhookFormState = { - url: "", - name: "", - secret: "", - events: ["*"], - enabled: true, -}; - -function getWebhookStatus(webhook: WebhookItem): "active" | "inactive" | "errored" { - if (!webhook.enabled) return "inactive"; - if (webhook.failure_count > 0 || (webhook.last_status !== null && webhook.last_status >= 400)) { - return "errored"; - } - return "active"; -} +import { WebhooksPageClient } from "./WebhooksPageClient"; export default function WebhooksPage() { - const t = useTranslations("webhooks"); - const tc = useTranslations("common"); - const [webhooks, setWebhooks] = useState([]); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [testingId, setTestingId] = useState(null); - const [feedback, setFeedback] = useState(null); - const [form, setForm] = useState(EMPTY_FORM); - const [formMode, setFormMode] = useState<"create" | "edit" | null>(null); - const [editingWebhook, setEditingWebhook] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); - - const loadWebhooks = useCallback(async () => { - setLoading(true); - try { - const response = await fetch("/api/webhooks"); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.error || t("loadFailed")); - } - setWebhooks(Array.isArray(data.webhooks) ? data.webhooks : []); - } catch (error) { - setFeedback({ - type: "error", - message: error instanceof Error ? error.message : t("loadFailed"), - }); - } finally { - setLoading(false); - } - }, [t]); - - useEffect(() => { - void loadWebhooks(); - }, [loadWebhooks]); - - const stats = useMemo(() => { - return webhooks.reduce( - (acc, webhook) => { - const status = getWebhookStatus(webhook); - acc.total += 1; - acc[status] += 1; - return acc; - }, - { total: 0, active: 0, inactive: 0, errored: 0 } - ); - }, [webhooks]); - - const resetForm = () => { - setForm(EMPTY_FORM); - setFormMode(null); - setEditingWebhook(null); - }; - - const openCreateModal = () => { - setFeedback(null); - setForm(EMPTY_FORM); - setFormMode("create"); - setEditingWebhook(null); - }; - - const openEditModal = (webhook: WebhookItem) => { - setFeedback(null); - setFormMode("edit"); - setEditingWebhook(webhook); - setForm({ - url: webhook.url, - name: webhook.description || "", - secret: "", - events: webhook.events.length > 0 ? webhook.events : ["*"], - enabled: webhook.enabled, - }); - }; - - const closeModal = () => { - if (saving) return; - resetForm(); - }; - - const toggleEvent = (eventName: string) => { - setForm((prev) => { - if (eventName === "*") { - return { ...prev, events: ["*"] }; - } - - if (prev.events.includes("*")) { - return { ...prev, events: [eventName] }; - } - - const nextEvents = prev.events.includes(eventName) - ? prev.events.filter((event) => event !== eventName) - : [...prev.events, eventName]; - - return { ...prev, events: nextEvents.length > 0 ? nextEvents : ["*"] }; - }); - }; - - const saveWebhook = async () => { - if (!form.url.trim()) return; - - setSaving(true); - setFeedback(null); - - const payload: Record = { - url: form.url.trim(), - events: form.events, - description: form.name.trim(), - enabled: form.enabled, - }; - - if (form.secret.trim()) { - payload.secret = form.secret.trim(); - } - - const isEditing = formMode === "edit" && Boolean(editingWebhook?.id); - - try { - const response = await fetch( - isEditing ? `/api/webhooks/${editingWebhook?.id}` : "/api/webhooks", - { - method: isEditing ? "PUT" : "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - } - ); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.error || t("saveFailed")); - } - - setFeedback({ type: "success", message: t("saveSuccess") }); - resetForm(); - await loadWebhooks(); - } catch (error) { - setFeedback({ - type: "error", - message: error instanceof Error ? error.message : t("saveFailed"), - }); - } finally { - setSaving(false); - } - }; - - const toggleEnabled = async (webhook: WebhookItem) => { - setFeedback(null); - try { - const response = await fetch(`/api/webhooks/${webhook.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled: !webhook.enabled }), - }); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.error || t("saveFailed")); - } - - setWebhooks((prev) => - prev.map((item) => (item.id === webhook.id ? { ...item, enabled: !webhook.enabled } : item)) - ); - } catch (error) { - setFeedback({ - type: "error", - message: error instanceof Error ? error.message : t("saveFailed"), - }); - } - }; - - const testWebhook = async (webhook: WebhookItem) => { - setTestingId(webhook.id); - setFeedback(null); - try { - const response = await fetch(`/api/webhooks/${webhook.id}/test`, { method: "POST" }); - const data = await response.json().catch(() => ({})); - if (!response.ok || data.delivered === false) { - throw new Error(data.error || t("testFailed")); - } - - setFeedback({ type: "success", message: t("testSuccess") }); - await loadWebhooks(); - } catch (error) { - setFeedback({ - type: "error", - message: error instanceof Error ? error.message : t("testFailed"), - }); - } finally { - setTestingId(null); - } - }; - - const deleteWebhook = async () => { - if (!deleteTarget) return; - - setSaving(true); - setFeedback(null); - try { - const response = await fetch(`/api/webhooks/${deleteTarget.id}`, { method: "DELETE" }); - const data = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(data.error || t("deleteFailed")); - } - - setWebhooks((prev) => prev.filter((webhook) => webhook.id !== deleteTarget.id)); - setDeleteTarget(null); - setFeedback({ type: "success", message: t("deleteSuccess") }); - } catch (error) { - setFeedback({ - type: "error", - message: error instanceof Error ? error.message : t("deleteFailed"), - }); - } finally { - setSaving(false); - } - }; - - const modalTitle = formMode === "edit" ? t("editWebhook") : t("addWebhook"); - const isModalOpen = formMode !== null; - - return ( -
-
- -
- - {feedback && ( -
- {feedback.message} -
- )} - -
- {[ - { label: t("total"), value: stats.total, icon: "webhook", tone: "text-primary" }, - { - label: t("active"), - value: stats.active, - icon: "check_circle", - tone: "text-emerald-500", - }, - { - label: t("inactive"), - value: stats.inactive, - icon: "pause_circle", - tone: "text-text-muted", - }, - { label: t("errored"), value: stats.errored, icon: "error", tone: "text-red-500" }, - ].map((stat) => ( - -
-
-

- {stat.label} -

-

{stat.value}

-
- - {stat.icon} - -
-
- ))} -
- - -
-
-
-

{t("configuredWebhooks")}

-

{t("configuredWebhooksDesc")}

-
- -
-
- - {loading ? ( -
{t("loading")}
- ) : webhooks.length === 0 ? ( -
- webhook -

{t("noWebhooks")}

-
- ) : ( -
- - - - - - - - - - - - - {webhooks.map((webhook) => { - const status = getWebhookStatus(webhook); - return ( - - - - - - - - - ); - })} - -
{t("name")}{t("url")}{t("events")}{t("status")}{t("lastTriggered")}{t("actions")}
-
- {webhook.description || t("unnamedWebhook")} -
-
- {t("failureCount", { count: webhook.failure_count })} -
-
- - {webhook.url} - - -
- {webhook.events.map((eventName) => ( - - {eventName === "*" ? t("allEvents") : eventName} - - ))} -
-
- - - {status === "active" - ? "check_circle" - : status === "errored" - ? "error" - : "pause_circle"} - - {t(status)} - - - {webhook.last_triggered_at - ? new Date(webhook.last_triggered_at).toLocaleString() - : t("never")} - {webhook.last_status ? ( - ({webhook.last_status}) - ) : null} - -
- - - - -
-
-
- )} -
- - -
- vpn_key -
-

{t("signatureTitle")}

-

{t("signatureDescription")}

- - {`const sig = "sha256=" + crypto.createHmac("sha256", secret).update(body).digest("hex");`} - -
-
-
- - - - - - } - > -
-
- - setForm((prev) => ({ ...prev, name: event.target.value }))} - placeholder={t("namePlaceholder")} - className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" - /> -
-
- - setForm((prev) => ({ ...prev, url: event.target.value }))} - placeholder="https://example.com/webhook" - className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" - /> -
-
- - setForm((prev) => ({ ...prev, secret: event.target.value }))} - placeholder={ - formMode === "edit" ? t("secretEditPlaceholder") : t("secretPlaceholder") - } - className="mt-1 w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-main focus:outline-none focus:ring-2 focus:ring-primary/40" - /> -
-
- -
- - {WEBHOOK_EVENTS.map((eventName) => ( - - ))} -
-
- -
-
- - setDeleteTarget(null)} - onConfirm={deleteWebhook} - title={t("delete")} - message={t("deleteConfirm")} - confirmText={t("delete")} - cancelText={tc("cancel")} - loading={saving} - /> -
- ); + return ; } diff --git a/src/app/api/webhooks/[id]/deliveries/route.ts b/src/app/api/webhooks/[id]/deliveries/route.ts new file mode 100644 index 000000000000..0f7aee3516a9 --- /dev/null +++ b/src/app/api/webhooks/[id]/deliveries/route.ts @@ -0,0 +1,34 @@ +/** + * API: Webhook Delivery History + * GET — List recent deliveries for a webhook + */ + +import { NextResponse } from "next/server"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; +import { getWebhook, getDeliveries } from "@/lib/localDb"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + try { + const { id } = await params; + const webhook = getWebhook(id); + if (!webhook) { + return NextResponse.json({ error: "Webhook not found" }, { status: 404 }); + } + + const url = new URL(request.url); + const limitParam = url.searchParams.get("limit"); + const limit = Math.min(Math.max(1, parseInt(limitParam ?? "20", 10) || 20), 100); + + const deliveries = getDeliveries(id, limit); + return NextResponse.json({ deliveries }); + } catch (error: any) { + return NextResponse.json( + { error: sanitizeErrorMessage(error) || "Failed to fetch deliveries" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts index 220bd00cbf4d..84b7621913d5 100644 --- a/src/app/api/webhooks/[id]/route.ts +++ b/src/app/api/webhooks/[id]/route.ts @@ -11,14 +11,20 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { getWebhook, updateWebhookRecord, deleteWebhook } from "@/lib/localDb"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { encryptMetadata } from "@/lib/webhookDispatcher"; +import { isEncryptionEnabled } from "@/lib/db/encryption"; + +const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const; const updateWebhookSchema = z .object({ - url: z.string().url("Invalid URL format").max(2000).optional(), + url: z.string().min(1).max(2000).optional(), events: z.array(z.string()).optional(), secret: z.string().max(500).optional(), description: z.string().max(1000).optional(), enabled: z.boolean().optional(), + kind: z.enum(WEBHOOK_KINDS).optional(), + metadata: z.record(z.string()).optional(), }) .passthrough(); @@ -50,7 +56,28 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: return NextResponse.json({ error: validation.error }, { status: 400 }); } - const webhook = updateWebhookRecord(id, validation.data); + const { metadata, ...rest } = validation.data as typeof validation.data & { + metadata?: Record; + }; + + const existingWebhook = getWebhook(id); + if (!existingWebhook) { + return NextResponse.json({ error: "Webhook not found" }, { status: 404 }); + } + const effectiveKind = rest.kind ?? existingWebhook.kind; + if (effectiveKind === "telegram" && metadata?.botToken && !isEncryptionEnabled()) { + return NextResponse.json( + { error: "Telegram webhooks require STORAGE_ENCRYPTION_KEY to be configured" }, + { status: 400 } + ); + } + + const updateData: Parameters[1] = { ...rest }; + if (metadata !== undefined) { + (updateData as any).metadataEncrypted = encryptMetadata(metadata); + } + + const webhook = updateWebhookRecord(id, updateData); if (!webhook) { return NextResponse.json({ error: "Webhook not found" }, { status: 404 }); } diff --git a/src/app/api/webhooks/[id]/test/route.ts b/src/app/api/webhooks/[id]/test/route.ts index 0d5065853ee8..199a2e48e923 100644 --- a/src/app/api/webhooks/[id]/test/route.ts +++ b/src/app/api/webhooks/[id]/test/route.ts @@ -1,13 +1,67 @@ /** * API: Webhook Test Delivery - * POST — Send a test ping event to a specific webhook + * POST — Send a test ping event to a specific webhook and return full diagnostics. */ import { NextResponse } from "next/server"; import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; -import { getWebhook, recordWebhookDelivery } from "@/lib/localDb"; -import { deliverWebhook } from "@/lib/webhookDispatcher"; +import { getWebhook } from "@/lib/localDb"; +import { decryptMetadata } from "@/lib/webhookDispatcher"; +import { buildSlackPayload } from "@/lib/webhooks/integrations/slack"; +import { buildTelegramUrl, buildTelegramPayload } from "@/lib/webhooks/integrations/telegram"; +import { buildDiscordPayload } from "@/lib/webhooks/integrations/discord"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { insertDelivery } from "@/lib/db/webhookDeliveries"; +import { recordWebhookDelivery } from "@/lib/localDb"; +import crypto from "crypto"; + +const MAX_RESPONSE_BODY = 2048; + +async function testFetch( + url: string, + body: Record, + headers: Record +): Promise<{ + success: boolean; + status: number; + latencyMs: number; + responseBody: string; + error?: string; +}> { + const start = Date.now(); + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10_000); + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "User-Agent": "OmniRoute-Webhook/1.0", + ...headers, + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + clearTimeout(timeoutId); + const latencyMs = Date.now() - start; + let rawBody = ""; + try { + rawBody = await res.text(); + if (rawBody.length > MAX_RESPONSE_BODY) rawBody = rawBody.slice(0, MAX_RESPONSE_BODY) + "…"; + } catch { + rawBody = ""; + } + return { success: res.ok, status: res.status, latencyMs, responseBody: rawBody }; + } catch (error: any) { + return { + success: false, + status: 0, + latencyMs: Date.now() - start, + responseBody: "", + error: error.message || "Network error", + }; + } +} export async function POST(_: Request, { params }: { params: Promise<{ id: string }> }) { const authError = await requireManagementAuth(_); @@ -20,25 +74,69 @@ export async function POST(_: Request, { params }: { params: Promise<{ id: strin return NextResponse.json({ error: "Webhook not found" }, { status: 404 }); } - const result = await deliverWebhook( - webhook.url, - { - event: "test.ping", - timestamp: new Date().toISOString(), - data: { - message: "Test webhook delivery from OmniRoute", - webhookId: webhook.id, - }, - }, - webhook.secret, - 0 // No retries for test - ); + const kind = webhook.kind ?? "custom"; + const testData = { + message: "Test webhook delivery from OmniRoute", + webhookId: webhook.id, + }; + const testPayload = { event: "test.ping", timestamp: new Date().toISOString(), data: testData }; + let payloadSent: Record; + let fetchUrl: string; + let extraHeaders: Record = {}; + + if (kind === "slack") { + payloadSent = buildSlackPayload("test.ping", testData) as Record; + fetchUrl = webhook.url; + } else if (kind === "discord") { + payloadSent = buildDiscordPayload("test.ping", testData) as Record; + fetchUrl = webhook.url; + } else if (kind === "telegram") { + const meta = decryptMetadata(webhook.metadata_encrypted ?? null); + const botToken = meta?.botToken; + if (!botToken) { + return NextResponse.json({ error: "Missing Telegram botToken" }, { status: 422 }); + } + fetchUrl = buildTelegramUrl(botToken); + payloadSent = buildTelegramPayload("test.ping", testData, webhook.url) as Record< + string, + unknown + >; + } else { + payloadSent = testPayload as Record; + fetchUrl = webhook.url; + if (webhook.secret) { + const bodyStr = JSON.stringify(testPayload); + extraHeaders["X-Webhook-Signature"] = + `sha256=${crypto.createHmac("sha256", webhook.secret).update(bodyStr).digest("hex")}`; + extraHeaders["X-Webhook-Event"] = "test.ping"; + extraHeaders["X-Webhook-Timestamp"] = testPayload.timestamp; + } + } + + const result = await testFetch(fetchUrl, payloadSent, extraHeaders); + + try { + insertDelivery({ + webhookId: webhook.id, + eventType: "test.ping", + status: result.success ? "success" : "failed", + httpStatus: result.status || null, + latencyMs: result.latencyMs, + error: result.error ?? null, + payloadSnapshot: kind === "custom" ? JSON.stringify(payloadSent).slice(0, 2000) : null, + }); + } catch { + // delivery logging is best-effort + } recordWebhookDelivery(webhook.id, result.status, result.success); return NextResponse.json({ delivered: result.success, status: result.status, + latencyMs: result.latencyMs, + payloadSent, + responseBody: result.responseBody, error: result.error ? sanitizeErrorMessage(result.error) : null, }); } catch (error: any) { diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts index c69c4960b88d..bdb081073f75 100644 --- a/src/app/api/webhooks/route.ts +++ b/src/app/api/webhooks/route.ts @@ -10,12 +10,18 @@ import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { getWebhooks, createWebhook } from "@/lib/localDb"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { encryptMetadata } from "@/lib/webhookDispatcher"; +import { isEncryptionEnabled } from "@/lib/db/encryption"; + +const WEBHOOK_KINDS = ["slack", "telegram", "discord", "custom"] as const; const createWebhookSchema = z.object({ - url: z.string().url("Invalid URL format").max(2000), + url: z.string().min(1).max(2000), events: z.array(z.string()).optional().default(["*"]), secret: z.string().max(500).optional(), description: z.string().max(1000).optional().default(""), + kind: z.enum(WEBHOOK_KINDS).optional().default("custom"), + metadata: z.record(z.string()).optional(), }); export async function GET(request: Request) { @@ -50,11 +56,22 @@ export async function POST(request: Request) { } const { data } = validation; + + if (data.kind === "telegram" && !isEncryptionEnabled()) { + return NextResponse.json( + { error: "Telegram webhooks require STORAGE_ENCRYPTION_KEY to be configured" }, + { status: 400 } + ); + } + + const metadataEncrypted = data.metadata ? encryptMetadata(data.metadata) : undefined; const webhook = createWebhook({ url: data.url, events: data.events, secret: data.secret, description: data.description, + kind: data.kind, + metadataEncrypted, }); return NextResponse.json({ webhook }, { status: 201 }); diff --git a/src/app/api/webhooks/validate-url/route.ts b/src/app/api/webhooks/validate-url/route.ts new file mode 100644 index 000000000000..b328bffc410a --- /dev/null +++ b/src/app/api/webhooks/validate-url/route.ts @@ -0,0 +1,46 @@ +/** + * API: Validate Webhook URL + * POST — Check if a URL is safe to use as a webhook endpoint (SSRF guard) + */ + +import { z } from "zod"; +import { NextResponse } from "next/server"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; +import { + parseAndValidatePublicUrl, + OutboundUrlGuardError, +} from "@/shared/network/outboundUrlGuard"; +import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; + +const validateUrlSchema = z.object({ + url: z.string().min(1).max(2000), +}); + +export async function POST(request: Request) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const validation = validateBody(validateUrlSchema, rawBody); + if (isValidationFailure(validation)) { + return NextResponse.json({ error: validation.error }, { status: 400 }); + } + + const { url } = validation.data; + + try { + parseAndValidatePublicUrl(url); + return NextResponse.json({ valid: true }); + } catch (err) { + if (err instanceof OutboundUrlGuardError) { + return NextResponse.json({ valid: false, reason: "blocked_private" }); + } + return NextResponse.json({ valid: false, reason: "invalid_url" }); + } +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d5cc0caf72f5..2d4da499dadd 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -961,8 +961,10 @@ "never": "Never", "failureCount": "{count, plural, =0 {no failures} one {# failure} other {# failures}}", "testWebhook": "Send Test", - "testSuccess": "Test webhook sent successfully.", + "testSuccess": "delivery confirmed", "testFailed": "Test webhook failed.", + "testPayloadSent": "Payload sent", + "testResponse": "Endpoint response", "saveSuccess": "Webhook saved successfully.", "saveFailed": "Failed to save webhook.", "loadFailed": "Failed to load webhooks.", @@ -975,7 +977,106 @@ "disable": "Disable", "noWebhooks": "No webhooks configured yet.", "signatureTitle": "Webhook Signatures", - "signatureDescription": "Each delivery includes an X-Webhook-Signature header signed with HMAC-SHA256 using the webhook secret. Verify the signature before trusting the payload." + "signatureDescription": "Each delivery includes an X-Webhook-Signature header signed with HMAC-SHA256 using the webhook secret. Verify the signature before trusting the payload.", + "wizard": { + "step1Title": "Choose Integration", + "step1Desc": "Select the platform where you want to receive notifications.", + "step2Title": "Configure", + "step2Desc": "Enter the connection details for your integration.", + "step3Title": "Events & Test", + "step3Desc": "Select events to subscribe and send a test notification.", + "back": "Back", + "next": "Next", + "finish": "Add Webhook", + "cancel": "Cancel" + }, + "kinds": { + "slack": "Slack", + "slackDesc": "Send notifications to a Slack channel via Incoming Webhooks.", + "telegram": "Telegram", + "telegramDesc": "Send messages to a Telegram group or channel via Bot API.", + "discord": "Discord", + "discordDesc": "Send embeds to a Discord channel via Webhook URL.", + "custom": "Custom", + "customDesc": "POST JSON to any HTTP endpoint with optional HMAC-SHA256 signing.", + "email": "Email", + "emailDesc": "Send notifications to an email address.", + "pagerduty": "PagerDuty", + "pagerdutyDesc": "Trigger and resolve PagerDuty incidents automatically.", + "teams": "Microsoft Teams", + "teamsDesc": "Send messages to a Microsoft Teams channel.", + "comingSoon": "Coming soon" + }, + "slack": { + "webhookUrl": "Slack Webhook URL", + "webhookUrlPlaceholder": "https://hooks.slack.com/services/…", + "webhookUrlHint": "Create one in your Slack App under Incoming Webhooks.", + "tutorial": "How to get a Slack Webhook URL", + "tutorialStep1": "Go to api.slack.com/apps and open your app (or create one).", + "tutorialStep2": "Enable Incoming Webhooks in the Features menu.", + "tutorialStep3": "Click Add New Webhook to Workspace and choose the channel.", + "tutorialStep4": "Copy the generated webhook URL and paste it above." + }, + "telegram": { + "botToken": "Bot Token", + "botTokenPlaceholder": "123456789:ABC-DEF…", + "botTokenHint": "Get a token from @BotFather on Telegram.", + "chatId": "Chat ID", + "chatIdPlaceholder": "-100123456789", + "chatIdHint": "Use a negative ID for groups/channels (e.g. -1001234567890).", + "tutorial": "How to set up a Telegram Bot", + "tutorialStep1": "Open Telegram and search for @BotFather.", + "tutorialStep2": "Send /newbot, choose a name, and copy the bot token.", + "tutorialStep3": "Add the bot to your group or channel and make it an admin.", + "tutorialStep4": "Copy the Chat ID from the group info or use @userinfobot." + }, + "discord": { + "webhookUrl": "Discord Webhook URL", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/…", + "webhookUrlHint": "Create one in Discord channel settings under Integrations.", + "tutorial": "How to get a Discord Webhook URL", + "tutorialStep1": "Open Discord and go to your server's channel settings.", + "tutorialStep2": "Click Integrations → Webhooks → New Webhook.", + "tutorialStep3": "Choose a name and channel, then click Copy Webhook URL." + }, + "custom": { + "endpointUrl": "Endpoint URL", + "endpointUrlPlaceholder": "https://example.com/webhook", + "secretKey": "Signing Secret", + "secretKeyPlaceholder": "Leave blank to auto-generate", + "secretKeyHint": "Used to sign payloads with HMAC-SHA256 (X-Webhook-Signature header)." + }, + "validateUrl": { + "checking": "Checking URL…", + "blockedPrivate": "This URL points to a private/local address and is blocked for security.", + "invalidUrl": "This URL is not valid.", + "ok": "URL looks good." + }, + "deliveries": { + "title": "Delivery History", + "empty": "No deliveries recorded yet.", + "status": "Status", + "event": "Event", + "httpStatus": "HTTP", + "latency": "Latency", + "error": "Error", + "at": "At", + "success": "Success", + "failed": "Failed", + "loadFailed": "Failed to load delivery history." + }, + "howItWorks": { + "title": "How It Works", + "step1": "OmniRoute fires an event (e.g. request.failed).", + "step2": "The dispatcher builds a platform-specific payload.", + "step3": "The payload is posted to your configured endpoint.", + "step4": "You receive the notification in Slack, Telegram, Discord, or your custom handler.", + "customOnly": "For Custom webhooks, each delivery is signed with HMAC-SHA256.", + "hmacRecipeTitle": "Verifying the Signature", + "hmacRecipe": "const sig = req.headers['x-webhook-signature'];\nconst body = JSON.stringify(req.body);\nconst expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(body).digest('hex');\nif (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) throw new Error('Bad sig');", + "hmacRecipePython": "import hmac, hashlib\nsig = request.headers.get('X-Webhook-Signature', '')\nbody = request.get_data()\nexpected = 'sha256=' + hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()\nif not hmac.compare_digest(sig, expected):\n raise ValueError('Bad signature')", + "hmacRecipeBash": "SIG=$(echo -n \"$BODY\" | openssl dgst -sha256 -hmac \"$SECRET\" | awk '{print \"sha256=\" $2}')\n[ \"$SIG\" = \"$RECEIVED_SIG\" ] || { echo 'Bad signature'; exit 1; }" + } }, "compliance": { "auditTitle": "Audit", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 20bfa1100bf8..b6c57dfc19ae 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -960,8 +960,10 @@ "never": "Nunca", "failureCount": "{count, plural, =0 {sem falhas} one {# falha} other {# falhas}}", "testWebhook": "Enviar Teste", - "testSuccess": "Webhook de teste enviado com sucesso.", + "testSuccess": "entrega confirmada", "testFailed": "Webhook de teste falhou.", + "testPayloadSent": "Payload enviado", + "testResponse": "Resposta do endpoint", "saveSuccess": "Webhook salvo com sucesso.", "saveFailed": "Falha ao salvar webhook.", "loadFailed": "Falha ao carregar webhooks.", @@ -974,7 +976,106 @@ "disable": "Desativar", "noWebhooks": "Nenhum webhook configurado ainda.", "signatureTitle": "Assinaturas de Webhook", - "signatureDescription": "Cada entrega inclui um cabeçalho X-Webhook-Signature assinado com HMAC-SHA256 usando o segredo do webhook. Verifique a assinatura antes de confiar no payload." + "signatureDescription": "Cada entrega inclui um cabeçalho X-Webhook-Signature assinado com HMAC-SHA256 usando o segredo do webhook. Verifique a assinatura antes de confiar no payload.", + "wizard": { + "step1Title": "Escolher Integração", + "step1Desc": "Selecione a plataforma onde deseja receber notificações.", + "step2Title": "Configurar", + "step2Desc": "Insira os dados de conexão para a integração.", + "step3Title": "Eventos e Teste", + "step3Desc": "Selecione os eventos para assinar e envie uma notificação de teste.", + "back": "Voltar", + "next": "Próximo", + "finish": "Adicionar Webhook", + "cancel": "Cancelar" + }, + "kinds": { + "slack": "Slack", + "slackDesc": "Envie notificações para um canal Slack via Incoming Webhooks.", + "telegram": "Telegram", + "telegramDesc": "Envie mensagens para um grupo ou canal do Telegram via Bot API.", + "discord": "Discord", + "discordDesc": "Envie embeds para um canal do Discord via Webhook URL.", + "custom": "Personalizado", + "customDesc": "Faça POST de JSON para qualquer endpoint HTTP com assinatura HMAC-SHA256 opcional.", + "email": "E-mail", + "emailDesc": "Envie notificações para um endereço de e-mail.", + "pagerduty": "PagerDuty", + "pagerdutyDesc": "Acione e resolva incidentes no PagerDuty automaticamente.", + "teams": "Microsoft Teams", + "teamsDesc": "Envie mensagens para um canal do Microsoft Teams.", + "comingSoon": "Em breve" + }, + "slack": { + "webhookUrl": "URL do Webhook Slack", + "webhookUrlPlaceholder": "https://hooks.slack.com/services/…", + "webhookUrlHint": "Crie em seu Slack App em Incoming Webhooks.", + "tutorial": "Como obter uma URL de Webhook do Slack", + "tutorialStep1": "Acesse api.slack.com/apps e abra seu app (ou crie um).", + "tutorialStep2": "Ative Incoming Webhooks no menu Features.", + "tutorialStep3": "Clique em Add New Webhook to Workspace e escolha o canal.", + "tutorialStep4": "Copie a URL gerada e cole acima." + }, + "telegram": { + "botToken": "Token do Bot", + "botTokenPlaceholder": "123456789:ABC-DEF…", + "botTokenHint": "Obtenha um token com @BotFather no Telegram.", + "chatId": "Chat ID", + "chatIdPlaceholder": "-100123456789", + "chatIdHint": "Use um ID negativo para grupos/canais (ex.: -1001234567890).", + "tutorial": "Como configurar um Bot do Telegram", + "tutorialStep1": "Abra o Telegram e procure por @BotFather.", + "tutorialStep2": "Envie /newbot, escolha um nome e copie o token do bot.", + "tutorialStep3": "Adicione o bot ao grupo ou canal e torne-o administrador.", + "tutorialStep4": "Copie o Chat ID das informações do grupo ou use @userinfobot." + }, + "discord": { + "webhookUrl": "URL do Webhook Discord", + "webhookUrlPlaceholder": "https://discord.com/api/webhooks/…", + "webhookUrlHint": "Crie nas configurações do canal Discord em Integrações.", + "tutorial": "Como obter uma URL de Webhook do Discord", + "tutorialStep1": "Abra o Discord e acesse as configurações do canal no servidor.", + "tutorialStep2": "Clique em Integrações → Webhooks → Novo Webhook.", + "tutorialStep3": "Escolha nome e canal, depois clique em Copiar URL do Webhook." + }, + "custom": { + "endpointUrl": "URL do Endpoint", + "endpointUrlPlaceholder": "https://exemplo.com/webhook", + "secretKey": "Segredo de Assinatura", + "secretKeyPlaceholder": "Deixe em branco para gerar automaticamente", + "secretKeyHint": "Usado para assinar payloads com HMAC-SHA256 (cabeçalho X-Webhook-Signature)." + }, + "validateUrl": { + "checking": "Verificando URL…", + "blockedPrivate": "Esta URL aponta para um endereço privado/local e está bloqueada por segurança.", + "invalidUrl": "Esta URL não é válida.", + "ok": "URL parece válida." + }, + "deliveries": { + "title": "Histórico de Entregas", + "empty": "Nenhuma entrega registrada ainda.", + "status": "Status", + "event": "Evento", + "httpStatus": "HTTP", + "latency": "Latência", + "error": "Erro", + "at": "Em", + "success": "Sucesso", + "failed": "Falhou", + "loadFailed": "Falha ao carregar histórico de entregas." + }, + "howItWorks": { + "title": "Como Funciona", + "step1": "OmniRoute dispara um evento (ex.: request.failed).", + "step2": "O dispatcher constrói um payload específico para a plataforma.", + "step3": "O payload é enviado para o endpoint configurado.", + "step4": "Você recebe a notificação no Slack, Telegram, Discord ou handler personalizado.", + "customOnly": "Para webhooks Personalizados, cada entrega é assinada com HMAC-SHA256.", + "hmacRecipeTitle": "Verificando a Assinatura", + "hmacRecipe": "const sig = req.headers['x-webhook-signature'];\nconst body = JSON.stringify(req.body);\nconst expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(body).digest('hex');\nif (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) throw new Error('Assinatura inválida');", + "hmacRecipePython": "import hmac, hashlib\nsig = request.headers.get('X-Webhook-Signature', '')\nbody = request.get_data()\nexpected = 'sha256=' + hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()\nif not hmac.compare_digest(sig, expected):\n raise ValueError('Assinatura inválida')", + "hmacRecipeBash": "SIG=$(echo -n \"$BODY\" | openssl dgst -sha256 -hmac \"$SECRET\" | awk '{print \"sha256=\" $2}')\n[ \"$SIG\" = \"$RECEIVED_SIG\" ] || { echo 'Assinatura inválida'; exit 1; }" + } }, "compliance": { "auditTitle": "Auditoria", diff --git a/src/lib/db/migrations/068_webhooks_kind_metadata.sql b/src/lib/db/migrations/068_webhooks_kind_metadata.sql new file mode 100644 index 000000000000..877afe9e38a7 --- /dev/null +++ b/src/lib/db/migrations/068_webhooks_kind_metadata.sql @@ -0,0 +1,3 @@ +-- Migration 068: Add kind and encrypted metadata to webhooks +ALTER TABLE webhooks ADD COLUMN kind TEXT NOT NULL DEFAULT 'custom'; +ALTER TABLE webhooks ADD COLUMN metadata_encrypted BLOB; diff --git a/src/lib/db/migrations/069_webhook_deliveries.sql b/src/lib/db/migrations/069_webhook_deliveries.sql new file mode 100644 index 000000000000..1e343d296dcd --- /dev/null +++ b/src/lib/db/migrations/069_webhook_deliveries.sql @@ -0,0 +1,15 @@ +-- Migration 069: Webhook delivery history +CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + webhook_id TEXT NOT NULL, + event_type TEXT NOT NULL, + status TEXT NOT NULL, + http_status INTEGER, + latency_ms INTEGER, + error TEXT, + payload_snapshot TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook_created + ON webhook_deliveries(webhook_id, created_at DESC); diff --git a/src/lib/db/webhookDeliveries.ts b/src/lib/db/webhookDeliveries.ts new file mode 100644 index 000000000000..f57dee0ede3a --- /dev/null +++ b/src/lib/db/webhookDeliveries.ts @@ -0,0 +1,66 @@ +import { getDbInstance } from "./core"; + +export interface WebhookDelivery { + id: number; + webhook_id: string; + event_type: string; + status: string; + http_status: number | null; + latency_ms: number | null; + error: string | null; + payload_snapshot: string | null; + created_at: string; +} + +const MAX_DELIVERIES_PER_WEBHOOK = 100; + +export function insertDelivery(opts: { + webhookId: string; + eventType: string; + status: string; + httpStatus?: number | null; + latencyMs?: number | null; + error?: string | null; + payloadSnapshot?: string | null; +}): void { + const db = getDbInstance(); + const insertStmt = db.prepare( + `INSERT INTO webhook_deliveries + (webhook_id, event_type, status, http_status, latency_ms, error, payload_snapshot) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ); + const rotateStmt = db.prepare( + `DELETE FROM webhook_deliveries + WHERE webhook_id = ? + AND id NOT IN ( + SELECT id FROM webhook_deliveries + WHERE webhook_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ? + )` + ); + db.transaction(() => { + insertStmt.run( + opts.webhookId, + opts.eventType, + opts.status, + opts.httpStatus ?? null, + opts.latencyMs ?? null, + opts.error ?? null, + opts.payloadSnapshot ?? null + ); + rotateStmt.run(opts.webhookId, opts.webhookId, MAX_DELIVERIES_PER_WEBHOOK); + })(); +} + +export function getDeliveries(webhookId: string, limit: number): WebhookDelivery[] { + const db = getDbInstance(); + return db + .prepare( + `SELECT * FROM webhook_deliveries + WHERE webhook_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ?` + ) + .all(webhookId, limit) as WebhookDelivery[]; +} diff --git a/src/lib/db/webhooks.ts b/src/lib/db/webhooks.ts index 8a36c67ef00e..5fd5e223388b 100644 --- a/src/lib/db/webhooks.ts +++ b/src/lib/db/webhooks.ts @@ -6,6 +6,8 @@ import { getDbInstance } from "./core"; import crypto from "crypto"; +export type WebhookKind = "slack" | "telegram" | "discord" | "custom"; + export interface Webhook { id: string; url: string; @@ -17,6 +19,8 @@ export interface Webhook { last_triggered_at: string | null; last_status: number | null; failure_count: number; + kind: WebhookKind; + metadata_encrypted: string | null; } interface WebhookRow { @@ -30,11 +34,14 @@ interface WebhookRow { last_triggered_at: string | null; last_status: number | null; failure_count: number; + kind: string; + metadata_encrypted: string | null; } function rowToWebhook(row: WebhookRow): Webhook { return { ...row, + kind: (row.kind as WebhookKind) || "custom", events: JSON.parse(row.events || '["*"]'), enabled: row.enabled === 1, }; @@ -63,15 +70,26 @@ export function createWebhook(data: { events?: string[]; secret?: string; description?: string; + kind?: WebhookKind; + metadataEncrypted?: string | null; }): Webhook { const db = getDbInstance(); const id = crypto.randomUUID(); const secret = data.secret || `whsec_${crypto.randomBytes(24).toString("hex")}`; + const kind = data.kind || "custom"; db.prepare( - `INSERT INTO webhooks (id, url, events, secret, description) - VALUES (?, ?, ?, ?, ?)` - ).run(id, data.url, JSON.stringify(data.events || ["*"]), secret, data.description || ""); + `INSERT INTO webhooks (id, url, events, secret, description, kind, metadata_encrypted) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + data.url, + JSON.stringify(data.events || ["*"]), + secret, + data.description || "", + kind, + data.metadataEncrypted ?? null + ); return getWebhook(id)!; } @@ -84,6 +102,8 @@ export function updateWebhook( secret: string; enabled: boolean; description: string; + kind: WebhookKind; + metadataEncrypted: string | null; }> ): Webhook | null { const db = getDbInstance(); @@ -113,6 +133,14 @@ export function updateWebhook( fields.push("description = ?"); values.push(data.description); } + if (data.kind !== undefined) { + fields.push("kind = ?"); + values.push(data.kind); + } + if (data.metadataEncrypted !== undefined) { + fields.push("metadata_encrypted = ?"); + values.push(data.metadataEncrypted); + } if (fields.length === 0) return existing; diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 3edfaf573c50..bd6a09ba67ad 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -284,7 +284,10 @@ export { disableWebhooksWithHighFailures, } from "./db/webhooks"; -export type { Webhook } from "./db/webhooks"; +export type { Webhook, WebhookKind } from "./db/webhooks"; + +export { insertDelivery, getDeliveries } from "./db/webhookDeliveries"; +export type { WebhookDelivery } from "./db/webhookDeliveries"; export { saveQuotaSnapshot, diff --git a/src/lib/webhookDispatcher.ts b/src/lib/webhookDispatcher.ts index 7182055d8b39..c5ef9addf7a0 100644 --- a/src/lib/webhookDispatcher.ts +++ b/src/lib/webhookDispatcher.ts @@ -1,18 +1,15 @@ /** * Webhook Dispatcher - * Dispatches events to registered webhooks with HMAC-SHA256 signing and retries + * Dispatches events to registered webhooks with HMAC-SHA256 signing and retries. + * Slack/Telegram/Discord use per-kind payload transformers (no HMAC wrapping). */ import crypto from "crypto"; +import { encrypt, decrypt } from "./db/encryption"; +import { parseAndValidatePublicUrl } from "@/shared/network/outboundUrlGuard"; +import type { WebhookEvent } from "./webhooks/eventDescriptions"; -export type WebhookEvent = - | "request.completed" - | "request.failed" - | "provider.error" - | "provider.recovered" - | "quota.exceeded" - | "combo.switched" - | "test.ping"; +export type { WebhookEvent }; export interface WebhookPayload { event: WebhookEvent; @@ -24,12 +21,59 @@ function signPayload(payload: string, secret: string): string { return `sha256=${crypto.createHmac("sha256", secret).update(payload).digest("hex")}`; } +export function encryptMetadata(meta: Record): string { + return encrypt(JSON.stringify(meta)) ?? JSON.stringify(meta); +} + +export function decryptMetadata(encrypted: string | null): Record | null { + if (!encrypted) return null; + const raw = decrypt(encrypted); + if (!raw) return null; + try { + return JSON.parse(raw) as Record; + } catch { + return null; + } +} + +async function deliverRaw( + url: string, + body: Record +): Promise<{ success: boolean; status: number; latencyMs: number; error?: string }> { + const start = Date.now(); + try { + parseAndValidatePublicUrl(url); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10_000); + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", "User-Agent": "OmniRoute-Webhook/1.0" }, + body: JSON.stringify(body), + signal: controller.signal, + }); + clearTimeout(timeoutId); + return { success: res.ok, status: res.status, latencyMs: Date.now() - start }; + } catch (error: any) { + return { + success: false, + status: 0, + latencyMs: Date.now() - start, + error: error.message || "Network error", + }; + } +} + export async function deliverWebhook( url: string, payload: WebhookPayload, secret?: string | null, maxRetries = 3 ): Promise<{ success: boolean; status: number; error?: string }> { + try { + parseAndValidatePublicUrl(url); + } catch (error: any) { + return { success: false, status: 0, error: error.message || "Blocked outbound URL" }; + } const body = JSON.stringify(payload); const headers: Record = { "Content-Type": "application/json", @@ -45,7 +89,7 @@ export async function deliverWebhook( for (let attempt = 0; attempt <= maxRetries; attempt++) { try { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout + const timeoutId = setTimeout(() => controller.abort(), 10_000); const res = await fetch(url, { method: "POST", @@ -59,7 +103,6 @@ export async function deliverWebhook( return { success: res.ok, status: res.status }; } - // Server error — retry with exponential backoff if (attempt < maxRetries) { await new Promise((r) => setTimeout(r, Math.pow(2, attempt) * 1000)); } @@ -75,12 +118,18 @@ export async function deliverWebhook( } /** - * Dispatch an event to all matching enabled webhooks + * Dispatch an event to all matching enabled webhooks. + * Routes by kind: slack/discord use raw payload helpers; telegram decrypts botToken from metadata; + * custom uses HMAC-signed deliverWebhook. */ export async function dispatchEvent(event: WebhookEvent, data: Record): Promise { - // Lazy import to avoid circular deps const { getEnabledWebhooks, recordWebhookDelivery, disableWebhooksWithHighFailures } = await import("./db/webhooks"); + const { insertDelivery } = await import("./db/webhookDeliveries"); + const { buildSlackPayload } = await import("./webhooks/integrations/slack"); + const { buildTelegramUrl, buildTelegramPayload } = + await import("./webhooks/integrations/telegram"); + const { buildDiscordPayload } = await import("./webhooks/integrations/discord"); const webhooks = getEnabledWebhooks(); const payload: WebhookPayload = { @@ -90,18 +139,57 @@ export async function dispatchEvent(event: WebhookEvent, data: Record { - const events = wh.events; - return events.includes("*") || events.includes(event); - }) + .filter((wh) => wh.events.includes("*") || wh.events.includes(event)) .map(async (wh) => { - const result = await deliverWebhook(wh.url, payload, wh.secret); + const kind = wh.kind ?? "custom"; + const start = Date.now(); + let result: { success: boolean; status: number; error?: string }; + + try { + if (kind === "slack") { + const slackPayload = buildSlackPayload(event, data); + result = await deliverRaw(wh.url, slackPayload as Record); + } else if (kind === "discord") { + const discordPayload = buildDiscordPayload(event, data); + result = await deliverRaw(wh.url, discordPayload as Record); + } else if (kind === "telegram") { + const meta = decryptMetadata(wh.metadata_encrypted ?? null); + const botToken = meta?.botToken; + if (!botToken) { + result = { success: false, status: 0, error: "Missing Telegram botToken in metadata" }; + } else { + const apiUrl = buildTelegramUrl(botToken); + // For Telegram, wh.url stores the chat_id + const tgPayload = buildTelegramPayload(event, data, wh.url); + result = await deliverRaw(apiUrl, tgPayload as Record); + } + } else { + result = await deliverWebhook(wh.url, payload, wh.secret); + } + } catch (err: any) { + result = { success: false, status: 0, error: err.message || "Dispatch error" }; + } + + const latencyMs = Date.now() - start; + + try { + insertDelivery({ + webhookId: wh.id, + eventType: event, + status: result.success ? "success" : "failed", + httpStatus: result.status || null, + latencyMs, + error: result.error ?? null, + payloadSnapshot: kind === "custom" ? JSON.stringify(payload).slice(0, 2000) : null, + }); + } catch { + // Delivery logging is best-effort + } + recordWebhookDelivery(wh.id, result.status, result.success); return { webhookId: wh.id, ...result }; }); await Promise.allSettled(deliveries); - - // Auto-disable webhooks with too many failures disableWebhooksWithHighFailures(10); } diff --git a/src/lib/webhooks/eventDescriptions.ts b/src/lib/webhooks/eventDescriptions.ts new file mode 100644 index 000000000000..bac4e382c604 --- /dev/null +++ b/src/lib/webhooks/eventDescriptions.ts @@ -0,0 +1,85 @@ +export type WebhookEvent = + | "request.completed" + | "request.failed" + | "provider.error" + | "provider.recovered" + | "quota.exceeded" + | "combo.switched" + | "test.ping"; + +export interface EventDescription { + label: string; + description: string; + emoji: string; + exampleData: Record; +} + +export const EVENT_DESCRIPTIONS: Record = { + "request.completed": { + label: "Request Completed", + emoji: "✅", + description: "Triggered when an upstream request completes successfully (HTTP 2xx).", + exampleData: { + model: "claude-opus-4-7", + provider: "claude", + latencyMs: 1240, + tokensIn: 142, + tokensOut: 38, + }, + }, + "request.failed": { + label: "Request Failed", + emoji: "🚨", + description: "Triggered when a request fails after all retries and fallback combo targets.", + exampleData: { + model: "claude-opus-4-7", + provider: "claude", + error: "503 Service Unavailable", + attempts: 3, + }, + }, + "provider.error": { + label: "Provider Error", + emoji: "⚠️", + description: "A provider tripped the circuit breaker due to repeated failures.", + exampleData: { provider: "openai", model: "gpt-4o", errorCode: 503, consecutiveFailures: 3 }, + }, + "provider.recovered": { + label: "Provider Recovered", + emoji: "✅", + description: "A provider recovered from a circuit-breaker OPEN state.", + exampleData: { provider: "openai", recoveredAfterMs: 60000 }, + }, + "quota.exceeded": { + label: "Quota Exceeded", + emoji: "📊", + description: "A usage threshold (e.g. 95% of quota) was reached.", + exampleData: { quota: "daily_tokens", used: 950000, limit: 1000000, pct: 95 }, + }, + "combo.switched": { + label: "Combo Switched", + emoji: "🔄", + description: "Combo routing switched to a different target.", + exampleData: { + combo: "auto-fallback", + fromModel: "gpt-4o", + toModel: "claude-opus-4-7", + reason: "provider.error", + }, + }, + "test.ping": { + label: "Test Ping", + emoji: "🏓", + description: "Manual test delivery to verify your webhook is reachable.", + exampleData: { message: "Test ping from OmniRoute", webhookId: "preview" }, + }, +}; + +export function buildExamplePayload(event: WebhookEvent): Record { + return { + event, + webhook_id: "preview", + timestamp: new Date().toISOString(), + data: EVENT_DESCRIPTIONS[event].exampleData, + }; +} diff --git a/src/lib/webhooks/integrations/discord.ts b/src/lib/webhooks/integrations/discord.ts new file mode 100644 index 000000000000..0b3fdc4199ac --- /dev/null +++ b/src/lib/webhooks/integrations/discord.ts @@ -0,0 +1,49 @@ +import type { WebhookEvent } from "../eventDescriptions"; +import { EVENT_DESCRIPTIONS } from "../eventDescriptions"; + +export interface DiscordEmbed { + title: string; + description: string; + color: number; + footer: { text: string }; +} + +export interface DiscordPayload { + content?: string; + embeds?: DiscordEmbed[]; +} + +const EVENT_COLORS: Partial> = { + "request.completed": 0x22c55e, + "request.failed": 0xef4444, + "provider.error": 0xf97316, + "provider.recovered": 0x22c55e, + "quota.exceeded": 0xeab308, + "combo.switched": 0x3b82f6, + "test.ping": 0x8b5cf6, +}; + +export function buildDiscordPayload( + event: WebhookEvent, + data: Record +): DiscordPayload { + const desc = EVENT_DESCRIPTIONS[event]; + const model = typeof data.model === "string" ? data.model : null; + const error = typeof data.error === "string" ? data.error : null; + + const lines: string[] = []; + if (model) lines.push(`**Model:** \`${model}\``); + if (error) lines.push(`**Error:** \`${error}\``); + if (lines.length === 0) lines.push(desc.description); + + return { + embeds: [ + { + title: `${desc.emoji} ${desc.label}`, + description: lines.join("\n"), + color: EVENT_COLORS[event] ?? 0x6366f1, + footer: { text: `OmniRoute · ${new Date().toISOString()}` }, + }, + ], + }; +} diff --git a/src/lib/webhooks/integrations/slack.ts b/src/lib/webhooks/integrations/slack.ts new file mode 100644 index 000000000000..2de7efce27b7 --- /dev/null +++ b/src/lib/webhooks/integrations/slack.ts @@ -0,0 +1,45 @@ +import type { WebhookEvent } from "../eventDescriptions"; +import { EVENT_DESCRIPTIONS } from "../eventDescriptions"; + +export interface SlackPayload { + text: string; + blocks?: unknown[]; +} + +export function buildSlackPayload( + event: WebhookEvent, + data: Record +): SlackPayload { + const desc = EVENT_DESCRIPTIONS[event]; + const model = typeof data.model === "string" ? data.model : null; + const provider = typeof data.provider === "string" ? data.provider : null; + const error = typeof data.error === "string" ? data.error : null; + + const titleParts = [`${desc.emoji} *${desc.label}*`]; + if (model) titleParts.push(`on \`${model}\``); + + const lines: string[] = [titleParts.join(" ")]; + if (error) lines.push(`*Error:* \`${error}\``); + if (provider && !model) lines.push(`*Provider:* ${provider}`); + + const text = lines.join("\n"); + + return { + text, + blocks: [ + { + type: "section", + text: { type: "mrkdwn", text }, + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `OmniRoute · ${new Date().toISOString()}`, + }, + ], + }, + ], + }; +} diff --git a/src/lib/webhooks/integrations/telegram.ts b/src/lib/webhooks/integrations/telegram.ts new file mode 100644 index 000000000000..90d5ea5a5b30 --- /dev/null +++ b/src/lib/webhooks/integrations/telegram.ts @@ -0,0 +1,47 @@ +import type { WebhookEvent } from "../eventDescriptions"; +import { EVENT_DESCRIPTIONS } from "../eventDescriptions"; + +export interface TelegramSendMessagePayload { + chat_id: string; + text: string; + parse_mode: "Markdown"; +} + +function escapeMd(s: string): string { + // Escape Telegram Markdown v1 special chars: _ * [ ] ` + // Hyphens and parentheses are safe in Markdown v1 + return s.replace(/[_*[\]`]/g, (c) => `\\${c}`); +} + +// Telegram bot token format: : (min 35 chars after colon) +const BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{35,}$/; + +export function buildTelegramUrl(botToken: string): string { + if (!BOT_TOKEN_RE.test(botToken)) { + throw new Error("Invalid Telegram bot token format (expected :)"); + } + return `https://api.telegram.org/bot${botToken}/sendMessage`; +} + +export function buildTelegramPayload( + event: WebhookEvent, + data: Record, + chatId: string +): TelegramSendMessagePayload { + const desc = EVENT_DESCRIPTIONS[event]; + const model = typeof data.model === "string" ? escapeMd(data.model) : null; + const error = typeof data.error === "string" ? escapeMd(data.error) : null; + const provider = typeof data.provider === "string" ? escapeMd(data.provider) : null; + + const lines: string[] = [`${desc.emoji} *${desc.label}*`]; + if (model) lines.push(`Model: \`${model}\``); + if (provider && !model) lines.push(`Provider: \`${provider}\``); + if (error) lines.push(`Error: \`${error}\``); + lines.push(`_OmniRoute · ${new Date().toISOString()}_`); + + return { + chat_id: chatId, + text: lines.join("\n"), + parse_mode: "Markdown", + }; +} diff --git a/tests/unit/webhook-deliveries-db.test.ts b/tests/unit/webhook-deliveries-db.test.ts new file mode 100644 index 000000000000..a57708e9f3aa --- /dev/null +++ b/tests/unit/webhook-deliveries-db.test.ts @@ -0,0 +1,113 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omni-deliveries-")); +process.env.DATA_DIR = TEST_DIR; + +const coreDb = await import("../../src/lib/db/core.ts"); +const webhooksDb = await import("../../src/lib/db/webhooks.ts"); +const deliveriesDb = await import("../../src/lib/db/webhookDeliveries.ts"); + +async function resetStorage() { + coreDb.resetDbInstance(); + fs.rmSync(TEST_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + coreDb.resetDbInstance(); + fs.rmSync(TEST_DIR, { recursive: true, force: true }); +}); + +test("insertDelivery stores a row and getDeliveries returns it", () => { + const wh = webhooksDb.createWebhook({ url: "https://example.com/hook" }); + deliveriesDb.insertDelivery({ + webhookId: wh.id, + eventType: "request.failed", + status: "delivered", + httpStatus: 200, + latencyMs: 142, + payloadSnapshot: JSON.stringify({ event: "request.failed" }), + }); + const rows = deliveriesDb.getDeliveries(wh.id, 10); + assert.equal(rows.length, 1); + assert.equal(rows[0].event_type, "request.failed"); + assert.equal(rows[0].http_status, 200); + assert.equal(rows[0].latency_ms, 142); + assert.equal(rows[0].status, "delivered"); +}); + +test("rotation keeps only last 100 deliveries per webhook", () => { + const wh = webhooksDb.createWebhook({ url: "https://example.com/hook" }); + for (let i = 0; i < 105; i++) { + deliveriesDb.insertDelivery({ + webhookId: wh.id, + eventType: "test.ping", + status: "delivered", + httpStatus: 200, + latencyMs: 10, + }); + } + const rows = deliveriesDb.getDeliveries(wh.id, 200); + assert.equal(rows.length, 100); +}); + +test("getDeliveries respects limit parameter", () => { + const wh = webhooksDb.createWebhook({ url: "https://example.com/hook" }); + for (let i = 0; i < 10; i++) { + deliveriesDb.insertDelivery({ + webhookId: wh.id, + eventType: "test.ping", + status: "delivered", + httpStatus: 200, + latencyMs: 10, + }); + } + const rows = deliveriesDb.getDeliveries(wh.id, 5); + assert.equal(rows.length, 5); +}); + +test("CASCADE delete removes deliveries when webhook is deleted", () => { + const wh = webhooksDb.createWebhook({ url: "https://example.com/hook" }); + deliveriesDb.insertDelivery({ + webhookId: wh.id, + eventType: "test.ping", + status: "delivered", + httpStatus: 200, + latencyMs: 10, + }); + webhooksDb.deleteWebhook(wh.id); + const rows = deliveriesDb.getDeliveries(wh.id, 10); + assert.equal(rows.length, 0); +}); + +test("createWebhook uses kind=custom by default", () => { + const wh = webhooksDb.createWebhook({ url: "https://example.com/hook" }); + assert.equal(wh.kind, "custom"); + assert.equal(wh.metadata_encrypted, null); +}); + +test("createWebhook accepts kind=slack", () => { + const wh = webhooksDb.createWebhook({ + url: "https://hooks.slack.com/services/T/B/xxx", + kind: "slack", + }); + assert.equal(wh.kind, "slack"); +}); + +test("updateWebhook can change kind and metadataEncrypted", () => { + const wh = webhooksDb.createWebhook({ url: "https://example.com/hook" }); + const updated = webhooksDb.updateWebhook(wh.id, { + kind: "telegram", + metadataEncrypted: "enc:v1:test", + }); + assert.equal(updated?.kind, "telegram"); + assert.equal(updated?.metadata_encrypted, "enc:v1:test"); +}); diff --git a/tests/unit/webhook-discord-dispatcher.test.ts b/tests/unit/webhook-discord-dispatcher.test.ts new file mode 100644 index 000000000000..4a49c3ba2ef0 --- /dev/null +++ b/tests/unit/webhook-discord-dispatcher.test.ts @@ -0,0 +1,41 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildDiscordPayload } = await import("../../src/lib/webhooks/integrations/discord.ts"); + +test("buildDiscordPayload — request.failed produces embed with model", () => { + const payload = buildDiscordPayload("request.failed", { + model: "claude-opus-4-7", + error: "503", + }); + assert.ok(payload.content || payload.embeds, "should have content or embeds"); + const combined = JSON.stringify(payload); + assert.ok(combined.includes("claude-opus-4-7"), "should include model name"); +}); + +test("buildDiscordPayload — all WEBHOOK_EVENTS return object with content or embeds", () => { + const events = [ + "request.completed", + "request.failed", + "provider.error", + "provider.recovered", + "quota.exceeded", + "combo.switched", + "test.ping", + ] as const; + for (const event of events) { + const payload = buildDiscordPayload(event, {}); + assert.ok( + payload.content || (Array.isArray(payload.embeds) && payload.embeds.length > 0), + `event ${event} must have content or embeds` + ); + } +}); + +test("buildDiscordPayload — embeds have title and color fields", () => { + const payload = buildDiscordPayload("provider.error", { provider: "openai" }); + assert.ok(Array.isArray(payload.embeds) && payload.embeds.length > 0, "should have embeds"); + const embed = payload.embeds![0]; + assert.ok(typeof embed.title === "string" && embed.title.length > 0, "embed must have title"); + assert.ok(typeof embed.color === "number", "embed must have color"); +}); diff --git a/tests/unit/webhook-dispatcher-routing.test.ts b/tests/unit/webhook-dispatcher-routing.test.ts new file mode 100644 index 000000000000..13302811cdc5 --- /dev/null +++ b/tests/unit/webhook-dispatcher-routing.test.ts @@ -0,0 +1,31 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { encryptMetadata, decryptMetadata } = await import("../../src/lib/webhookDispatcher.ts"); + +test("encryptMetadata roundtrips without encryption key (passthrough mode)", () => { + const original = { botToken: "123456:ABC-DEF", extra: "value" }; + const encrypted = encryptMetadata(original); + assert.ok(typeof encrypted === "string" && encrypted.length > 0); + const decrypted = decryptMetadata(encrypted); + assert.deepEqual(decrypted, original); +}); + +test("decryptMetadata returns null for null input", () => { + const result = decryptMetadata(null); + assert.equal(result, null); +}); + +test("decryptMetadata returns null for malformed JSON", () => { + // Passthrough mode stores plaintext — inject bad JSON + const result = decryptMetadata("not-valid-json"); + assert.equal(result, null); +}); + +test("encryptMetadata produces stable parseable JSON in passthrough mode", () => { + const meta = { botToken: "tok", chatId: "-100123" }; + const enc = encryptMetadata(meta); + const dec = decryptMetadata(enc); + assert.equal(dec?.botToken, "tok"); + assert.equal(dec?.chatId, "-100123"); +}); diff --git a/tests/unit/webhook-slack-dispatcher.test.ts b/tests/unit/webhook-slack-dispatcher.test.ts new file mode 100644 index 000000000000..44b34626cf28 --- /dev/null +++ b/tests/unit/webhook-slack-dispatcher.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildSlackPayload } = await import("../../src/lib/webhooks/integrations/slack.ts"); + +test("buildSlackPayload — request.failed produces Block Kit with model in title", () => { + const payload = buildSlackPayload("request.failed", { + model: "claude-opus-4-7", + error: "503 Service Unavailable", + attempts: 3, + }); + assert.ok(payload.blocks, "should have blocks array"); + const sectionText = JSON.stringify(payload.blocks); + assert.ok(sectionText.includes("claude-opus-4-7"), "should include model name"); + assert.ok( + sectionText.toLowerCase().includes("request failed") || + payload.text.toLowerCase().includes("request failed"), + "should include event label" + ); +}); + +test("buildSlackPayload — test.ping produces a ping/test message", () => { + const payload = buildSlackPayload("test.ping", { message: "Test ping from OmniRoute" }); + const combined = JSON.stringify(payload); + assert.ok( + combined.toLowerCase().includes("ping") || + combined.toLowerCase().includes("test") || + combined.toLowerCase().includes("🏓"), + "should reference test/ping" + ); +}); + +test("buildSlackPayload — provider.error includes provider context", () => { + const payload = buildSlackPayload("provider.error", { provider: "openai", model: "gpt-4" }); + const combined = JSON.stringify(payload); + assert.ok( + combined.includes("Provider") || + combined.includes("provider") || + combined.includes("error") || + combined.includes("⚠️"), + "should reference provider/error" + ); +}); + +test("buildSlackPayload — all WEBHOOK_EVENTS produce valid payloads with text field", () => { + const events = [ + "request.completed", + "request.failed", + "provider.error", + "provider.recovered", + "quota.exceeded", + "combo.switched", + "test.ping", + ] as const; + for (const event of events) { + const payload = buildSlackPayload(event, {}); + assert.ok( + typeof payload.text === "string" && payload.text.length > 0, + `event ${event} must produce non-empty text` + ); + } +}); diff --git a/tests/unit/webhook-telegram-dispatcher.test.ts b/tests/unit/webhook-telegram-dispatcher.test.ts new file mode 100644 index 000000000000..6d717b2d2158 --- /dev/null +++ b/tests/unit/webhook-telegram-dispatcher.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { buildTelegramPayload, buildTelegramUrl } = + await import("../../src/lib/webhooks/integrations/telegram.ts"); + +const VALID_TOKEN = "123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi"; + +test("buildTelegramUrl constructs correct API URL from valid botToken", () => { + const url = buildTelegramUrl(VALID_TOKEN); + assert.equal(url, `https://api.telegram.org/bot${VALID_TOKEN}/sendMessage`); +}); + +test("buildTelegramUrl throws on invalid botToken format", () => { + assert.throws(() => buildTelegramUrl("123456:SHORT"), /Invalid Telegram bot token/); + assert.throws( + () => buildTelegramUrl("notanumber:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi"), + /Invalid Telegram bot token/ + ); + assert.throws(() => buildTelegramUrl(""), /Invalid Telegram bot token/); +}); + +test("buildTelegramPayload — request.failed includes model and event label", () => { + const payload = buildTelegramPayload( + "request.failed", + { model: "claude-opus-4-7", error: "503" }, + "-100123" + ); + assert.equal(payload.chat_id, "-100123"); + assert.ok(payload.text.includes("claude-opus-4-7"), "should include model name"); + assert.ok( + payload.text.toLowerCase().includes("request failed") || + payload.text.toLowerCase().includes("failed"), + "should include event label" + ); + assert.equal(payload.parse_mode, "Markdown"); +}); + +test("buildTelegramPayload — chat_id matches provided value for groups", () => { + const payload = buildTelegramPayload("test.ping", { message: "ping" }, "-1001234567890"); + assert.equal(payload.chat_id, "-1001234567890"); +}); + +test("buildTelegramPayload — all WEBHOOK_EVENTS produce valid payloads with chat_id", () => { + const events = [ + "request.completed", + "request.failed", + "provider.error", + "provider.recovered", + "quota.exceeded", + "combo.switched", + "test.ping", + ] as const; + for (const event of events) { + const payload = buildTelegramPayload(event, {}, "99999"); + assert.equal(payload.chat_id, "99999"); + assert.ok( + typeof payload.text === "string" && payload.text.length > 0, + `event ${event} must produce non-empty text` + ); + } +}); diff --git a/vitest.config.ts b/vitest.config.ts index 95d3dc286f71..e64af80a717b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ include: [ "src/app/**/dashboard/cache/__tests__/**/*.test.tsx", "src/app/**/dashboard/endpoint/__tests__/**/*.test.tsx", + "src/app/**/dashboard/webhooks/__tests__/**/*.test.tsx", "src/shared/hooks/__tests__/**/*.test.tsx", "src/lib/memory/__tests__/**/*.test.ts", "src/lib/skills/__tests__/**/*.test.ts", From c73d025632451d5d5c058c4f49c96b8b5f2dfb2f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 26 May 2026 00:28:03 -0300 Subject: [PATCH 31/77] =?UTF-8?q?feat(openapi):=20API=20endpoints=20conten?= =?UTF-8?q?t=20audit=20=E2=80=94=20100%=20coverage,=20security=20tiers,=20?= =?UTF-8?q?i18n=20(#2701)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.4 --- .husky/pre-commit | 7 + CHANGELOG.md | 12 +- docs/i18n/ar/CHANGELOG.md | 4 +- docs/i18n/az/CHANGELOG.md | 4 +- docs/i18n/bg/CHANGELOG.md | 4 +- docs/i18n/bn/CHANGELOG.md | 4 +- docs/i18n/cs/CHANGELOG.md | 4 +- docs/i18n/da/CHANGELOG.md | 4 +- docs/i18n/de/CHANGELOG.md | 4 +- docs/i18n/es/CHANGELOG.md | 4 +- docs/i18n/fa/CHANGELOG.md | 4 +- docs/i18n/fi/CHANGELOG.md | 4 +- docs/i18n/fr/CHANGELOG.md | 4 +- docs/i18n/gu/CHANGELOG.md | 4 +- docs/i18n/he/CHANGELOG.md | 4 +- docs/i18n/hi/CHANGELOG.md | 4 +- docs/i18n/hu/CHANGELOG.md | 4 +- docs/i18n/id/CHANGELOG.md | 4 +- docs/i18n/in/CHANGELOG.md | 4 +- docs/i18n/it/CHANGELOG.md | 4 +- docs/i18n/ja/CHANGELOG.md | 4 +- docs/i18n/ko/CHANGELOG.md | 4 +- docs/i18n/mr/CHANGELOG.md | 4 +- docs/i18n/ms/CHANGELOG.md | 4 +- docs/i18n/nl/CHANGELOG.md | 4 +- docs/i18n/no/CHANGELOG.md | 4 +- docs/i18n/phi/CHANGELOG.md | 4 +- docs/i18n/pl/CHANGELOG.md | 4 +- docs/i18n/pt-BR/CHANGELOG.md | 4 +- docs/i18n/pt/CHANGELOG.md | 4 +- docs/i18n/ro/CHANGELOG.md | 4 +- docs/i18n/ru/CHANGELOG.md | 4 +- docs/i18n/sk/CHANGELOG.md | 4 +- docs/i18n/sv/CHANGELOG.md | 4 +- docs/i18n/sw/CHANGELOG.md | 4 +- docs/i18n/ta/CHANGELOG.md | 4 +- docs/i18n/te/CHANGELOG.md | 4 +- docs/i18n/th/CHANGELOG.md | 4 +- docs/i18n/tr/CHANGELOG.md | 4 +- docs/i18n/uk-UA/CHANGELOG.md | 4 +- docs/i18n/ur/CHANGELOG.md | 4 +- docs/i18n/vi/CHANGELOG.md | 4 +- docs/i18n/zh-CN/CHANGELOG.md | 4 +- docs/reference/openapi.yaml | 5025 +++++++++++++++-- docs/security/ROUTE_GUARD_TIERS.md | 31 + scripts/check/check-openapi-coverage.mjs | 81 + .../check/check-openapi-security-tiers.mjs | 82 + scripts/i18n/translate-endpoint-tier-keys.mjs | 243 + .../dashboard/endpoint/ApiEndpointsTab.tsx | 88 +- .../[kind]/[id]/MediaProviderPageClient.tsx | 2 +- .../media-providers/[kind]/[id]/page.tsx | 4 +- .../dashboard/media-providers/[kind]/page.tsx | 5 +- .../components/MediaProviderKindNav.tsx | 23 +- .../media-providers/components/mediaKinds.ts | 22 + src/app/api/openapi/spec/route.ts | 6 +- src/i18n/messages/ar.json | 12 +- src/i18n/messages/az.json | 12 +- src/i18n/messages/bg.json | 12 +- src/i18n/messages/bn.json | 12 +- src/i18n/messages/cs.json | 12 +- src/i18n/messages/da.json | 12 +- src/i18n/messages/de.json | 12 +- src/i18n/messages/en.json | 10 + src/i18n/messages/es.json | 12 +- src/i18n/messages/fa.json | 12 +- src/i18n/messages/fi.json | 12 +- src/i18n/messages/fr.json | 12 +- src/i18n/messages/gu.json | 12 +- src/i18n/messages/he.json | 12 +- src/i18n/messages/hi.json | 12 +- src/i18n/messages/hu.json | 12 +- src/i18n/messages/id.json | 12 +- src/i18n/messages/in.json | 12 +- src/i18n/messages/it.json | 12 +- src/i18n/messages/ja.json | 12 +- src/i18n/messages/ko.json | 12 +- src/i18n/messages/mr.json | 12 +- src/i18n/messages/ms.json | 12 +- src/i18n/messages/nl.json | 12 +- src/i18n/messages/no.json | 12 +- src/i18n/messages/phi.json | 12 +- src/i18n/messages/pl.json | 12 +- src/i18n/messages/pt-BR.json | 10 + src/i18n/messages/pt.json | 12 +- src/i18n/messages/ro.json | 12 +- src/i18n/messages/ru.json | 12 +- src/i18n/messages/sk.json | 12 +- src/i18n/messages/sv.json | 12 +- src/i18n/messages/sw.json | 12 +- src/i18n/messages/ta.json | 12 +- src/i18n/messages/te.json | 12 +- src/i18n/messages/th.json | 12 +- src/i18n/messages/tr.json | 12 +- src/i18n/messages/uk-UA.json | 12 +- src/i18n/messages/ur.json | 12 +- src/i18n/messages/vi.json | 12 +- src/i18n/messages/zh-CN.json | 12 +- tests/unit/openapi-coverage.test.ts | 64 + tests/unit/openapi-security-tiers.test.ts | 94 + 99 files changed, 5969 insertions(+), 484 deletions(-) create mode 100644 scripts/check/check-openapi-coverage.mjs create mode 100644 scripts/check/check-openapi-security-tiers.mjs create mode 100644 scripts/i18n/translate-endpoint-tier-keys.mjs create mode 100644 src/app/(dashboard)/dashboard/media-providers/components/mediaKinds.ts create mode 100644 tests/unit/openapi-coverage.test.ts create mode 100644 tests/unit/openapi-security-tiers.test.ts diff --git a/.husky/pre-commit b/.husky/pre-commit index f23acbf84762..9339f8f42006 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -22,3 +22,10 @@ node scripts/i18n/check-translation-drift.mjs --warn || \ # i18n UI coverage advisory (FASE 6) — pre-commit warns; CI enforces strict. node scripts/i18n/check-ui-keys-coverage.mjs --threshold=80 || \ echo "⚠️ UI i18n coverage below 80% for at least one locale." + +# OpenAPI coverage check — fails if coverage < 99% (FASE 08 content audit) +node scripts/check/check-openapi-coverage.mjs + +# OpenAPI security tier consistency check — fails if x-loopback-only / x-always-protected +# annotations diverge from routeGuard.ts compile-time constants (FASE 08 content audit) +node scripts/check/check-openapi-security-tiers.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 77153168a0ea..acfd139ed88f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ --- -## [3.8.4] +## [3.8.4] — 2026-05-24 ### ✨ New Features @@ -16,16 +16,6 @@ ### 🔧 Bug Fixes -- **fix(db):** remove transactions from migrations to avoid nested-transaction errors when the runner already wraps the file in a transaction. -- **fix(docker):** use `node:24-trixie-slim` base image (and add `python3`/`make`/`g++` to the builder stage) so native addons like `better-sqlite3` compile from source within the `engines` range. ([#2713](https://github.com/diegosouzapw/OmniRoute/pull/2713) — thanks @mrmm) -- **fix(ci):** semver-aware release publish guards in the npm and Docker workflows. -- **fix(i18n):** restore real English copy for web-cookie provider hints in `en.json` (Blackbox, Grok, Muse Spark, Perplexity, Qoder, Vertex, SearXNG) — replaces placeholder strings that were leaking the translation key into the UI. ([#2694](https://github.com/diegosouzapw/OmniRoute/pull/2694) — thanks @diegosouzapw) -- **fix(oauth):** comprehensive Codex/OpenAI rotating-refresh race fix — moves credential persistence INSIDE the per-connection mutex via `runWithOnPersist`/`AsyncLocalStorage`, consolidates duplicate mutex maps in `src/sse` and `open-sse`, adds absolute `expiresAt` staleness fallback, and prevents `refresh_token_reused` family revocation. Replaces `token-refresh-race.test.ts` with a broader `token-refresh-race-comprehensive.test.ts` covering Fix A–F. ([#2718](https://github.com/diegosouzapw/OmniRoute/pull/2718) — thanks @diegosouzapw) -- **fix(vision-bridge):** process images when a vision-capable model is routed through a combo whose targets may NOT support vision — resolves combo mapping for the requested model and only short-circuits the bridge when ALL combo targets natively support vision. ([#2706](https://github.com/diegosouzapw/OmniRoute/pull/2706) — thanks @herjarsa) -- **fix(antigravity):** default exhausted quota to 0% remaining (was incorrectly defaulting to 100% when the API omitted `remainingFraction`). Clamps to `[0, 1]` and distinguishes unlimited (no `resetTime`) from full-with-reset. ([#2700](https://github.com/diegosouzapw/OmniRoute/pull/2700) — thanks @ahmet-cetinkaya) -- **fix(electron):** Caps Lock indicator (`` + onboarding) with ARIA live region, Electron-aware forgot-password page showing the data directory, macOS Helper-binary spawn for the Next.js server (prevents a second dock icon and terminal flash), plus `windowsHide: true` and `shell: false` hardening on `spawn()`. ([#2714](https://github.com/diegosouzapw/OmniRoute/pull/2714) — thanks @benzntech) -- **fix(proxy):** atomically create/update a custom proxy and its assignment inside a single SQLite transaction — eliminates the 3-call race that could leave orphan registry rows or stale legacy `proxyConfig` entries when the UI saved a dashboard custom proxy. New `createProxyAndAssign` / `updateProxyAndAssign` helpers. ([#2697](https://github.com/diegosouzapw/OmniRoute/pull/2697) — thanks @terence71-glitch) - --- ## [3.8.4] — 2026-05-25 diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index 7f5eadf519e3..ed8e96b15d22 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 7ccad1a04f62..4d36609c6d97 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 7ccad1a04f62..4d36609c6d97 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 56e97d677b81..9a0bf382b0dc 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index ff3afb623607..3d24dd2d034a 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index becf8320bebd..662ce9573d4e 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index 1060a4b9e280..b37a747566ac 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 7d89ed8e7026..129c2ce99331 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 15161e23d7a0..7fc6963516fa 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index c12a1819dc83..1c394ee86457 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index 225db1bca568..c0f90a047e8a 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index c18b3f317f43..0636e8b85cf6 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index a95d4f5717ce..5e7a38a381ba 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index a1ed48b2b667..015f64978b3c 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index c35f498b5ea8..bc20263da8df 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index 7c61efb9899d..e32a0ea06bf9 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index 4b11d0199b60..edfd9e0e3b3c 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index 23596ad3e5f7..fd05f1830b63 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index ab372bff7570..9b9b374dbc1c 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index 29a4dac26b8a..aa2ee9326cba 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 13668777a3e8..648543f607ac 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index e061ed8e1b96..883f8259fd5e 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index af2930648d5a..0c5bbeea0476 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index 5d6faf2fd59c..d90b67dbe136 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index ede728986fdf..ec0f54f4135a 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 3c42e6de0bb6..12a7e140f615 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 677d8340a683..646d709469c0 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index c1ba3ce5d285..d8a0421aeff3 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index aa31bf930ba5..899c9053f54b 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 2c4fb6c7b955..6005c58bfc56 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 2b80a63eee9e..53e2810c88a2 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 08ee640ce8e9..6f63031338d7 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index 8c7cecd37439..ad86a7ad16ec 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 9f7b148a32ef..354a06276528 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index 08db0d4df924..a9e2f816ed68 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index 49ce0af0eaea..a886e831293d 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index ef48775adedc..258f91bc2d52 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index df747a4299fe..6a37651b53cb 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index dadb5b84027c..cb617c7add03 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 7a4afd208a24..8022f2240fae 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 59c208fab970..98fbba90ed76 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -12,10 +12,12 @@ --- -## [3.8.4] — 2026-05-25 +## [3.8.4] — 2026-05-24 ### ✨ New Features +- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) + ### 🔧 Bug Fixes --- diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 69a4683a8542..0fb3542328af 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -22,6 +22,20 @@ servers: - url: http://localhost:20128 description: Local development +# Vendor extensions — OmniRoute security tier annotations (OpenAPI 3.x compatible) +x-tier-definitions: + loopback-only: + description: "Route is enforced as loopback-only (127.0.0.1, ::1) regardless of auth. + Cannot be authenticated remotely. Spawn-capable routes are classified here. + Reference: docs/security/ROUTE_GUARD_TIERS.md" + always-protected: + description: "Route requires authentication unconditionally, even when REQUIRE_API_KEY=false. + Destructive/irreversible operations are classified here. + Reference: docs/security/ROUTE_GUARD_TIERS.md" + internal: + description: "Internal route not part of the public API surface. + Hidden from /dashboard/api-endpoints by default." + tags: - name: Chat description: OpenAI-compatible chat completions @@ -71,6 +85,30 @@ tags: description: Fallback chain management - name: Telemetry description: Telemetry and token health monitoring + - name: MCP + description: Model Context Protocol server (LOCAL_ONLY — loopback access only) + - name: Webhooks + description: Webhook endpoint management and testing + - name: Tunnels + description: Tunnel (ngrok, Cloudflare, Tailscale) management + - name: Skills + description: Skill framework — install, list, execute + - name: Memory + description: Persistent conversational memory (FTS5 + Qdrant) + - name: Logs + description: Application and request log access + - name: Version Manager + description: Version check and update management + - name: Analytics + description: Internal analytics and routing statistics + - name: A2A + description: Agent-to-Agent JSON-RPC 2.0 protocol + - name: Evals + description: Evaluation suites + - name: Files + description: File uploads and management + - name: Batches + description: Batch request processing paths: # ─── Proxy Endpoints ────────────────────────────────────────── @@ -199,6 +237,47 @@ paths: "401": $ref: "#/components/responses/Unauthorized" + /api/v1/responses/{...path}: + get: + tags: [Responses] + summary: Responses API catch-all (GET) + description: Generic Responses API passthrough for sub-paths. + parameters: + - name: path + in: path + required: true + schema: + type: string + security: + - BearerAuth: [] + responses: + "200": + description: Responses API result + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [Responses] + summary: Responses API catch-all (POST) + parameters: + - name: path + in: path + required: true + schema: + type: string + security: + - BearerAuth: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + "200": + description: Responses API result + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/embeddings: post: tags: [Embeddings] @@ -1180,6 +1259,91 @@ paths: "200": description: Updated thinking budget configuration + /api/settings/database: + get: + tags: [Settings] + summary: Get database settings + description: Returns SQLite database configuration (WAL mode, journal, paths). + x-always-protected: true + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Database settings + "401": + $ref: "#/components/responses/Unauthorized" + patch: + tags: [Settings] + summary: Update database settings + description: Updates SQLite WAL or journal mode settings. Requires restart to take effect. + x-always-protected: true + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Settings updated + "401": + $ref: "#/components/responses/Unauthorized" + put: + tags: [Settings] + summary: Update database settings (alias for PATCH) + description: >- + Alias for PATCH /api/settings/database. Updates SQLite WAL or journal mode + settings. Requires restart to take effect. + x-always-protected: true + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Settings updated + "401": + $ref: "#/components/responses/Unauthorized" + + /api/settings/database/refresh-stats: + post: + tags: [Settings] + summary: Refresh database statistics + description: Runs ANALYZE to refresh SQLite query planner statistics. + x-always-protected: true + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Stats refreshed + "401": + $ref: "#/components/responses/Unauthorized" + + /api/settings/database/vacuum: + post: + tags: [Settings] + summary: Vacuum database + description: Runs VACUUM FULL on the SQLite database to reclaim disk space. Destructive — cannot be interrupted. + x-always-protected: true + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Vacuum completed + "401": + $ref: "#/components/responses/Unauthorized" + /api/rate-limit: get: tags: [Settings] @@ -1453,6 +1617,11 @@ paths: get: tags: [CLI Tools] summary: Get runtime status for a CLI tool + description: >- + Gets runtime status for a CLI tool. + LOCAL_ONLY — spawn-capable route, blocked unconditionally from non-loopback. + CVE class: GHSA-fhh6-4qxv-rpqj. + x-loopback-only: true parameters: - name: toolId in: path @@ -1462,6 +1631,8 @@ paths: responses: "200": description: Runtime status + "403": + description: Forbidden — request not from loopback /api/cli-tools/guide-settings/{toolId}: get: @@ -1717,50 +1888,43 @@ paths: "200": description: OpenClaw CLI settings reset - # ─── OAuth ───────────────────────────────────────────────────── - - /api/oauth/{provider}/{action}: - get: - tags: [OAuth] - summary: OAuth flow handler - description: Handles OAuth authorization and callback for providers - parameters: - - name: provider - in: path - required: true - schema: - type: string - - name: action - in: path - required: true - schema: - type: string - enum: [authorize, callback, refresh, status] - responses: - "200": - description: OAuth flow response - "302": - description: Redirect to provider auth page - - /api/oauth/cursor/auto-import: - get: - tags: [OAuth] - summary: Auto-import Cursor OAuth credentials - description: Automatically detects and imports Cursor credentials from local config. + /api/cli-tools/apply: + post: + tags: [CLI Tools] + summary: Apply CLI tool configuration + x-internal: true + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object responses: "200": - description: Import result + description: Configuration applied + "401": + $ref: "#/components/responses/Unauthorized" - /api/oauth/cursor/import: + /api/cli-tools/config: get: - tags: [OAuth] - summary: Get Cursor import status + tags: [CLI Tools] + summary: Get CLI tools global configuration + x-internal: true + security: + - BearerAuth: [] responses: "200": - description: Current import status + description: CLI tools configuration + "401": + $ref: "#/components/responses/Unauthorized" post: - tags: [OAuth] - summary: Import Cursor OAuth credentials + tags: [CLI Tools] + summary: Update CLI tools global configuration + x-internal: true + security: + - BearerAuth: [] requestBody: required: true content: @@ -1769,27 +1933,41 @@ paths: type: object responses: "200": - description: Credentials imported + description: Configuration updated + "401": + $ref: "#/components/responses/Unauthorized" - /api/oauth/kiro/auto-import: + /api/cli-tools/detect: get: - tags: [OAuth] - summary: Auto-import Kiro OAuth credentials - description: Automatically detects and imports Kiro credentials from local config. + tags: [CLI Tools] + summary: Detect locally installed CLI tools + description: Scans the local system and returns a map of detected CLI tools and their versions. + security: + - BearerAuth: [] responses: "200": - description: Import result + description: Map of detected tools with version info + "401": + $ref: "#/components/responses/Unauthorized" - /api/oauth/kiro/import: + /api/cli-tools/hermes-agent-settings: get: - tags: [OAuth] - summary: Get Kiro import status + tags: [CLI Tools] + summary: Get Hermes agent CLI settings + x-internal: true + security: + - BearerAuth: [] responses: "200": - description: Current import status + description: Hermes agent CLI configuration + "401": + $ref: "#/components/responses/Unauthorized" post: - tags: [OAuth] - summary: Import Kiro OAuth credentials + tags: [CLI Tools] + summary: Apply Hermes agent CLI settings + x-internal: true + security: + - BearerAuth: [] requestBody: required: true content: @@ -1798,22 +1976,28 @@ paths: type: object responses: "200": - description: Credentials imported + description: Hermes agent CLI settings applied + "401": + $ref: "#/components/responses/Unauthorized" - /api/oauth/kiro/social-authorize: + /api/cli-tools/keys: get: - tags: [OAuth] - summary: Initiate Kiro social OAuth authorization - description: Starts the social OAuth flow for Kiro. + tags: [CLI Tools] + summary: Get CLI tool API keys + x-internal: true + security: + - BearerAuth: [] responses: - "302": - description: Redirect to OAuth provider - - /api/oauth/kiro/social-exchange: + "200": + description: CLI tool API key configuration + "401": + $ref: "#/components/responses/Unauthorized" post: - tags: [OAuth] - summary: Exchange Kiro social OAuth token - description: Exchanges the authorization code for access tokens. + tags: [CLI Tools] + summary: Set CLI tool API keys + x-internal: true + security: + - BearerAuth: [] requestBody: required: true content: @@ -1822,15 +2006,28 @@ paths: type: object responses: "200": - description: Token exchange result - - # ─── Cloud ───────────────────────────────────────────────────── + description: CLI tool API keys updated + "401": + $ref: "#/components/responses/Unauthorized" - /api/cloud/auth: + /api/cli-tools/openclaw/auto-order: + get: + tags: [CLI Tools] + summary: Get OpenClaw auto-order configuration + x-internal: true + security: + - BearerAuth: [] + responses: + "200": + description: OpenClaw auto-order configuration + "401": + $ref: "#/components/responses/Unauthorized" post: - tags: [Cloud] - summary: Authenticate with cloud worker - description: Authenticates with the OmniRoute cloud worker for remote access. + tags: [CLI Tools] + summary: Update OpenClaw auto-order configuration + x-internal: true + security: + - BearerAuth: [] requestBody: required: true content: @@ -1839,27 +2036,28 @@ paths: type: object responses: "200": - description: Authentication result + description: OpenClaw auto-order configuration updated + "401": + $ref: "#/components/responses/Unauthorized" - /api/cloud/credentials/update: - put: - tags: [Cloud] - summary: Update cloud worker credentials - requestBody: - required: true - content: - application/json: - schema: - type: object + /api/cli-tools/qwen-settings: + get: + tags: [CLI Tools] + summary: Get Qwen CLI settings + x-internal: true + security: + - BearerAuth: [] responses: "200": - description: Credentials updated - - /api/cloud/model/resolve: + description: Qwen CLI configuration + "401": + $ref: "#/components/responses/Unauthorized" post: - tags: [Cloud] - summary: Resolve model via cloud - description: Resolves a model request through the cloud worker. + tags: [CLI Tools] + summary: Apply Qwen CLI settings + x-internal: true + security: + - BearerAuth: [] requestBody: required: true content: @@ -1868,187 +2066,296 @@ paths: type: object responses: "200": - description: Resolved model info + description: Qwen CLI settings applied + "401": + $ref: "#/components/responses/Unauthorized" - /api/cloud/models/alias: + /api/cli-tools/status: get: - tags: [Cloud] - summary: Get cloud model aliases - responses: - "200": - description: Cloud model alias list - put: - tags: [Cloud] - summary: Update cloud model alias - requestBody: - required: true - content: - application/json: - schema: - type: object + tags: [CLI Tools] + summary: CLI tools service status + description: Returns health and status of the CLI tools subsystem. + security: + - BearerAuth: [] responses: "200": - description: Alias updated - - # ─── Fallback ────────────────────────────────────────────────── + description: CLI tools status + "401": + $ref: "#/components/responses/Unauthorized" - /api/fallback/chains: + /api/upstream-proxy/{providerId}: get: - tags: [Fallback] - summary: List fallback chains - description: Returns all registered fallback chains for model routing. + tags: [Internal] + summary: Upstream proxy passthrough (GET) + description: Proxies GET requests to the upstream provider. Internal use only. + x-internal: true + parameters: + - name: providerId + in: path + required: true + schema: + type: string + security: + - BearerAuth: [] responses: "200": - description: Fallback chain list + description: Proxied response from upstream provider + "401": + $ref: "#/components/responses/Unauthorized" post: - tags: [Fallback] - summary: Create fallback chain - description: Registers a fallback routing chain for a model. + tags: [Internal] + summary: Upstream proxy passthrough (POST) + x-internal: true + parameters: + - name: providerId + in: path + required: true + schema: + type: string + security: + - BearerAuth: [] requestBody: - required: true + required: false content: application/json: schema: type: object - required: [model, chain] - properties: - model: - type: string - chain: - type: array - items: - type: object - properties: - provider: - type: string - priority: - type: integer - enabled: - type: boolean responses: "200": - description: Fallback chain created - delete: - tags: [Fallback] - summary: Delete fallback chain + description: Proxied response from upstream provider + "401": + $ref: "#/components/responses/Unauthorized" + put: + tags: [Internal] + summary: Upstream proxy passthrough (PUT) + x-internal: true + parameters: + - name: providerId + in: path + required: true + schema: + type: string + security: + - BearerAuth: [] requestBody: - required: true + required: false content: application/json: schema: type: object - required: [model] - properties: - model: - type: string responses: "200": - description: Fallback chain deleted + description: Proxied response from upstream provider + "401": + $ref: "#/components/responses/Unauthorized" + delete: + tags: [Internal] + summary: Upstream proxy passthrough (DELETE) + x-internal: true + parameters: + - name: providerId + in: path + required: true + schema: + type: string + security: + - BearerAuth: [] + responses: + "200": + description: Proxied response from upstream provider + "401": + $ref: "#/components/responses/Unauthorized" - # ─── System ──────────────────────────────────────────────────── + # ─── MCP Server (LOCAL_ONLY — spawn-capable) ───────────────────── + # These routes start/control the MCP server subprocess. They are + # enforced as loopback-only by routeGuard.ts regardless of auth state. + # CVE class: GHSA-fhh6-4qxv-rpqj — never expose to non-loopback traffic. - /api/auth/login: + /api/mcp/sse: + get: + tags: [MCP] + summary: MCP Server-Sent Events stream + description: >- + SSE transport for the MCP server. Streams tool call results and events. + LOCAL_ONLY — blocked unconditionally from non-loopback IPs. + x-loopback-only: true + security: + - BearerAuth: [] + responses: + "200": + description: SSE event stream + content: + text/event-stream: + schema: + type: string + "403": + description: Forbidden — request not from loopback + + /api/mcp/stream: + get: + tags: [MCP] + summary: MCP HTTP streaming transport + description: >- + HTTP streaming (chunked) transport for the MCP server. + LOCAL_ONLY — blocked unconditionally from non-loopback IPs. + x-loopback-only: true + security: + - BearerAuth: [] + responses: + "200": + description: Chunked stream + "403": + description: Forbidden — request not from loopback post: - tags: [System] - summary: Authenticate user + tags: [MCP] + summary: MCP HTTP streaming transport (POST) + description: >- + HTTP streaming (chunked) transport for the MCP server — POST variant. + LOCAL_ONLY — blocked unconditionally from non-loopback IPs. + x-loopback-only: true + security: + - BearerAuth: [] requestBody: - required: true + required: false content: application/json: schema: type: object - required: [password] - properties: - password: - type: string responses: "200": - description: JWT token returned - "401": - description: Invalid password + description: MCP response + "403": + description: Forbidden — request not from loopback - /api/auth/logout: - post: - tags: [System] - summary: Log out + /api/mcp/status: + get: + tags: [MCP] + summary: MCP server status + description: Returns running/stopped state of the MCP server process. LOCAL_ONLY. + x-loopback-only: true + security: + - BearerAuth: [] responses: "200": - description: Session cleared + description: MCP server status object + "403": + description: Forbidden — request not from loopback - /api/init: + /api/mcp/tools: get: - tags: [System] - summary: Initialize application + tags: [MCP] + summary: List MCP tools + description: Returns the registered tool manifest. LOCAL_ONLY. + x-loopback-only: true + security: + - BearerAuth: [] responses: "200": - description: Init status + description: Tool list + "403": + description: Forbidden — request not from loopback - /api/restart: - post: - tags: [System] - summary: Restart the application + /api/mcp/audit: + get: + tags: [MCP] + summary: MCP audit log + description: Returns recent MCP tool invocation log entries. LOCAL_ONLY. + x-loopback-only: true + security: + - BearerAuth: [] responses: "200": - description: Restart initiated + description: Audit log entries + "403": + description: Forbidden — request not from loopback - /api/shutdown: - post: - tags: [System] - summary: Shutdown the application + /api/mcp/audit/stats: + get: + tags: [MCP] + summary: MCP audit statistics + description: Returns aggregate MCP tool usage statistics. LOCAL_ONLY. + x-loopback-only: true + security: + - BearerAuth: [] responses: "200": - description: Shutdown initiated + description: Audit stats + "403": + description: Forbidden — request not from loopback - /api/db-backups: + # ─── OAuth ───────────────────────────────────────────────────── + + /api/oauth/{provider}/{action}: get: - tags: [System] - summary: List database backups + tags: [OAuth] + summary: OAuth flow handler + description: Handles OAuth authorization and callback for providers + parameters: + - name: provider + in: path + required: true + schema: + type: string + - name: action + in: path + required: true + schema: + type: string + enum: [authorize, callback, refresh, status] responses: "200": - description: Backup list - post: - tags: [System] - summary: Create database backup + description: OAuth flow response + "302": + description: Redirect to provider auth page + + /api/oauth/cursor/auto-import: + get: + tags: [OAuth] + summary: Auto-import Cursor OAuth credentials + description: Automatically detects and imports Cursor credentials from local config. responses: "200": - description: Backup created + description: Import result - /api/storage/health: + /api/oauth/cursor/import: get: - tags: [System] - summary: Check storage health + tags: [OAuth] + summary: Get Cursor import status responses: "200": - description: Storage health status - - /api/sync/cloud: + description: Current import status post: - tags: [System] - summary: Sync with cloud + tags: [OAuth] + summary: Import Cursor OAuth credentials + requestBody: + required: true + content: + application/json: + schema: + type: object responses: "200": - description: Sync result + description: Credentials imported - /api/sync/initialize: - post: - tags: [System] - summary: Initialize cloud sync + /api/oauth/kiro/auto-import: + get: + tags: [OAuth] + summary: Auto-import Kiro OAuth credentials + description: Automatically detects and imports Kiro credentials from local config. responses: "200": - description: Sync initialized - - # ─── Resilience & Monitoring ──────────────────────────────────── + description: Import result - /api/resilience: + /api/oauth/kiro/import: get: - tags: [System] - summary: Get resilience configuration + tags: [OAuth] + summary: Get Kiro import status responses: "200": - description: Request queue, connection cooldown, provider breaker, and wait settings - patch: - tags: [System] - summary: Update resilience configuration + description: Current import status + post: + tags: [OAuth] + summary: Import Kiro OAuth credentials requestBody: required: true content: @@ -2057,192 +2364,4195 @@ paths: type: object responses: "200": - description: Updated resilience configuration + description: Credentials imported - /api/resilience/reset: - post: - tags: [System] + /api/oauth/kiro/social-authorize: + get: + tags: [OAuth] + summary: Initiate Kiro social OAuth authorization + description: Starts the social OAuth flow for Kiro. + responses: + "302": + description: Redirect to OAuth provider + + /api/oauth/kiro/social-exchange: + post: + tags: [OAuth] + summary: Exchange Kiro social OAuth token + description: Exchanges the authorization code for access tokens. + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Token exchange result + + # ─── Cloud ───────────────────────────────────────────────────── + + /api/cloud/auth: + post: + tags: [Cloud] + summary: Authenticate with cloud worker + description: Authenticates with the OmniRoute cloud worker for remote access. + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Authentication result + + /api/cloud/credentials/update: + put: + tags: [Cloud] + summary: Update cloud worker credentials + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Credentials updated + + /api/cloud/model/resolve: + post: + tags: [Cloud] + summary: Resolve model via cloud + description: Resolves a model request through the cloud worker. + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Resolved model info + + /api/cloud/models/alias: + get: + tags: [Cloud] + summary: Get cloud model aliases + responses: + "200": + description: Cloud model alias list + put: + tags: [Cloud] + summary: Update cloud model alias + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Alias updated + + # ─── Fallback ────────────────────────────────────────────────── + + /api/fallback/chains: + get: + tags: [Fallback] + summary: List fallback chains + description: Returns all registered fallback chains for model routing. + responses: + "200": + description: Fallback chain list + post: + tags: [Fallback] + summary: Create fallback chain + description: Registers a fallback routing chain for a model. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [model, chain] + properties: + model: + type: string + chain: + type: array + items: + type: object + properties: + provider: + type: string + priority: + type: integer + enabled: + type: boolean + responses: + "200": + description: Fallback chain created + delete: + tags: [Fallback] + summary: Delete fallback chain + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [model] + properties: + model: + type: string + responses: + "200": + description: Fallback chain deleted + + # ─── Webhooks ───────────────────────────────────────────────────── + + /api/webhooks: + get: + tags: [Webhooks] + summary: List webhooks + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Webhook list + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [Webhooks] + summary: Create webhook + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: uri + events: + type: array + items: + type: string + secret: + type: string + responses: + "201": + description: Webhook created + "401": + $ref: "#/components/responses/Unauthorized" + + /api/webhooks/{id}: + get: + tags: [Webhooks] + summary: Get webhook + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Webhook details + "404": + description: Not found + "401": + $ref: "#/components/responses/Unauthorized" + patch: + tags: [Webhooks] + summary: Update webhook + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Webhook updated + "401": + $ref: "#/components/responses/Unauthorized" + delete: + tags: [Webhooks] + summary: Delete webhook + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Webhook deleted + "401": + $ref: "#/components/responses/Unauthorized" + + /api/webhooks/{id}/test: + post: + tags: [Webhooks] + summary: Test webhook delivery + description: Sends a test event payload to the webhook URL. + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Test delivery result + "401": + $ref: "#/components/responses/Unauthorized" + + # ─── Tunnels ────────────────────────────────────────────────────── + + /api/tunnels/ngrok: + get: + tags: [Tunnels] + summary: Get ngrok tunnel status + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: ngrok tunnel status + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [Tunnels] + summary: Start/configure ngrok tunnel + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + "200": + description: Tunnel started + "401": + $ref: "#/components/responses/Unauthorized" + delete: + tags: [Tunnels] + summary: Stop ngrok tunnel + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Tunnel stopped + + /api/tunnels/cloudflared: + get: + tags: [Tunnels] + summary: Get Cloudflare Tunnel status + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Cloudflare tunnel status + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [Tunnels] + summary: Start/configure Cloudflare Tunnel + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + "200": + description: Tunnel started + delete: + tags: [Tunnels] + summary: Stop Cloudflare Tunnel + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Tunnel stopped + + /api/tunnels/tailscale: + get: + tags: [Tunnels] + summary: Get Tailscale status + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Tailscale status + "401": + $ref: "#/components/responses/Unauthorized" + + /api/tunnels/tailscale/install: + post: + tags: [Tunnels] + summary: Install Tailscale + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Install initiated + + /api/tunnels/tailscale/login: + post: + tags: [Tunnels] + summary: Tailscale login / auth key + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + authKey: + type: string + responses: + "200": + description: Login result + + /api/tunnels/tailscale/start-daemon: + post: + tags: [Tunnels] + summary: Start Tailscale daemon + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Daemon started + + /api/tunnels/tailscale/enable: + post: + tags: [Tunnels] + summary: Enable Tailscale routing + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Routing enabled + + /api/tunnels/tailscale/disable: + post: + tags: [Tunnels] + summary: Disable Tailscale routing + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Routing disabled + + /api/tunnels/tailscale/check: + get: + tags: [Tunnels] + summary: Check Tailscale connectivity + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Connectivity check result + + # ─── Skills ─────────────────────────────────────────────────────── + + /api/skills: + get: + tags: [Skills] + summary: List installed skills + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Skill list + "401": + $ref: "#/components/responses/Unauthorized" + + /api/skills/{id}: + get: + tags: [Skills] + summary: Get skill details + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Skill details + "404": + description: Skill not found + "401": + $ref: "#/components/responses/Unauthorized" + delete: + tags: [Skills] + summary: Uninstall skill + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Skill uninstalled + + /api/skills/install: + post: + tags: [Skills] + summary: Install skill from ZIP or URL + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + source: + type: string + description: URL or base64 ZIP + responses: + "201": + description: Skill installed + "401": + $ref: "#/components/responses/Unauthorized" + + /api/skills/marketplace: + get: + tags: [Skills] + summary: Browse skill marketplace + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Available skills + + /api/skills/marketplace/install: + post: + tags: [Skills] + summary: Install skill from marketplace + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + skillId: + type: string + responses: + "201": + description: Skill installed + + /api/skills/executions: + get: + tags: [Skills] + summary: List skill execution history + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Execution history + + # ─── System ──────────────────────────────────────────────────── + + /api/auth/login: + post: + tags: [System] + summary: Authenticate user + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [password] + properties: + password: + type: string + responses: + "200": + description: JWT token returned + "401": + description: Invalid password + + /api/auth/logout: + post: + tags: [System] + summary: Log out + responses: + "200": + description: Session cleared + + /api/init: + get: + tags: [System] + summary: Initialize application + responses: + "200": + description: Init status + + /api/restart: + post: + tags: [System] + summary: Restart the application + responses: + "200": + description: Restart initiated + + /api/shutdown: + post: + tags: [System] + summary: Shutdown the OmniRoute service + description: >- + Initiates a graceful shutdown. Always requires authentication regardless of + the `requireLogin` setting — classified as ALWAYS_PROTECTED in routeGuard.ts. + x-always-protected: true + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "202": + description: Shutdown initiated + "401": + $ref: "#/components/responses/Unauthorized" + + /api/db-backups: + get: + tags: [System] + summary: List database backups + responses: + "200": + description: Backup list + post: + tags: [System] + summary: Create database backup + responses: + "200": + description: Backup created + + /api/db-backups/export: + post: + tags: [System] + summary: Export database to file + description: Creates a snapshot export of the SQLite database. + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Export file path or download + "401": + $ref: "#/components/responses/Unauthorized" + + /api/db-backups/exportAll: + post: + tags: [System] + summary: Export all data to ZIP + description: Exports the full database including attachments to a ZIP archive. + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: ZIP archive + "401": + $ref: "#/components/responses/Unauthorized" + + /api/db-backups/import: + post: + tags: [System] + summary: Import database from backup + description: >- + Restores database from a backup file. Destructive — overwrites current data. + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + "200": + description: Import successful + "401": + $ref: "#/components/responses/Unauthorized" + + /api/storage/health: + get: + tags: [System] + summary: Check storage health + responses: + "200": + description: Storage health status + + /api/sync/cloud: + post: + tags: [System] + summary: Sync with cloud + responses: + "200": + description: Sync result + + /api/sync/initialize: + post: + tags: [System] + summary: Initialize cloud sync + responses: + "200": + description: Sync initialized + + # ─── Resilience & Monitoring ──────────────────────────────────── + + /api/resilience: + get: + tags: [System] + summary: Get resilience configuration + responses: + "200": + description: Request queue, connection cooldown, provider breaker, and wait settings + patch: + tags: [System] + summary: Update resilience configuration + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Updated resilience configuration + + /api/resilience/reset: + post: + tags: [System] summary: Reset circuit breakers responses: "200": - description: Circuit breakers reset + description: Circuit breakers reset + + /api/monitoring/health: + get: + tags: [System] + summary: System health check + description: Returns system health including uptime, memory, circuit breakers, rate limits + responses: + "200": + description: Health status + + /api/rate-limits: + get: + tags: [System] + summary: Get per-account rate limit status + responses: + "200": + description: Rate limit status by account + + /api/sessions: + get: + tags: [System] + summary: Get active sessions + responses: + "200": + description: Active session list + + /api/cache: + get: + tags: [System] + summary: Get cache statistics + responses: + "200": + description: Semantic cache and idempotency stats + delete: + tags: [System] + summary: Clear all caches + responses: + "200": + description: Caches cleared + + /api/cache/stats: + get: + tags: [System] + summary: Get detailed cache statistics + description: Returns detailed statistics for all cache layers. + responses: + "200": + description: Detailed cache stats + delete: + tags: [System] + summary: Clear cache statistics + responses: + "200": + description: Cache stats cleared + + # ─── Telemetry & Token Health ─────────────────────────────────── + + /api/telemetry/summary: + get: + tags: [Telemetry] + summary: Get telemetry summary + description: Returns aggregated telemetry data including request metrics and performance stats. + responses: + "200": + description: Telemetry summary data + + /api/token-health: + get: + tags: [Telemetry] + summary: Get token health status + description: Returns health status of OAuth tokens across all providers. + responses: + "200": + description: Token health status + + # ─── Evals & Policies ────────────────────────────────────────── + + /api/evals: + get: + tags: [System] + summary: List eval suites + responses: + "200": + description: Eval suite list + post: + tags: [System] + summary: Run evaluation + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Eval results + + /api/evals/{suiteId}: + get: + tags: [System] + summary: Get eval suite details + parameters: + - name: suiteId + in: path + required: true + schema: + type: string + responses: + "200": + description: Eval suite details + + /api/policies: + get: + tags: [System] + summary: List routing policies + responses: + "200": + description: Policy list + post: + tags: [System] + summary: Create routing policy + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Created policy + delete: + tags: [System] + summary: Delete routing policy + responses: + "200": + description: Policy deleted + + /api/compliance/audit-log: + get: + tags: [System] + summary: Get compliance audit log + parameters: + - name: limit + in: query + schema: + type: integer + default: 100 + responses: + "200": + description: Audit log entries + + # ─── v1beta (Gemini-Compatible) ───────────────────────────────── + + /api/v1beta/models: + get: + tags: [Models] + summary: List models (Gemini format) + description: Returns models in Gemini v1beta format for native SDK compatibility + security: + - BearerAuth: [] + responses: + "200": + description: Model list in Gemini format + + /api/v1beta/models/{...path}: + post: + tags: [Models] + summary: Gemini generateContent + description: Gemini-compatible generateContent endpoint + security: + - BearerAuth: [] + parameters: + - name: path + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Generated content + + # ─── OpenAPI Spec ────────────────────────────────────────────── + + /api/openapi/spec: + get: + tags: [System] + summary: Get OpenAPI specification catalog + description: >- + Returns a structured JSON catalog parsed from this `openapi.yaml`, + including info, servers, tags, schemas, and a flat list of endpoints + (method, path, tags, summary, security, parameters, responses). + Used by the in-app API explorer. + responses: + "200": + description: Parsed OpenAPI catalog + content: + application/json: + schema: + type: object + properties: + info: + type: object + servers: + type: array + items: + type: object + tags: + type: array + items: + type: object + endpoints: + type: array + items: + type: object + properties: + method: + type: string + path: + type: string + tags: + type: array + items: + type: string + summary: + type: string + description: + type: string + security: + type: boolean + parameters: + type: array + items: + type: object + requestBody: + type: boolean + responses: + type: array + items: + type: string + schemas: + type: array + items: + type: string + "404": + description: openapi.yaml file not found on disk + "500": + description: Failed to parse OpenAPI spec + + # ─── Memory ─────────────────────────────────────────────────────── + + /api/memory: + get: + tags: [Memory] + summary: List memory entries + description: Returns conversational memory entries (FTS5 + Qdrant). + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Memory entries + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [Memory] + summary: Create memory entry + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + content: + type: string + tags: + type: array + items: + type: string + responses: + "201": + description: Memory entry created + "401": + $ref: "#/components/responses/Unauthorized" + + /api/memory/{id}: + get: + tags: [Memory] + summary: Get memory entry + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Memory entry + "404": + description: Not found + patch: + tags: [Memory] + summary: Update memory entry + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Memory entry updated + delete: + tags: [Memory] + summary: Delete memory entry + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Memory entry deleted + + /api/memory/health: + get: + tags: [Memory] + summary: Memory system health + description: Returns health of FTS5 index and Qdrant vector store. + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Memory health status + + # ─── Logs ───────────────────────────────────────────────────────── + + /api/logs/console: + get: + tags: [Logs] + summary: Get application console logs + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Console log entries + "401": + $ref: "#/components/responses/Unauthorized" + + /api/logs/active: + get: + tags: [Logs] + summary: Get active request logs + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Active request log entries + + /api/logs/detail: + get: + tags: [Logs] + summary: Get detailed log entries + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: query + schema: + type: string + responses: + "200": + description: Detailed log entry + + /api/logs/export: + get: + tags: [Logs] + summary: Export logs as file + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Log file download + + # ─── v1 Missing Routes ──────────────────────────────────────────── + + /api/v1/completions: + post: + tags: [Chat] + summary: Legacy completions (text) + description: OpenAI legacy text completions endpoint. + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Completion response + + /api/v1/batches: + get: + tags: [Batches] + summary: List batch jobs + security: + - BearerAuth: [] + responses: + "200": + description: Batch job list + "401": + $ref: "#/components/responses/Unauthorized" + post: + tags: [Batches] + summary: Create batch job + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Batch job created + + /api/v1/batches/{id}: + get: + tags: [Batches] + summary: Get batch job + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Batch job details + delete: + tags: [Batches] + summary: Cancel batch job + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Batch cancelled + + /api/v1/batches/{id}/cancel: + post: + tags: [Batches] + summary: Cancel batch job (explicit) + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Batch cancelled + + /api/v1/files: + get: + tags: [Files] + summary: List uploaded files + security: + - BearerAuth: [] + responses: + "200": + description: File list + post: + tags: [Files] + summary: Upload file + security: + - BearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + purpose: + type: string + responses: + "201": + description: File uploaded + + /api/v1/files/{id}: + get: + tags: [Files] + summary: Get file metadata + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: File metadata + delete: + tags: [Files] + summary: Delete file + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: File deleted + + /api/v1/files/{id}/content: + get: + tags: [Files] + summary: Get file content + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: File content + + /api/v1/images/edits: + post: + tags: [Images] + summary: Edit image + security: + - BearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + responses: + "200": + description: Edited image + + /api/v1/music/generations: + post: + tags: [Audio] + summary: Generate music + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Generated music + + /api/v1/videos/generations: + post: + tags: [Images] + summary: Generate video + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Generated video + + /api/v1/quotas/check: + get: + tags: [API Keys] + summary: Check quota usage + security: + - BearerAuth: [] + responses: + "200": + description: Quota status + + /api/v1/registered-keys: + get: + tags: [API Keys] + summary: List registered API keys + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Registered keys + post: + tags: [API Keys] + summary: Register API key + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Key registered + + /api/v1/registered-keys/{id}: + get: + tags: [API Keys] + summary: Get registered key + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Key details + delete: + tags: [API Keys] + summary: Delete registered key + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Key deleted + + /api/v1/registered-keys/{id}/revoke: + post: + tags: [API Keys] + summary: Revoke registered key + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Key revoked + + # ─── Usage Extensions ───────────────────────────────────────────── + + /api/usage/quota: + get: + tags: [Usage] + summary: Get quota usage + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Quota usage stats + + /api/usage/utilization: + get: + tags: [Usage] + summary: Get provider utilization metrics + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Utilization data + + /api/usage/combo-health: + get: + tags: [Usage] + summary: Get combo health metrics + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Combo health metrics + + /api/usage/provider-limits: + get: + tags: [Usage] + summary: Get provider rate limit status + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Provider limit status + + /api/usage/budget/bulk: + patch: + tags: [Usage] + summary: Bulk update usage budgets + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Budgets updated + + # ─── Provider Extensions ────────────────────────────────────────── + + /api/providers/bulk: + post: + tags: [Providers] + summary: Bulk provider operations + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Bulk operation result + + /api/providers/{id}/refresh: + post: + tags: [Providers] + summary: Refresh provider connection + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Provider refreshed + + /api/providers/{id}/sync-models: + post: + tags: [Providers] + summary: Sync provider models + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Models synced + + /api/providers/expiration: + get: + tags: [Providers] + summary: Get provider credential expiration status + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Expiration status + + /api/providers/quota-windows: + get: + tags: [Providers] + summary: Get provider quota windows + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Quota window data + + # ─── Settings Extensions ────────────────────────────────────────── + + /api/settings/feature-flags: + get: + tags: [Settings] + summary: Get feature flags + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Feature flags + patch: + tags: [Settings] + summary: Update feature flags + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Flags updated + + /api/settings/memory: + get: + tags: [Settings] + summary: Get memory settings + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Memory settings + patch: + tags: [Settings] + summary: Update memory settings + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Settings updated + + /api/settings/model-aliases: + get: + tags: [Settings] + summary: Get model aliases + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Model alias map + post: + tags: [Settings] + summary: Create model alias + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Alias created + delete: + tags: [Settings] + summary: Delete model alias + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Alias deleted + + /api/settings/export-json: + get: + tags: [Settings] + summary: Export settings as JSON + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Settings JSON export + + /api/settings/import-json: + post: + tags: [Settings] + summary: Import settings from JSON + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Settings imported + + /api/settings/purge-call-logs: + post: + tags: [Settings] + summary: Purge call logs + description: Deletes all call log records. Destructive. + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Logs purged + + /api/settings/purge-logs: + post: + tags: [Settings] + summary: Purge all logs + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Logs purged + + /api/settings/cache-config: + get: + tags: [Settings] + summary: Get cache configuration + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Cache config + patch: + tags: [Settings] + summary: Update cache configuration + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Config updated + + /api/settings/task-routing: + get: + tags: [Settings] + summary: Get task routing settings + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Task routing config + patch: + tags: [Settings] + summary: Update task routing settings + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Settings updated + + # ─── Internal Routes (x-internal: true — hidden from dashboard by default) ─ + + /api/analytics/auto-routing: + get: + tags: [Analytics] + x-internal: true + summary: Auto-routing decision analytics + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Auto-routing analytics + + /api/analytics/compression: + get: + tags: [Analytics] + x-internal: true + summary: Compression analytics + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Compression analytics + + /api/analytics/diversity: + get: + tags: [Analytics] + x-internal: true + summary: Provider diversity analytics + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Diversity analytics + + /api/assess: + post: + tags: [System] + x-internal: true + summary: Assess request (internal) + security: + - BearerAuth: [] + responses: + "200": + description: Assessment result + + /api/admin/concurrency: + get: + tags: [System] + x-internal: true + summary: Admin concurrency status + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Concurrency info + + /api/copilot/chat: + post: + tags: [Chat] + x-internal: true + summary: Copilot chat relay (internal) + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Chat response + + /api/gamification/leaderboard: + get: + tags: [System] + x-internal: true + summary: Local gamification leaderboard + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Leaderboard + + /api/gamification/federation/leaderboard: + get: + tags: [System] + x-internal: true + summary: Federated gamification leaderboard + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Federation leaderboard + + /api/gamification/federation/score: + post: + tags: [System] + x-internal: true + summary: Submit federation score + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Score submitted + + /api/gamification/anomalies: + get: + tags: [System] + x-internal: true + summary: Gamification anomaly detection + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Anomaly data + + /api/gamification/invite: + get: + tags: [System] + x-internal: true + summary: Get invite code + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Invite code + post: + tags: [System] + x-internal: true + summary: Create invite + security: + - ManagementSessionAuth: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + "201": + description: Invite created + + /api/gamification/invite/redeem: + post: + tags: [System] + x-internal: true + summary: Redeem invite code + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Invite redeemed + + /api/gamification/notifications: + get: + tags: [System] + x-internal: true + summary: Gamification notifications + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Notifications + + /api/gamification/rotate: + post: + tags: [System] + x-internal: true + summary: Rotate gamification key + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Key rotated + + /api/gamification/servers: + get: + tags: [System] + x-internal: true + summary: Gamification server list + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Server list + + /api/gamification/stream: + get: + tags: [System] + x-internal: true + summary: Gamification event stream + security: + - ManagementSessionAuth: [] + responses: + "200": + description: SSE stream + + /api/gamification/transfer: + post: + tags: [System] + x-internal: true + summary: Transfer gamification data + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Transfer result + + /api/health/degradation: + get: + tags: [System] + x-internal: true + summary: Degradation health check + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Degradation status + + /api/network/info: + get: + tags: [System] + x-internal: true + summary: Network info + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Network info + + /api/playground/simulate-route: + post: + tags: [System] + x-internal: true + summary: Simulate routing decision (playground) + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Routing simulation result + + /api/provider-metrics: + get: + tags: [Providers] + x-internal: true + summary: Raw provider performance metrics + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Metrics + + /api/relay/tokens: + get: + tags: [System] + x-internal: true + summary: List relay tokens + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Relay tokens + post: + tags: [System] + x-internal: true + summary: Create relay token + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Token created + + /api/relay/tokens/{id}: + delete: + tags: [System] + x-internal: true + summary: Delete relay token + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Token deleted + + /api/search/providers: + get: + tags: [Providers] + x-internal: true + summary: Search providers (internal) + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Search results + + /api/search/stats: + get: + tags: [System] + x-internal: true + summary: Search subsystem stats + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Stats + + /api/sync/bundle: + get: + tags: [System] + x-internal: true + summary: Get sync bundle + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Sync bundle + + /api/sync/tokens: + get: + tags: [System] + x-internal: true + summary: List sync tokens + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Sync tokens + post: + tags: [System] + x-internal: true + summary: Create sync token + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Token created + + /api/sync/tokens/{id}: + delete: + tags: [System] + x-internal: true + summary: Delete sync token + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Token deleted + + /api/synced-available-models: + get: + tags: [Models] + x-internal: true + summary: Synced available models (internal cache) + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Model list + + /api/system/env/repair: + post: + tags: [System] + x-internal: true + summary: Repair environment configuration + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Repair result + + /api/system/version: + get: + tags: [System] + x-internal: true + summary: Get system version info + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Version info + + /api/v1/accounts/{id}/limits: + get: + tags: [API Keys] + x-internal: true + summary: Get account limits (internal) + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Account limits + + /api/v1/agents/credentials: + get: + tags: [A2A] + x-internal: true + summary: Get agent credentials + security: + - BearerAuth: [] + responses: + "200": + description: Agent credentials + post: + tags: [A2A] + x-internal: true + summary: Create agent credentials + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Credentials created + + /api/v1/agents/health: + get: + tags: [A2A] + x-internal: true + summary: Agent health check + security: + - BearerAuth: [] + responses: + "200": + description: Agent health + + /api/v1/agents/tasks: + get: + tags: [A2A] + x-internal: true + summary: List agent tasks + security: + - BearerAuth: [] + responses: + "200": + description: Agent tasks + post: + tags: [A2A] + x-internal: true + summary: Create agent task + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Task created + + /api/v1/agents/tasks/{id}: + get: + tags: [A2A] + x-internal: true + summary: Get agent task + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Task details + patch: + tags: [A2A] + x-internal: true + summary: Update agent task + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Task updated + + /api/v1/batches/delete-completed: + post: + tags: [Batches] + x-internal: true + summary: Delete completed batch jobs + security: + - BearerAuth: [] + responses: + "200": + description: Completed batches deleted + + /api/v1/chatgpt-web/image/{id}: + get: + tags: [Images] + x-internal: true + summary: Retrieve ChatGPT web image (internal) + security: + - BearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Image data + + /api/v1/combos: + get: + tags: [Combos] + x-internal: true + summary: List combos (v1 proxy) + security: + - BearerAuth: [] + responses: + "200": + description: Combo list + + /api/v1/issues/report: + post: + tags: [System] + x-internal: true + summary: Submit issue report + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Issue reported + + /api/v1/management/proxies: + get: + tags: [Providers] + x-internal: true + summary: Management proxy list + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + responses: + "200": + description: Proxy list + post: + tags: [Providers] + x-internal: true + summary: Create management proxy + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Proxy created + + /api/v1/management/proxies/assignments: + get: + tags: [Providers] + x-internal: true + summary: List proxy assignments + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Assignments + + /api/v1/management/proxies/bulk-assign: + post: + tags: [Providers] + x-internal: true + summary: Bulk assign proxies + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Bulk assignment result + + /api/v1/management/proxies/health: + get: + tags: [Providers] + x-internal: true + summary: Management proxy health + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Health status + + /api/v1/providers/{provider}/limits: + get: + tags: [Providers] + x-internal: true + summary: Get provider-specific rate limits (v1) + security: + - BearerAuth: [] + parameters: + - name: provider + in: path + required: true + schema: + type: string + responses: + "200": + description: Rate limits + + /api/v1/relay/chat/completions: + post: + tags: [Chat] + x-internal: true + summary: Relay chat completions (internal) + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Relay response + + /api/v1/search: + get: + tags: [System] + x-internal: true + summary: Internal search endpoint + security: + - BearerAuth: [] + responses: + "200": + description: Search results + + /api/v1/search/analytics: + get: + tags: [Analytics] + x-internal: true + summary: Search analytics + security: + - BearerAuth: [] + responses: + "200": + description: Search analytics + + /api/v1/web/fetch: + post: + tags: [System] + x-internal: true + summary: Web fetch proxy (internal) + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Fetched content + + /api/v1/ws: + get: + tags: [System] + x-internal: true + summary: WebSocket upgrade endpoint + security: + - BearerAuth: [] + responses: + "101": + description: WebSocket upgrade + + /api/version-manager/status: + get: + tags: [Version Manager] + x-internal: true + summary: Version manager status + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Version manager state + + /api/version-manager/check-update: + get: + tags: [Version Manager] + x-internal: true + summary: Check for updates + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Update availability + + /api/version-manager/install: + post: + tags: [Version Manager] + x-internal: true + summary: Install update + security: + - ManagementSessionAuth: [] + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + "200": + description: Install initiated + + /api/version-manager/start: + post: + tags: [Version Manager] + x-internal: true + summary: Start version manager + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Started + + /api/version-manager/stop: + post: + tags: [Version Manager] + x-internal: true + summary: Stop version manager + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Stopped + + /api/version-manager/restart: + post: + tags: [Version Manager] + x-internal: true + summary: Restart version manager + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Restarted + + /api/models/openrouter-catalog: + get: + tags: [Models] + x-internal: true + summary: OpenRouter model catalog (cached) + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Model catalog + + /api/models/test: + post: + tags: [Models] + x-internal: true + summary: Test model routing + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Test result + + /api/cache/entries: + get: + tags: [System] + x-internal: true + summary: List cache entries + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Cache entries + + /api/cache/reasoning: + get: + tags: [System] + x-internal: true + summary: Reasoning cache entries + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Reasoning cache + + /api/context/analytics: + get: + tags: [Settings] + x-internal: true + summary: Context analytics + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Context analytics + + /api/context/caveman/config: + get: + tags: [Settings] + x-internal: true + summary: Caveman context config + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Config + patch: + tags: [Settings] + x-internal: true + summary: Update caveman context config + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Config updated + + /api/context/combos: + get: + tags: [Settings] + x-internal: true + summary: List context combos + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Context combos + post: + tags: [Settings] + x-internal: true + summary: Create context combo + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Combo created + + /api/context/combos/{id}: + get: + tags: [Settings] + x-internal: true + summary: Get context combo + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Context combo + patch: + tags: [Settings] + x-internal: true + summary: Update context combo + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Combo updated + delete: + tags: [Settings] + x-internal: true + summary: Delete context combo + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Combo deleted + + /api/context/combos/{id}/assignments: + get: + tags: [Settings] + x-internal: true + summary: Get context combo assignments + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Assignments + post: + tags: [Settings] + x-internal: true + summary: Update context combo assignments + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Assignments updated + + /api/middleware/hooks: + get: + tags: [Settings] + x-internal: true + summary: List middleware hooks + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Hook list + post: + tags: [Settings] + x-internal: true + summary: Create middleware hook + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Hook created + + /api/middleware/hooks/{name}: + get: + tags: [Settings] + x-internal: true + summary: Get middleware hook + security: + - ManagementSessionAuth: [] + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: Hook details + patch: + tags: [Settings] + x-internal: true + summary: Update middleware hook + security: + - ManagementSessionAuth: [] + parameters: + - name: name + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Hook updated + delete: + tags: [Settings] + x-internal: true + summary: Delete middleware hook + security: + - ManagementSessionAuth: [] + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + "200": + description: Hook deleted + + /api/settings/auto-disable-accounts: + get: + tags: [Settings] + x-internal: true + summary: Get auto-disable accounts settings + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Auto-disable settings + patch: + tags: [Settings] + x-internal: true + summary: Update auto-disable accounts settings + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Settings updated + + /api/settings/background-degradation: + get: + tags: [Settings] + x-internal: true + summary: Get background degradation settings + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Degradation settings + patch: + tags: [Settings] + x-internal: true + summary: Update background degradation settings + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Settings updated + + /api/settings/authz-inventory: + get: + tags: [Settings] + x-internal: true + summary: Get authorization inventory + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Authz inventory + + /api/settings/cache-metrics: + get: + tags: [Settings] + x-internal: true + summary: Get cache performance metrics + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Cache metrics + + /api/settings/favicon: + get: + tags: [Settings] + x-internal: true + summary: Get custom favicon setting + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Favicon config + post: + tags: [Settings] + x-internal: true + summary: Update custom favicon + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + responses: + "200": + description: Favicon updated + + /api/settings/lkgp-cache: + get: + tags: [Settings] + x-internal: true + summary: Get LKGP cache state + security: + - ManagementSessionAuth: [] + responses: + "200": + description: LKGP cache state + delete: + tags: [Settings] + x-internal: true + summary: Clear LKGP cache + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Cache cleared + + /api/settings/mitm: + get: + tags: [Settings] + x-internal: true + summary: Get MITM proxy settings + security: + - ManagementSessionAuth: [] + responses: + "200": + description: MITM settings + patch: + tags: [Settings] + x-internal: true + summary: Update MITM proxy settings + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Settings updated + + /api/settings/models-dev: + get: + tags: [Settings] + x-internal: true + summary: Get dev model overrides + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Dev model overrides + patch: + tags: [Settings] + x-internal: true + summary: Update dev model overrides + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Overrides updated + + /api/settings/oneproxy: + get: + tags: [Settings] + x-internal: true + summary: Get OneProxy settings + security: + - ManagementSessionAuth: [] + responses: + "200": + description: OneProxy settings + patch: + tags: [Settings] + x-internal: true + summary: Update OneProxy settings + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Settings updated + + /api/settings/oneproxy/rotate: + post: + tags: [Settings] + x-internal: true + summary: Rotate OneProxy credentials + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Credentials rotated + + /api/settings/proxies: + get: + tags: [Settings] + x-internal: true + summary: List proxy configurations + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Proxy configurations + post: + tags: [Settings] + x-internal: true + summary: Create proxy configuration + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Proxy created + + /api/settings/proxies/assignments: + get: + tags: [Settings] + x-internal: true + summary: List proxy assignments + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Proxy assignments + post: + tags: [Settings] + x-internal: true + summary: Update proxy assignments + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Assignments updated + + /api/settings/proxies/bulk-assign: + post: + tags: [Settings] + x-internal: true + summary: Bulk assign proxies + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Bulk assignment result + + /api/settings/proxies/bulk-import: + post: + tags: [Settings] + x-internal: true + summary: Bulk import proxy configurations + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Import result + + /api/settings/proxies/health: + get: + tags: [Settings] + x-internal: true + summary: Proxy configuration health check + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Health status + + /api/settings/proxies/migrate: + post: + tags: [Settings] + x-internal: true + summary: Migrate proxy configurations + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Migration result + + /api/settings/purge-detailed-logs: + post: + tags: [Settings] + x-internal: true + summary: Purge detailed logs + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Logs purged + + /api/settings/purge-quota-snapshots: + post: + tags: [Settings] + x-internal: true + summary: Purge quota snapshots + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Snapshots purged + + /api/settings/qdrant/embedding-models: + get: + tags: [Settings] + x-internal: true + summary: List Qdrant embedding models + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Embedding model list + + /api/settings/compression/rules: + get: + tags: [Settings] + x-internal: true + summary: Get compression rules (settings sub-path) + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Compression rules + + /api/keys/groups: + get: + tags: [API Keys] + x-internal: true + summary: List API key groups + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Key groups + post: + tags: [API Keys] + x-internal: true + summary: Create API key group + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Group created + + /api/keys/groups/{id}: + get: + tags: [API Keys] + x-internal: true + summary: Get API key group + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Group details + patch: + tags: [API Keys] + x-internal: true + summary: Update API key group + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Group updated + delete: + tags: [API Keys] + x-internal: true + summary: Delete API key group + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Group deleted + + /api/keys/groups/{id}/keys: + get: + tags: [API Keys] + x-internal: true + summary: List keys in group + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Keys in group + post: + tags: [API Keys] + x-internal: true + summary: Add key to group + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Key added + + /api/keys/groups/{id}/permissions: + get: + tags: [API Keys] + x-internal: true + summary: Get group permissions + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Permissions + patch: + tags: [API Keys] + x-internal: true + summary: Update group permissions + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Permissions updated + + /api/keys/{id}/regenerate: + post: + tags: [API Keys] + x-internal: true + summary: Regenerate API key secret + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: New key value + + /api/keys/{id}/reveal: + post: + tags: [API Keys] + x-internal: true + summary: Reveal API key value + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Key value (one-time reveal) + + /api/resilience/model-cooldowns: + get: + tags: [System] + x-internal: true + summary: List model cooldown state + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Model cooldown entries + + /api/a2a/status: + get: + tags: [A2A] + x-internal: true + summary: A2A server status + security: + - ManagementSessionAuth: [] + responses: + "200": + description: A2A status + + /api/a2a/tasks: + get: + tags: [A2A] + x-internal: true + summary: List A2A tasks + security: + - ManagementSessionAuth: [] + responses: + "200": + description: A2A task list + post: + tags: [A2A] + x-internal: true + summary: Create A2A task + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Task created + + /api/a2a/tasks/{id}: + get: + tags: [A2A] + x-internal: true + summary: Get A2A task + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Task details + + /api/a2a/tasks/{id}/cancel: + post: + tags: [A2A] + x-internal: true + summary: Cancel A2A task + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Task cancelled + + /api/acp/agents: + get: + tags: [A2A] + x-internal: true + summary: List ACP agents + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Agent list + + /api/db/health: + get: + tags: [System] + x-internal: true + summary: Database health check + security: + - ManagementSessionAuth: [] + responses: + "200": + description: DB health + + /api/evals/suites: + get: + tags: [Evals] + x-internal: true + summary: List eval suites + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Suite list + post: + tags: [Evals] + x-internal: true + summary: Create eval suite + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Suite created + + /api/evals/suites/{suiteId}: + get: + tags: [Evals] + x-internal: true + summary: Get eval suite details + security: + - ManagementSessionAuth: [] + parameters: + - name: suiteId + in: path + required: true + schema: + type: string + responses: + "200": + description: Suite details + patch: + tags: [Evals] + x-internal: true + summary: Update eval suite + security: + - ManagementSessionAuth: [] + parameters: + - name: suiteId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Suite updated + + /api/files: + get: + tags: [Files] + x-internal: true + summary: List files (management view) + security: + - ManagementSessionAuth: [] + responses: + "200": + description: File list + post: + tags: [Files] + x-internal: true + summary: Upload file (management) + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + responses: + "201": + description: File uploaded + + /api/files/{id}/content: + get: + tags: [Files] + x-internal: true + summary: Get file content (management) + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: File content - /api/monitoring/health: + /api/batches: get: - tags: [System] - summary: System health check - description: Returns system health including uptime, memory, circuit breakers, rate limits + tags: [Batches] + x-internal: true + summary: List batch jobs (management) + security: + - ManagementSessionAuth: [] responses: "200": - description: Health status + description: Batch list + post: + tags: [Batches] + x-internal: true + summary: Create batch job (management) + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Batch created - /api/rate-limits: + /api/batches/{id}: get: - tags: [System] - summary: Get per-account rate limit status + tags: [Batches] + x-internal: true + summary: Get batch job (management) + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string responses: "200": - description: Rate limit status by account + description: Batch details + delete: + tags: [Batches] + x-internal: true + summary: Delete batch job (management) + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Batch deleted - /api/sessions: + /api/internal/codex-responses-ws: get: tags: [System] - summary: Get active sessions + x-internal: true + summary: Codex responses WebSocket (internal) + security: + - BearerAuth: [] + responses: + "101": + description: WebSocket upgrade + + /api/openapi/try: + post: + tags: [System] + x-internal: true + summary: Try It — execute an API request via proxy + description: Internal endpoint used by the API Explorer "Try It" feature. + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + method: + type: string + path: + type: string + body: + type: object responses: "200": - description: Active session list + description: Try result - /api/cache: + /api/pricing/sync: + post: + tags: [Pricing] + x-internal: true + summary: Sync pricing data + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Pricing synced + + /api/auth/status: get: tags: [System] - summary: Get cache statistics + x-internal: true + summary: Auth session status + security: + - ManagementSessionAuth: [] responses: "200": - description: Semantic cache and idempotency stats + description: Auth status + + /api/model-combo-mappings: + get: + tags: [Combos] + x-internal: true + summary: List model combo mappings + security: + - ManagementSessionAuth: [] + responses: + "200": + description: Mappings + post: + tags: [Combos] + x-internal: true + summary: Create model combo mapping + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "201": + description: Mapping created + + /api/model-combo-mappings/{id}: + patch: + tags: [Combos] + x-internal: true + summary: Update model combo mapping + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Mapping updated delete: - tags: [System] - summary: Clear all caches + tags: [Combos] + x-internal: true + summary: Delete model combo mapping + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string responses: "200": - description: Caches cleared + description: Mapping deleted - /api/cache/stats: + /api/combos/builder/options: get: - tags: [System] - summary: Get detailed cache statistics - description: Returns detailed statistics for all cache layers. + tags: [Combos] + x-internal: true + summary: Combo builder options + security: + - ManagementSessionAuth: [] responses: "200": - description: Detailed cache stats - delete: - tags: [System] - summary: Clear cache statistics + description: Builder options + + /api/combos/reorder: + patch: + tags: [Combos] + x-internal: true + summary: Reorder combos + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Reordered + + /api/providers/claude-auth/import: + post: + tags: [Providers] + x-internal: true + summary: Import Claude auth session + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Import result + + /api/providers/claude-auth/import-bulk: + post: + tags: [Providers] + x-internal: true + summary: Bulk import Claude auth sessions + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Bulk import result + + /api/providers/claude-auth/zip-extract: + post: + tags: [Providers] + x-internal: true + summary: Extract Claude auth ZIP + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + responses: + "200": + description: Extract result + + /api/providers/codex-auth/import: + post: + tags: [Providers] + x-internal: true + summary: Import Codex auth session + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Import result + + /api/providers/codex-auth/import-bulk: + post: + tags: [Providers] + x-internal: true + summary: Bulk import Codex auth sessions + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Bulk import result + + /api/providers/codex-auth/zip-extract: + post: + tags: [Providers] + x-internal: true + summary: Extract Codex auth ZIP + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + responses: + "200": + description: Extract result + + /api/providers/command-code/auth/start: + post: + tags: [Providers] + x-internal: true + summary: Start Command Code OAuth flow + security: + - ManagementSessionAuth: [] + responses: + "200": + description: OAuth flow started + + /api/providers/command-code/auth/callback: + get: + tags: [Providers] + x-internal: true + summary: Command Code OAuth callback + responses: + "302": + description: OAuth redirect + + /api/providers/command-code/auth/apply: + post: + tags: [Providers] + x-internal: true + summary: Apply Command Code OAuth token + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object responses: "200": - description: Cache stats cleared + description: Token applied - # ─── Telemetry & Token Health ─────────────────────────────────── - - /api/telemetry/summary: + /api/providers/command-code/auth/status: get: - tags: [Telemetry] - summary: Get telemetry summary - description: Returns aggregated telemetry data including request metrics and performance stats. + tags: [Providers] + x-internal: true + summary: Command Code OAuth status + security: + - ManagementSessionAuth: [] responses: "200": - description: Telemetry summary data + description: OAuth status - /api/token-health: - get: - tags: [Telemetry] - summary: Get token health status - description: Returns health status of OAuth tokens across all providers. + /api/providers/gemini-cli-auth/import: + post: + tags: [Providers] + x-internal: true + summary: Import Gemini CLI auth session + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object responses: "200": - description: Token health status - - # ─── Evals & Policies ────────────────────────────────────────── + description: Import result - /api/evals: - get: - tags: [System] - summary: List eval suites + /api/providers/gemini-cli-auth/import-bulk: + post: + tags: [Providers] + x-internal: true + summary: Bulk import Gemini CLI auth sessions + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object responses: "200": - description: Eval suite list + description: Bulk import result + + /api/providers/gemini-cli-auth/zip-extract: post: - tags: [System] - summary: Run evaluation + tags: [Providers] + x-internal: true + summary: Extract Gemini CLI auth ZIP + security: + - ManagementSessionAuth: [] requestBody: required: true content: - application/json: + multipart/form-data: schema: type: object responses: "200": - description: Eval results + description: Extract result - /api/evals/{suiteId}: - get: - tags: [System] - summary: Get eval suite details + /api/providers/{id}/claude-auth/apply-local: + post: + tags: [Providers] + x-internal: true + summary: Apply local Claude auth to provider + security: + - ManagementSessionAuth: [] parameters: - - name: suiteId + - name: id in: path required: true schema: type: string + requestBody: + required: true + content: + application/json: + schema: + type: object responses: "200": - description: Eval suite details + description: Auth applied - /api/policies: - get: - tags: [System] - summary: List routing policies + /api/providers/{id}/claude-auth/export: + post: + tags: [Providers] + x-internal: true + summary: Export Claude auth from provider + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string responses: "200": - description: Policy list + description: Auth exported + + /api/providers/{id}/codex-auth/apply-local: post: - tags: [System] - summary: Create routing policy + tags: [Providers] + x-internal: true + summary: Apply local Codex auth to provider + security: + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string requestBody: required: true content: application/json: schema: type: object - responses: - "201": - description: Created policy - delete: - tags: [System] - summary: Delete routing policy responses: "200": - description: Policy deleted + description: Auth applied - /api/compliance/audit-log: - get: - tags: [System] - summary: Get compliance audit log + /api/providers/{id}/codex-auth/export: + post: + tags: [Providers] + x-internal: true + summary: Export Codex auth from provider + security: + - ManagementSessionAuth: [] parameters: - - name: limit - in: query + - name: id + in: path + required: true schema: - type: integer - default: 100 + type: string responses: "200": - description: Audit log entries - - # ─── v1beta (Gemini-Compatible) ───────────────────────────────── + description: Auth exported - /api/v1beta/models: - get: - tags: [Models] - summary: List models (Gemini format) - description: Returns models in Gemini v1beta format for native SDK compatibility + /api/providers/{id}/gemini-cli-auth/apply-local: + post: + tags: [Providers] + x-internal: true + summary: Apply local Gemini CLI auth to provider security: - - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object responses: "200": - description: Model list in Gemini format + description: Auth applied - /api/v1beta/models/{path}: + /api/providers/{id}/gemini-cli-auth/export: post: - tags: [Models] - summary: Gemini generateContent - description: Gemini-compatible generateContent endpoint + tags: [Providers] + x-internal: true + summary: Export Gemini CLI auth from provider security: - - BearerAuth: [] + - ManagementSessionAuth: [] parameters: - - name: path + - name: id in: path required: true schema: type: string + responses: + "200": + description: Auth exported + + /api/providers/zed/import: + post: + tags: [Providers] + x-internal: true + summary: Import Zed auth session + security: + - ManagementSessionAuth: [] requestBody: required: true content: @@ -2251,74 +6561,63 @@ paths: type: object responses: "200": - description: Generated content + description: Import result - # ─── OpenAPI Spec ────────────────────────────────────────────── + /api/providers/zed/manual-import: + post: + tags: [Providers] + x-internal: true + summary: Manual Zed auth import + security: + - ManagementSessionAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Import result - /api/openapi/spec: + /api/skills/skillssh: get: - tags: [System] - summary: Get OpenAPI specification catalog - description: >- - Returns a structured JSON catalog parsed from this `openapi.yaml`, - including info, servers, tags, schemas, and a flat list of endpoints - (method, path, tags, summary, security, parameters, responses). - Used by the in-app API explorer. + tags: [Skills] + x-internal: true + summary: SkillSSH status + security: + - ManagementSessionAuth: [] responses: "200": - description: Parsed OpenAPI catalog - content: - application/json: - schema: - type: object - properties: - info: - type: object - servers: - type: array - items: - type: object - tags: - type: array - items: - type: object - endpoints: - type: array - items: - type: object - properties: - method: - type: string - path: - type: string - tags: - type: array - items: - type: string - summary: - type: string - description: - type: string - security: - type: boolean - parameters: - type: array - items: - type: object - requestBody: - type: boolean - responses: - type: array - items: - type: string - schemas: - type: array - items: - type: string - "404": - description: openapi.yaml file not found on disk - "500": - description: Failed to parse OpenAPI spec + description: SkillSSH status + + /api/skills/skillssh/install: + post: + tags: [Skills] + x-internal: true + summary: Install SkillSSH + security: + - ManagementSessionAuth: [] + responses: + "200": + description: SkillSSH installed + + /api/translator/transform-stream: + post: + tags: [Translator] + x-internal: true + summary: Transform stream (internal) + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Transform result components: securitySchemes: diff --git a/docs/security/ROUTE_GUARD_TIERS.md b/docs/security/ROUTE_GUARD_TIERS.md index 3c0eb04814ad..83f12310c129 100644 --- a/docs/security/ROUTE_GUARD_TIERS.md +++ b/docs/security/ROUTE_GUARD_TIERS.md @@ -132,6 +132,37 @@ expired DB doesn't silently downgrade to "deny". | `tests/unit/authz/routeGuard.test.ts` | Unit tests for tier helpers | | `tests/unit/authz/management-policy.test.ts` | Unit tests for evaluate() | +## Documenting Security Tiers in OpenAPI + +When adding a new route to `docs/reference/openapi.yaml`, apply the corresponding +vendor extension if the route is classified by `routeGuard.ts`: + +| routeGuard.ts classification | YAML annotation | Enforcement | +| ----------------------------- | -------------------------- | ----------------------------------------------- | +| `LOCAL_ONLY_API_PREFIXES` | `x-loopback-only: true` | Blocked from non-loopback unconditionally | +| `ALWAYS_PROTECTED_API_PATHS` | `x-always-protected: true` | Auth required even with `requireLogin=false` | +| Internal admin/debug route | `x-internal: true` | Hidden from /dashboard/api-endpoints by default | +| None (public / standard auth) | (no annotation needed) | Standard `requireLogin`-controlled access | + +### Validation + +Two scripts enforce consistency between YAML annotations and `routeGuard.ts`: + +- `scripts/check/check-openapi-coverage.mjs` — fails if coverage < 99% +- `scripts/check/check-openapi-security-tiers.mjs` — fails if `x-loopback-only` or + `x-always-protected` annotations diverge from the compile-time constants + +Both scripts run in the pre-commit hook and in CI. + +### False Positive Rule + +If `x-always-protected` or `x-loopback-only` is annotated on a route that is NOT in +the `routeGuard.ts` constant, the coverage script fails. The fix is always to align the +YAML to what `routeGuard.ts` actually enforces — not to add routes to `routeGuard.ts` +without also implementing the enforcement logic. + +--- + ## See also - `docs/security/CLI_TOKEN.md` — CLI machine-ID token diff --git a/scripts/check/check-openapi-coverage.mjs b/scripts/check/check-openapi-coverage.mjs new file mode 100644 index 000000000000..cc90f93d67e4 --- /dev/null +++ b/scripts/check/check-openapi-coverage.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * Validates that openapi.yaml documents ≥ 99% of implemented routes. + * Routes marked x-internal: true in openapi.yaml count as "covered" because + * they are acknowledged as existing — just not part of the public API surface. + * + * Fails if coverage < 99%. + */ + +import fs from "node:fs"; +import path from "node:path"; +import yaml from "js-yaml"; + +const ROOT = process.cwd(); +const API_ROOT = path.join(ROOT, "src", "app", "api"); +const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); +const THRESHOLD = 99; + +function collectRoutePaths(dir) { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const paths = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + paths.push(...collectRoutePaths(fullPath)); + continue; + } + if (entry.isFile() && entry.name === "route.ts") { + const apiPath = path + .dirname(fullPath) + .replace(API_ROOT, "") + .replace(/\[([^\]]+)\]/g, "{$1}"); + paths.push(`/api${apiPath}`); + } + } + return paths; +} + +function normalizePath(p) { + return p.replace(/\/\[\.\.\.([^\]]+)\]/g, "/{$1}").replace(/\[([^\]]+)\]/g, "{$1}"); +} + +if (!fs.existsSync(API_ROOT)) { + console.error(`[openapi-coverage] FAIL — API root not found: ${API_ROOT}`); + process.exit(1); +} + +if (!fs.existsSync(OPENAPI_PATH)) { + console.error(`[openapi-coverage] FAIL — openapi.yaml not found: ${OPENAPI_PATH}`); + process.exit(1); +} + +const implementedPaths = collectRoutePaths(API_ROOT).map(normalizePath).sort(); +const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); +const documentedPaths = new Set(Object.keys(raw.paths || {})); + +let covered = 0; +const missing = []; + +for (const p of implementedPaths) { + if (documentedPaths.has(p)) { + covered++; + } else { + missing.push(p); + } +} + +const total = implementedPaths.length; +const coverage = (covered / total) * 100; + +if (coverage >= THRESHOLD) { + console.log( + `[openapi-coverage] PASS — ${coverage.toFixed(1)}% (${covered}/${total} routes documented)` + ); + process.exit(0); +} else { + console.error(`[openapi-coverage] FAIL — coverage ${coverage.toFixed(1)}% < ${THRESHOLD}%`); + console.error(`Missing routes (${missing.length}):`); + missing.forEach((p) => console.error(` - ${p}`)); + process.exit(1); +} diff --git a/scripts/check/check-openapi-security-tiers.mjs b/scripts/check/check-openapi-security-tiers.mjs new file mode 100644 index 000000000000..1fcb0a669917 --- /dev/null +++ b/scripts/check/check-openapi-security-tiers.mjs @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Cross-references openapi.yaml x-loopback-only / x-always-protected annotations + * against the compile-time constants in src/server/authz/routeGuard.ts. + * + * Fails if any YAML annotation disagrees with the routeGuard.ts constants. + */ + +import fs from "node:fs"; +import path from "node:path"; +import yaml from "js-yaml"; + +const ROOT = process.cwd(); +const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); +const ROUTE_GUARD_PATH = path.join(ROOT, "src", "server", "authz", "routeGuard.ts"); + +function parseStringArray(match) { + if (!match) return []; + return match[1] + .split(",") + .map((s) => s.trim().replace(/^["']|["']$/g, "")) + .filter(Boolean); +} + +const guardSrc = fs.readFileSync(ROUTE_GUARD_PATH, "utf-8"); +const LOCAL_ONLY_PREFIXES = parseStringArray( + guardSrc.match(/export const LOCAL_ONLY_API_PREFIXES.*?=\s*\[([^\]]+)\]/s) +); +const ALWAYS_PROTECTED_PATHS = parseStringArray( + guardSrc.match(/export const ALWAYS_PROTECTED_API_PATHS.*?=\s*\[([^\]]+)\]/s) +); + +if (LOCAL_ONLY_PREFIXES.length === 0 || ALWAYS_PROTECTED_PATHS.length === 0) { + console.error("[openapi-security-tiers] FAIL — could not parse routeGuard.ts constants"); + process.exit(1); +} + +const raw = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); +const paths = raw.paths || {}; + +const errors = []; + +for (const [pathStr, methods] of Object.entries(paths)) { + if (!methods || typeof methods !== "object") continue; + for (const [method, spec] of Object.entries(methods)) { + if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue; + + if (spec["x-loopback-only"] === true) { + const matchesPrefix = LOCAL_ONLY_PREFIXES.some((prefix) => { + const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; + return pathStr === norm || pathStr.startsWith(norm + "/"); + }); + if (!matchesPrefix) { + errors.push( + `${method.toUpperCase()} ${pathStr}: has x-loopback-only but is NOT covered by ` + + `LOCAL_ONLY_API_PREFIXES [${LOCAL_ONLY_PREFIXES.join(", ")}]` + ); + } + } + + if (spec["x-always-protected"] === true) { + const matchesPath = ALWAYS_PROTECTED_PATHS.some( + (p) => pathStr === p || pathStr.startsWith(`${p}/`) + ); + if (!matchesPath) { + errors.push( + `${method.toUpperCase()} ${pathStr}: has x-always-protected but is NOT in ` + + `ALWAYS_PROTECTED_API_PATHS [${ALWAYS_PROTECTED_PATHS.join(", ")}]` + ); + } + } + } +} + +if (errors.length === 0) { + console.log("[openapi-security-tiers] PASS — all security tier annotations match routeGuard.ts"); + process.exit(0); +} else { + console.error(`[openapi-security-tiers] FAIL — ${errors.length} annotation mismatches:`); + errors.forEach((e) => console.error(` - ${e}`)); + process.exit(1); +} diff --git a/scripts/i18n/translate-endpoint-tier-keys.mjs b/scripts/i18n/translate-endpoint-tier-keys.mjs new file mode 100644 index 000000000000..3f32bb64dc13 --- /dev/null +++ b/scripts/i18n/translate-endpoint-tier-keys.mjs @@ -0,0 +1,243 @@ +#!/usr/bin/env node +/** + * One-shot script: translates the 10 new `endpoint.*` tier/badge keys + * to every non-English locale in src/i18n/messages/. + * + * Only writes keys that are genuinely absent — never overwrites existing + * translations. Skips pt-BR and en (already have the keys). + * + * Usage: + * node scripts/i18n/translate-endpoint-tier-keys.mjs + * node scripts/i18n/translate-endpoint-tier-keys.mjs --dry-run + * node scripts/i18n/translate-endpoint-tier-keys.mjs --locale=de,fr + */ + +import { promises as fs, existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, "..", ".."); +const MESSAGES_DIR = path.join(ROOT, "src", "i18n", "messages"); +const I18N_CONFIG = path.join(ROOT, "config", "i18n.json"); + +// ---- .env loader ----------------------------------------------------------- +(function loadDotEnv() { + const envPath = path.join(ROOT, ".env"); + if (!existsSync(envPath)) return; + try { + const raw = readFileSync(envPath, "utf8"); + for (const rawLine of raw.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + if (!key || process.env[key] !== undefined) continue; + let value = line.slice(eq + 1); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[key] = value; + } + } catch { + /* ignore */ + } +})(); + +// ---- CLI opts -------------------------------------------------------------- +const args = process.argv.slice(2); +const isDryRun = args.includes("--dry-run"); +const localeFilter = args + .find((a) => a.startsWith("--locale=")) + ?.slice("--locale=".length) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + +// ---- Helpers --------------------------------------------------------------- +function log(...parts) { + console.log("[endpoint-tier-i18n]", ...parts); +} + +function requireEnv(name) { + const val = process.env[name]; + if (!val) throw new Error(`Missing required env var: ${name}`); + return val; +} + +function backendConfig() { + const apiUrl = requireEnv("OMNIROUTE_TRANSLATION_API_URL").replace(/\/$/, ""); + const apiKey = requireEnv("OMNIROUTE_TRANSLATION_API_KEY"); + const model = requireEnv("OMNIROUTE_TRANSLATION_MODEL"); + const timeoutMs = Number(process.env.OMNIROUTE_TRANSLATION_TIMEOUT_MS || 60000); + return { apiUrl, apiKey, model, timeoutMs }; +} + +const TRANSLATION_SYSTEM = (englishName, native) => + [ + `You are a professional translator for technical software UI strings.`, + `Translate the user's English UI string into ${englishName} (native: ${native}).`, + `Return ONLY the translated string — no quotes, no commentary, no surrounding markdown.`, + `Preserve placeholders such as {name}, {{count}}, %s, %d, and any HTML tags exactly.`, + `Do NOT translate command names (npm/git/curl/etc), code identifiers, URLs, or environment variable names.`, + `Keep the same casing style (Title Case stays Title Case, sentence case stays sentence case).`, + `Keep punctuation and trailing whitespace identical to the source.`, + ].join(" "); + +async function callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry = 0) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetch(`${apiUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ model, messages, temperature: 0.15, stream: false }), + signal: ctrl.signal, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + const transient = res.status === 408 || res.status === 429 || res.status >= 500; + if (transient && retry < 2) { + const wait = 1500 * (retry + 1); + log(`upstream ${res.status} — retrying after ${wait}ms`); + await new Promise((r) => setTimeout(r, wait)); + return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1); + } + throw new Error(`upstream ${res.status}: ${text.slice(0, 200)}`); + } + const json = await res.json(); + const content = json?.choices?.[0]?.message?.content; + if (typeof content !== "string" || !content) throw new Error("empty content from upstream"); + return content.trim(); + } catch (err) { + if (err?.name === "AbortError") { + if (retry < 2) { + log(`timeout — retrying`); + return callChat(messages, { apiUrl, apiKey, model, timeoutMs }, retry + 1); + } + throw new Error(`timeout after ${timeoutMs}ms`); + } + throw err; + } finally { + clearTimeout(timer); + } +} + +async function translateString(englishValue, localeEntry, backend) { + const messages = [ + { role: "system", content: TRANSLATION_SYSTEM(localeEntry.english, localeEntry.native) }, + { role: "user", content: englishValue }, + ]; + return callChat(messages, backend); +} + +// ---- Keys to translate ----------------------------------------------------- +// These 10 keys were added to en.json and pt-BR.json in the api-endpoints audit +// but not propagated to other locales. +const NEW_ENDPOINT_KEYS = { + tierAll: "All tiers", + tierAuth: "Auth", + tierLoopback: "Local-only", + tierAlwaysProtected: "Always-protected", + tierPublic: "Public", + showInternal: "Show internal", + hideInternal: "Hide internal", + badgeLoopbackTooltip: "Local-only: blocked from non-loopback IPs", + badgeAlwaysProtectedTooltip: "Always protected: requires auth even when requireLogin=false", + badgeInternalTooltip: "Internal route — not part of the public API", +}; + +// Technical terms that stay in English regardless of locale +const KEEP_AS_ENGLISH = new Set(["tierAuth"]); + +// ---- Main ------------------------------------------------------------------ +async function main() { + const config = JSON.parse(readFileSync(I18N_CONFIG, "utf8")); + if (!config.locales || !Array.isArray(config.locales)) { + throw new Error("config/i18n.json: expected { locales: [] }"); + } + + // Exclude English source + pt-BR (already has keys) + const SKIP = new Set(["en", "pt-BR"]); + let locales = config.locales.filter((l) => !SKIP.has(l.code)); + if (localeFilter && localeFilter.length > 0) { + locales = locales.filter((l) => localeFilter.includes(l.code)); + } + + const backend = isDryRun ? null : backendConfig(); + + log( + isDryRun ? "[DRY RUN]" : "", + `Processing ${locales.length} locales — ${Object.keys(NEW_ENDPOINT_KEYS).length} keys each` + ); + + let totalAdded = 0; + let totalSkipped = 0; + + for (const locale of locales) { + const filePath = path.join(MESSAGES_DIR, `${locale.code}.json`); + if (!existsSync(filePath)) { + log(`${locale.code}: file not found — skipping`); + continue; + } + + const data = JSON.parse(readFileSync(filePath, "utf8")); + const ep = (data.endpoint ??= {}); + + const toTranslate = Object.entries(NEW_ENDPOINT_KEYS).filter(([k]) => !(k in ep)); + + if (toTranslate.length === 0) { + log(`${locale.code}: all keys already present — skipping`); + continue; + } + + log(`${locale.code}: adding ${toTranslate.length} keys…`); + + let added = 0; + for (const [key, englishValue] of toTranslate) { + if (isDryRun) { + log(` [DRY] ${locale.code}.endpoint.${key} = "${englishValue}" → `); + added++; + continue; + } + + try { + let translated; + if (KEEP_AS_ENGLISH.has(key)) { + translated = englishValue; + } else { + translated = await translateString(englishValue, locale, backend); + } + ep[key] = translated; + log(` ${locale.code}.endpoint.${key} = "${translated}"`); + added++; + } catch (err) { + log(` ERROR translating ${locale.code}.endpoint.${key}: ${err.message}`); + ep[key] = `__MISSING__:${englishValue}`; + added++; + } + } + + if (!isDryRun) { + await fs.writeFile(filePath, JSON.stringify(data, null, 2) + "\n", "utf8"); + } + + totalAdded += added; + totalSkipped += Object.keys(NEW_ENDPOINT_KEYS).length - added; + } + + log(`Done. Added ${totalAdded} keys, ${totalSkipped} already present.`); +} + +main().catch((err) => { + console.error("[endpoint-tier-i18n] FATAL:", err.message); + process.exit(1); +}); diff --git a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx index 590788a12f69..b02166acaf9d 100644 --- a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx @@ -16,6 +16,9 @@ interface Endpoint { parameters: any[]; requestBody: boolean; responses: string[]; + loopbackOnly?: boolean; + alwaysProtected?: boolean; + internal?: boolean; } interface CatalogData { @@ -47,12 +50,48 @@ const METHOD_COLORS: Record = { export default function ApiEndpointsTab() { const t = useTranslations("endpoint"); const baseUrl = useDisplayBaseUrl(); + + function EndpointBadges({ ep }: { ep: Endpoint }) { + return ( +
+ {ep.loopbackOnly && ( + + LOCAL + + )} + {ep.alwaysProtected && ( + + PROTECTED + + )} + {ep.internal && ( + + INTERNAL + + )} +
+ ); + } + const [catalog, setCatalog] = useState(null); const [catalogError, setCatalogError] = useState(null); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [expandedEndpoint, setExpandedEndpoint] = useState(null); const [selectedTag, setSelectedTag] = useState(null); + const [showInternal, setShowInternal] = useState(false); + const [securityTier, setSecurityTier] = useState< + "all" | "public" | "auth" | "loopback" | "always-protected" + >("all"); // Try It state const [tryingEndpoint, setTryingEndpoint] = useState(null); @@ -97,15 +136,22 @@ export default function ApiEndpointsTab() { const filteredEndpoints = useMemo(() => { if (!catalog) return []; return catalog.endpoints.filter((ep) => { + if (!showInternal && ep.internal) return false; const matchesSearch = !search || ep.path.toLowerCase().includes(search.toLowerCase()) || ep.summary.toLowerCase().includes(search.toLowerCase()) || ep.tags.some((t) => t.toLowerCase().includes(search.toLowerCase())); const matchesTag = !selectedTag || ep.tags.includes(selectedTag); - return matchesSearch && matchesTag; + const matchesTier = + securityTier === "all" || + (securityTier === "loopback" && ep.loopbackOnly) || + (securityTier === "always-protected" && ep.alwaysProtected) || + (securityTier === "auth" && ep.security && !ep.loopbackOnly && !ep.alwaysProtected) || + (securityTier === "public" && !ep.security && !ep.loopbackOnly && !ep.alwaysProtected); + return matchesSearch && matchesTag && matchesTier; }); - }, [catalog, search, selectedTag]); + }, [catalog, search, selectedTag, showInternal, securityTier]); // Group by tag const groupedEndpoints = useMemo(() => { @@ -295,6 +341,43 @@ export default function ApiEndpointsTab() { )} + {/* Security tier filter */} +
+ {(["all", "auth", "loopback", "always-protected", "public"] as const).map((tier) => ( + + ))} + +
{/* Endpoint groups */} @@ -335,6 +418,7 @@ export default function ApiEndpointsTab() { {ep.summary} + {ep.security && ( { + const implementedPaths = collectRoutePaths(API_ROOT).map(normalizePath).sort(); + const raw: any = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); + const documentedPaths = new Set(Object.keys(raw.paths || {})); + + let covered = 0; + const missing: string[] = []; + + for (const p of implementedPaths) { + if (documentedPaths.has(p)) { + covered++; + } else { + missing.push(p); + } + } + + const total = implementedPaths.length; + const coverage = (covered / total) * 100; + + if (coverage < 99) { + console.error(`Coverage: ${coverage.toFixed(1)}% (${covered}/${total})`); + console.error("Missing paths:"); + missing.forEach((p) => console.error(` - ${p}`)); + } + + assert.ok( + coverage >= 99, + `OpenAPI coverage ${coverage.toFixed(1)}% < 99%. Missing: ${missing.slice(0, 10).join(", ")}${missing.length > 10 ? ` ... +${missing.length - 10} more` : ""}` + ); +}); diff --git a/tests/unit/openapi-security-tiers.test.ts b/tests/unit/openapi-security-tiers.test.ts new file mode 100644 index 000000000000..b2742d38f98f --- /dev/null +++ b/tests/unit/openapi-security-tiers.test.ts @@ -0,0 +1,94 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import yaml from "js-yaml"; + +const ROOT = process.cwd(); +const OPENAPI_PATH = path.join(ROOT, "docs", "reference", "openapi.yaml"); + +const { LOCAL_ONLY_API_PREFIXES, ALWAYS_PROTECTED_API_PATHS } = + await import("../../src/server/authz/routeGuard.ts"); + +const raw: any = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); +const paths: Record = raw.paths || {}; + +test("every x-loopback-only path matches a LOCAL_ONLY prefix in routeGuard.ts", () => { + for (const [pathStr, methods] of Object.entries(paths)) { + if (!methods || typeof methods !== "object") continue; + for (const [method, spec] of Object.entries(methods as Record)) { + if (!["get", "post", "put", "patch", "delete"].includes(method)) continue; + if (spec?.["x-loopback-only"] !== true) continue; + const matchesPrefix = (LOCAL_ONLY_API_PREFIXES as ReadonlyArray).some( + (prefix: string) => { + const norm = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix; + return pathStr === norm || pathStr.startsWith(norm + "/"); + } + ); + assert.ok( + matchesPrefix, + `YAML path "${pathStr}" ${method.toUpperCase()} has x-loopback-only but is NOT in LOCAL_ONLY_API_PREFIXES. ` + + `Add it to routeGuard.ts LOCAL_ONLY_API_PREFIXES or remove x-loopback-only.` + ); + } + } +}); + +test("every x-always-protected path matches ALWAYS_PROTECTED_API_PATHS in routeGuard.ts", () => { + for (const [pathStr, methods] of Object.entries(paths)) { + if (!methods || typeof methods !== "object") continue; + for (const [method, spec] of Object.entries(methods as Record)) { + if (!["get", "post", "put", "patch", "delete"].includes(method)) continue; + if (spec?.["x-always-protected"] !== true) continue; + const matchesPath = (ALWAYS_PROTECTED_API_PATHS as ReadonlyArray).some( + (p: string) => pathStr === p || pathStr.startsWith(`${p}/`) + ); + assert.ok( + matchesPath, + `YAML path "${pathStr}" ${method.toUpperCase()} has x-always-protected but is NOT in ALWAYS_PROTECTED_API_PATHS. ` + + `Entries: ${(ALWAYS_PROTECTED_API_PATHS as ReadonlyArray).join(", ")}` + ); + } + } +}); + +test("spec route error response uses sanitizeErrorMessage (no raw error.message)", () => { + const routeSrc = fs.readFileSync(path.join(ROOT, "src/app/api/openapi/spec/route.ts"), "utf-8"); + assert.ok( + routeSrc.includes("sanitizeErrorMessage"), + "spec route must use sanitizeErrorMessage() to prevent stack trace leakage in error responses" + ); + assert.ok( + !routeSrc.match(/\berror\.message\b/), + "spec route must not expose raw error.message in HTTP responses" + ); +}); + +test("spec route catalog includes vendor extension fields for annotated endpoints", () => { + const raw2: any = yaml.load(fs.readFileSync(OPENAPI_PATH, "utf-8")); + const endpoints: any[] = []; + for (const [pathStr, methods] of Object.entries(raw2.paths as Record)) { + if (!methods || typeof methods !== "object") continue; + for (const [method, spec] of Object.entries(methods as Record)) { + if (!["get", "post", "put", "patch", "delete"].includes(method) || !spec) continue; + endpoints.push({ + method: method.toUpperCase(), + path: pathStr, + loopbackOnly: spec["x-loopback-only"] === true, + alwaysProtected: spec["x-always-protected"] === true, + internal: spec["x-internal"] === true, + }); + } + } + const mcpSse = endpoints.find((e) => e.path === "/api/mcp/sse" && e.method === "GET"); + assert.ok(mcpSse, "Should have GET /api/mcp/sse in catalog"); + assert.equal(mcpSse.loopbackOnly, true, "GET /api/mcp/sse must have loopbackOnly: true"); + + const shutdown = endpoints.find((e) => e.path === "/api/shutdown" && e.method === "POST"); + assert.ok(shutdown, "Should have POST /api/shutdown in catalog"); + assert.equal( + shutdown.alwaysProtected, + true, + "POST /api/shutdown must have alwaysProtected: true" + ); +}); From 532208bf6691243eac111fa228b69684560eccaa Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 26 May 2026 00:29:18 -0300 Subject: [PATCH 32/77] =?UTF-8?q?feat(services):=20Embedded=20Services=20?= =?UTF-8?q?=E2=80=94=209Router=20+=20CLIProxyAPI=20unified=20management=20?= =?UTF-8?q?(v3.8.4)=20(#2719)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.4 --- .env.example | 15 + CHANGELOG.md | 18 +- CLAUDE.md | 13 + docs/architecture/ARCHITECTURE.md | 23 + docs/architecture/CODEBASE_DOCUMENTATION.md | 116 +- docs/frameworks/EMBEDDED-SERVICES.md | 790 +++ docs/i18n/ar/CHANGELOG.md | 8 +- docs/i18n/az/CHANGELOG.md | 8 +- docs/i18n/bg/CHANGELOG.md | 8 +- docs/i18n/bn/CHANGELOG.md | 8 +- docs/i18n/cs/CHANGELOG.md | 8 +- docs/i18n/da/CHANGELOG.md | 8 +- docs/i18n/de/CHANGELOG.md | 8 +- docs/i18n/es/CHANGELOG.md | 8 +- docs/i18n/fa/CHANGELOG.md | 8 +- docs/i18n/fi/CHANGELOG.md | 8 +- docs/i18n/fr/CHANGELOG.md | 8 +- docs/i18n/gu/CHANGELOG.md | 8 +- docs/i18n/he/CHANGELOG.md | 8 +- docs/i18n/hi/CHANGELOG.md | 8 +- docs/i18n/hu/CHANGELOG.md | 8 +- docs/i18n/id/CHANGELOG.md | 8 +- docs/i18n/in/CHANGELOG.md | 8 +- docs/i18n/it/CHANGELOG.md | 8 +- docs/i18n/ja/CHANGELOG.md | 8 +- docs/i18n/ko/CHANGELOG.md | 8 +- docs/i18n/mr/CHANGELOG.md | 8 +- docs/i18n/ms/CHANGELOG.md | 8 +- docs/i18n/nl/CHANGELOG.md | 8 +- docs/i18n/no/CHANGELOG.md | 8 +- docs/i18n/phi/CHANGELOG.md | 8 +- docs/i18n/pl/CHANGELOG.md | 8 +- docs/i18n/pt-BR/CHANGELOG.md | 8 +- docs/i18n/pt/CHANGELOG.md | 8 +- docs/i18n/ro/CHANGELOG.md | 8 +- docs/i18n/ru/CHANGELOG.md | 8 +- docs/i18n/sk/CHANGELOG.md | 8 +- docs/i18n/sv/CHANGELOG.md | 8 +- docs/i18n/sw/CHANGELOG.md | 8 +- docs/i18n/ta/CHANGELOG.md | 8 +- docs/i18n/te/CHANGELOG.md | 8 +- docs/i18n/th/CHANGELOG.md | 8 +- docs/i18n/tr/CHANGELOG.md | 8 +- docs/i18n/uk-UA/CHANGELOG.md | 8 +- docs/i18n/ur/CHANGELOG.md | 8 +- docs/i18n/vi/CHANGELOG.md | 8 +- docs/i18n/zh-CN/CHANGELOG.md | 8 +- docs/ops/RELEASE_CHECKLIST.md | 30 + docs/reference/ENVIRONMENT.md | 4 + docs/reference/openapi.yaml | 5032 ++--------------- docs/security/ROUTE_GUARD_TIERS.md | 16 +- next.config.mjs | 7 + open-sse/executors/index.ts | 4 + open-sse/executors/ninerouter.ts | 212 + open-sse/services/accountFallback.ts | 25 + package-lock.json | 69 +- package.json | 1 + scripts/check/check-env-doc-sync.mjs | 3 + .../services/[name]/embed/[...path]/route.ts | 78 + .../services/components/ApiKeyField.tsx | 165 + .../services/components/AutoStartToggle.tsx | 49 + .../components/CliproxyConnectionPanel.tsx | 142 + .../components/CliproxyModelMappingEditor.tsx | 199 + .../components/NinerouterEmbedFrame.tsx | 56 + .../components/NinerouterInstallWizard.tsx | 133 + .../components/NinerouterModelList.tsx | 166 + .../NinerouterProviderExposureCard.tsx | 92 + .../components/ServiceLifecycleButtons.tsx | 66 + .../services/components/ServiceLogsPanel.tsx | 101 + .../services/components/ServiceStatusCard.tsx | 76 + .../services/hooks/useServiceLogs.ts | 79 + .../services/hooks/useServiceStatus.ts | 68 + .../dashboard/providers/services/page.tsx | 61 + .../services/tabs/CliproxyServiceTab.tsx | 26 + .../services/tabs/NinerouterServiceTab.tsx | 43 + .../components/CliproxyapiSettingsTab.tsx | 16 + src/app/api/services/9router/_lib.ts | 32 + .../api/services/9router/auto-start/route.ts | 28 + src/app/api/services/9router/install/route.ts | 37 + src/app/api/services/9router/models/route.ts | 54 + .../services/9router/provider-expose/route.ts | 40 + src/app/api/services/9router/restart/route.ts | 22 + .../api/services/9router/rotate-key/route.ts | 32 + src/app/api/services/9router/start/route.ts | 23 + src/app/api/services/9router/status/route.ts | 76 + src/app/api/services/9router/stop/route.ts | 19 + src/app/api/services/9router/update/route.ts | 45 + src/app/api/services/[name]/logs/route.ts | 111 + src/app/api/services/cliproxy/_lib.ts | 29 + .../api/services/cliproxy/auto-start/route.ts | 28 + .../api/services/cliproxy/install/route.ts | 34 + .../api/services/cliproxy/restart/route.ts | 22 + src/app/api/services/cliproxy/start/route.ts | 22 + src/app/api/services/cliproxy/status/route.ts | 39 + src/app/api/services/cliproxy/stop/route.ts | 19 + src/app/api/services/cliproxy/update/route.ts | 45 + src/app/api/settings/route.ts | 25 +- .../api/version-manager/check-update/route.ts | 31 +- src/app/api/version-manager/install/route.ts | 17 +- src/app/api/version-manager/restart/route.ts | 28 +- src/app/api/version-manager/start/route.ts | 30 +- src/app/api/version-manager/status/route.ts | 33 +- src/app/api/version-manager/stop/route.ts | 21 +- src/i18n/messages/en.json | 2 + src/instrumentation-node.ts | 19 + src/lib/db/migrations/068_services.sql | 19 + src/lib/db/serviceModels.ts | 79 + src/lib/db/versionManager.ts | 61 +- src/lib/localDb.ts | 2 + src/lib/services/ServiceSupervisor.ts | 205 + src/lib/services/apiKey.ts | 23 + src/lib/services/bootstrap.ts | 98 + src/lib/services/embedWsProxy.ts | 270 + src/lib/services/healthCheck.ts | 77 + src/lib/services/htmlRewriter.ts | 185 + src/lib/services/installers/cliproxy.ts | 116 + src/lib/services/installers/ninerouter.ts | 163 + src/lib/services/installers/utils.ts | 110 + src/lib/services/modelSync.ts | 105 + src/lib/services/registry.ts | 31 + src/lib/services/reverseProxy.ts | 179 + src/lib/services/ringBuffer.ts | 91 + src/lib/services/types.ts | 42 + src/server/authz/routeGuard.ts | 7 +- src/shared/constants/providers.ts | 18 +- src/shared/constants/sidebarVisibility.ts | 8 + src/shared/validation/providerSchema.ts | 3 +- src/shared/validation/settingsSchemas.ts | 6 + .../services/cliproxy-coexistence.test.ts | 192 + .../services/full-lifecycle.int.test.ts | 273 + .../services/route-guard-services.int.test.ts | 193 + tests/unit/account-fallback-service.test.ts | 78 + .../unit/api/services/9router-models.test.ts | 132 + .../services/9router-provider-expose.test.ts | 100 + .../services/9router-status-reveal.test.ts | 127 + tests/unit/authz/routeGuard.test.ts | 31 + .../providers/services/api-key-field.test.ts | 33 + .../services/auto-start-toggle.test.ts | 39 + .../services/cliproxy-model-mapping.test.ts | 175 + .../providers/services/cliproxy-tab.test.ts | 69 + .../providers/services/ninerouter-tab.test.ts | 155 + .../dashboard/providers/services/page.test.ts | 78 + tests/unit/db/migration-068.test.ts | 146 + tests/unit/db/serviceModels.test.ts | 182 + tests/unit/ninerouter-executor.test.ts | 503 ++ tests/unit/services/ServiceSupervisor.test.ts | 201 + tests/unit/services/embed-proxy.test.ts | 392 ++ tests/unit/services/embedWsProxy.test.ts | 240 + tests/unit/services/end-to-end-shape.test.ts | 552 ++ tests/unit/services/htmlRewriter.test.ts | 241 + .../unit/services/installers/cliproxy.test.ts | 108 + .../services/installers/ninerouter.test.ts | 181 + tests/unit/services/lifecycle.test.ts | 193 + tests/unit/services/logs-sse.test.ts | 265 + tests/unit/services/modelSync.test.ts | 202 + tests/unit/services/ringBuffer.test.ts | 92 + 156 files changed, 11600 insertions(+), 4756 deletions(-) create mode 100644 docs/frameworks/EMBEDDED-SERVICES.md create mode 100644 open-sse/executors/ninerouter.ts create mode 100644 src/app/(dashboard)/dashboard/providers/services/[name]/embed/[...path]/route.ts create mode 100644 src/app/(dashboard)/dashboard/providers/services/components/ApiKeyField.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/services/components/AutoStartToggle.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/services/components/CliproxyConnectionPanel.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/services/components/NinerouterEmbedFrame.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/services/components/NinerouterInstallWizard.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/services/components/NinerouterProviderExposureCard.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/services/components/ServiceLifecycleButtons.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/services/components/ServiceLogsPanel.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/services/components/ServiceStatusCard.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/services/hooks/useServiceLogs.ts create mode 100644 src/app/(dashboard)/dashboard/providers/services/hooks/useServiceStatus.ts create mode 100644 src/app/(dashboard)/dashboard/providers/services/page.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/services/tabs/CliproxyServiceTab.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/services/tabs/NinerouterServiceTab.tsx create mode 100644 src/app/api/services/9router/_lib.ts create mode 100644 src/app/api/services/9router/auto-start/route.ts create mode 100644 src/app/api/services/9router/install/route.ts create mode 100644 src/app/api/services/9router/models/route.ts create mode 100644 src/app/api/services/9router/provider-expose/route.ts create mode 100644 src/app/api/services/9router/restart/route.ts create mode 100644 src/app/api/services/9router/rotate-key/route.ts create mode 100644 src/app/api/services/9router/start/route.ts create mode 100644 src/app/api/services/9router/status/route.ts create mode 100644 src/app/api/services/9router/stop/route.ts create mode 100644 src/app/api/services/9router/update/route.ts create mode 100644 src/app/api/services/[name]/logs/route.ts create mode 100644 src/app/api/services/cliproxy/_lib.ts create mode 100644 src/app/api/services/cliproxy/auto-start/route.ts create mode 100644 src/app/api/services/cliproxy/install/route.ts create mode 100644 src/app/api/services/cliproxy/restart/route.ts create mode 100644 src/app/api/services/cliproxy/start/route.ts create mode 100644 src/app/api/services/cliproxy/status/route.ts create mode 100644 src/app/api/services/cliproxy/stop/route.ts create mode 100644 src/app/api/services/cliproxy/update/route.ts create mode 100644 src/lib/db/migrations/068_services.sql create mode 100644 src/lib/db/serviceModels.ts create mode 100644 src/lib/services/ServiceSupervisor.ts create mode 100644 src/lib/services/apiKey.ts create mode 100644 src/lib/services/bootstrap.ts create mode 100644 src/lib/services/embedWsProxy.ts create mode 100644 src/lib/services/healthCheck.ts create mode 100644 src/lib/services/htmlRewriter.ts create mode 100644 src/lib/services/installers/cliproxy.ts create mode 100644 src/lib/services/installers/ninerouter.ts create mode 100644 src/lib/services/installers/utils.ts create mode 100644 src/lib/services/modelSync.ts create mode 100644 src/lib/services/registry.ts create mode 100644 src/lib/services/reverseProxy.ts create mode 100644 src/lib/services/ringBuffer.ts create mode 100644 src/lib/services/types.ts create mode 100644 tests/integration/services/cliproxy-coexistence.test.ts create mode 100644 tests/integration/services/full-lifecycle.int.test.ts create mode 100644 tests/integration/services/route-guard-services.int.test.ts create mode 100644 tests/unit/api/services/9router-models.test.ts create mode 100644 tests/unit/api/services/9router-provider-expose.test.ts create mode 100644 tests/unit/api/services/9router-status-reveal.test.ts create mode 100644 tests/unit/dashboard/providers/services/api-key-field.test.ts create mode 100644 tests/unit/dashboard/providers/services/auto-start-toggle.test.ts create mode 100644 tests/unit/dashboard/providers/services/cliproxy-model-mapping.test.ts create mode 100644 tests/unit/dashboard/providers/services/cliproxy-tab.test.ts create mode 100644 tests/unit/dashboard/providers/services/ninerouter-tab.test.ts create mode 100644 tests/unit/dashboard/providers/services/page.test.ts create mode 100644 tests/unit/db/migration-068.test.ts create mode 100644 tests/unit/db/serviceModels.test.ts create mode 100644 tests/unit/ninerouter-executor.test.ts create mode 100644 tests/unit/services/ServiceSupervisor.test.ts create mode 100644 tests/unit/services/embed-proxy.test.ts create mode 100644 tests/unit/services/embedWsProxy.test.ts create mode 100644 tests/unit/services/end-to-end-shape.test.ts create mode 100644 tests/unit/services/htmlRewriter.test.ts create mode 100644 tests/unit/services/installers/cliproxy.test.ts create mode 100644 tests/unit/services/installers/ninerouter.test.ts create mode 100644 tests/unit/services/lifecycle.test.ts create mode 100644 tests/unit/services/logs-sse.test.ts create mode 100644 tests/unit/services/modelSync.test.ts create mode 100644 tests/unit/services/ringBuffer.test.ts diff --git a/.env.example b/.env.example index c8c88915c2ad..0c9fdd1138c8 100644 --- a/.env.example +++ b/.env.example @@ -997,6 +997,21 @@ APP_LOG_TO_FILE=true # Used by: src/shared/utils/featureFlags.ts # ENABLE_CC_COMPATIBLE_PROVIDER=false +# ── 9router embedded service ── +# Override the host/port where the embedded 9router instance listens. +# Rarely needed — defaults match the bootstrap config (127.0.0.1:20130). +# Used by: open-sse/executors/ninerouter.ts +# NINEROUTER_HOST=127.0.0.1 +# NINEROUTER_PORT=20130 + +# ── Embedded service WebSocket proxy ── +# Standalone WebSocket proxy that tunnels WS connections to embedded services. +# Binds to loopback by default. Only change EMBED_WS_PROXY_HOST if you know +# what you are doing — exposing this to non-loopback bypasses local-only policy. +# Used by: src/lib/services/embedWsProxy.ts +# EMBED_WS_PROXY_HOST=127.0.0.1 +# EMBED_WS_PROXY_PORT=20131 + # ── CLIProxyAPI bridge (legacy) ── # Connection settings for external CLIProxyAPI instances. # Used by: open-sse/executors/cliproxyapi.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index acfd139ed88f..537042c77ae1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,11 +20,21 @@ ## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- **feat(services):** embedded service manager for 9Router and CLIProxyAPI — introduces a full lifecycle management system for locally-run AI proxy daemons accessible on loopback only: + - **ServiceSupervisor** (`src/lib/services/supervisor.ts`) — EventEmitter-based child process manager with state machine (`not_installed → stopped → starting → running → stopping → error`), ring-buffer log capture (5 MB/service), health polling, and configurable stop timeout. + - **ServiceRegistry** (`src/lib/services/registry.ts`) — process-scoped map of active `ServiceSupervisor` instances; integrates with `bootstrap.ts` for auto-start on app launch. + - **9Router lifecycle** — npm-installer (`src/lib/services/installers/ninerouter.ts`), 8 REST endpoints under `/api/services/9router/` (install, start, stop, restart, update, status, auto-start, rotate-key), NineRouterExecutor at `open-sse/executors/ninerouter.ts`, model-sync job, and provider registration. + - **CLIProxyAPI lifecycle** — GitHub-release installer (`src/lib/services/installers/cliproxy.ts`), 7 REST endpoints under `/api/services/cliproxy/` (install, start, stop, restart, update, status, auto-start), health probe at `/v1/models` (CPA 6.x has no `/health` endpoint). + - **SSE log streaming** — `/api/services/{name}/logs` with `tail` and `filter` query params, `snapshot` + `log` SSE events, 30-second heartbeat. + - **WebSocket proxy** — `/api/services/{name}/ws` reverse-proxies WebSocket connections to the embedded service UI port (port 20131); `isLocalOnlyPath()` guard in `routeGuard.ts` (Hard Rule #17). + - **HTTP UI proxy** — `/api/services/9router/proxy/[...path]` for iframe asset loading. + - **Dashboard page** `/dashboard/providers/services` — URL-based tab navigation (`?tab=cliproxy` default / `?tab=9router`), shared components (`ServiceStatusCard`, `ServiceLifecycleButtons`, `ServiceLogsPanel`), sidebar item under Omni Proxy (hideable, `material-symbols-outlined: deployed_code`). + - **CliproxyServiceTab** — auto-start toggle, fallback routing card (enable/disable, URL, status codes); fallback settings remain mirrored in Settings → CLIProxyAPI for backward compatibility. + - **NinerouterServiceTab** — auto-start toggle, API key display + rotation, collapsible embedded Web UI iframe (`sandbox="allow-scripts allow-same-origin allow-forms"`, loopback-only). + - **DB migration 068** — extends `version_manager` table with `autoStart`, `autoUpdate`, `providerExpose`, `apiKey`, and `port` columns. + - All service routes classified as `LOCAL_ONLY` in `routeGuard.ts`; loopback enforcement is unconditional before any auth check (leaked JWT via tunnel cannot trigger process spawning). --- diff --git a/CLAUDE.md b/CLAUDE.md index 79374608a315..409043d7f004 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -303,6 +303,17 @@ connection continue serving other models. 4. Add OAuth/credentials handling if needed (`src/lib/oauth/providers/`) 5. Tests + document in `docs/frameworks/CLOUD_AGENT.md` +### Adding a New Embedded Service + +1. Create installer in `src/lib/services/installers/{name}.ts` modeled on `ninerouter.ts` (use `runNpm` from `installers/utils.ts` — no shell interpolation, hard rule #13). +2. Register the service in `src/lib/services/bootstrap.ts` (add to `SERVICES[]` array and extend `buildSpawnArgsFactory()`). +3. Add a DB seed row for the new service in `src/lib/db/migrations/` (`version_manager` table, `status='not_installed'`, `auto_start=0`). +4. Create 7 API endpoints under `src/app/api/services/{name}/` (`_lib.ts`, `install`, `start`, `stop`, `restart`, `update`, `status`, `auto-start`). All delegate errors through `createErrorResponse()`. The shared `logs` endpoint is already wired via `[name]/logs/route.ts`. +5. Verify `/api/services/` is in `LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`; add a test asserting `isLocalOnlyPath()` returns `true` for the new prefix if you add one (hard rule #17). +6. Add a UI tab in `src/app/(dashboard)/dashboard/providers/services/tabs/` reusing `ServiceStatusCard`, `ServiceLifecycleButtons`, `ServiceLogsPanel`. +7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/reference/openapi.yaml`. +8. Write tests: unit (`tests/unit/services/`), integration (`tests/integration/services/`, gated by `RUN_SERVICES_INT=1`), and update `docs/ops/RELEASE_CHECKLIST.md` smoke section. + ### Adding a New Guardrail / Eval / Skill / Webhook event - Guardrail: `src/lib/guardrails/` → docs: `docs/security/GUARDRAILS.md` @@ -341,6 +352,7 @@ For any non-trivial change, read the matching deep-dive first: | API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/reference/openapi.yaml` | | Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` | | Release flow | `docs/ops/RELEASE_CHECKLIST.md` | +| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` | --- @@ -415,3 +427,4 @@ git push -u origin feat/your-feature 14. Never dismiss a CodeQL / Secret-Scanning alert without (a) first checking the pattern docs above to see if the helper applies, and (b) recording the technical justification in the dismissal comment. Precedent: `js/stack-trace-exposure` raised on callsites that already route through `sanitizeErrorMessage()` is a known CodeQL limitation (custom sanitizers not recognized) — dismiss as `false positive` referencing `docs/security/ERROR_SANITIZATION.md`. 15. Never expose routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. Loopback enforcement happens unconditionally before any auth check — leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`. 16. Never include `Co-Authored-By` trailers in commit messages. Commits must appear solely under the repository owner's Git identity (`diegosouzapw`). The `Co-Authored-By: Claude …` line causes GitHub to attribute commits to the `claude` Anthropic account, hiding the real author in the PR history. +17. Never expose routes under `/api/services/` or `/dashboard/providers/services/*/embed/` without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. These routes can spawn child processes (`npm install`, `node`). Loopback enforcement happens unconditionally before any auth check — a leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`. diff --git a/docs/architecture/ARCHITECTURE.md b/docs/architecture/ARCHITECTURE.md index 45d7b35ecc15..3f0b8e8cfdbd 100644 --- a/docs/architecture/ARCHITECTURE.md +++ b/docs/architecture/ARCHITECTURE.md @@ -327,6 +327,29 @@ OAuth provider modules (14 individual files under `src/lib/oauth/providers/`): - Individual providers: `claude.ts`, `codex.ts`, `gemini.ts`, `antigravity.ts`, `qoder.ts`, `qwen.ts`, `kimi-coding.ts`, `github.ts`, `kiro.ts`, `cursor.ts`, `kilocode.ts`, `cline.ts`, `windsurf.ts`, `gitlab-duo.ts` - Thin wrapper: `src/lib/oauth/providers.ts` — re-exports from individual modules +## 5) Embedded Services (v3.8.4) + +OmniRoute can install, supervise, and route to locally-running AI tool processes +called **embedded services**. Two are shipped in v3.8.4: 9Router and CLIProxyAPI. + +Architecture layers: + +- **UI** (`/dashboard/providers/services`) — two-tab page with lifecycle controls, + live log streaming, API key management, and (for 9Router) embedded native UI via + an internal reverse proxy. +- **API** (`/api/services/{name}/*`) — 8 endpoints for 9Router, 7 for CLIProxyAPI, + all classified **LOCAL_ONLY** (hard rule #17). A shared `GET /api/services/[name]/logs` + SSE endpoint serves both services. +- **Supervisor** (`src/lib/services/`) — generic `ServiceSupervisor` class wraps + `child_process.spawn`, holds a 5 MB ring buffer for SSE log streaming, a health + probe loop, an atomic operation lock, and a SIGTERM→SIGKILL graceful shutdown. + `bootstrap.ts` wires all configured services at process start. +- **Provider/executor** (`open-sse/executors/ninerouter.ts`) — 9Router is exposed as + a real provider. Models are prefixed `9router/{sub}/{model}` and synced every 5 min + from 9Router's `/v1/models` endpoint. + +Deep-dive: `docs/frameworks/EMBEDDED-SERVICES.md` + ## Major Subsystems (v3.8.0) ### A. Auto Combo Engine diff --git a/docs/architecture/CODEBASE_DOCUMENTATION.md b/docs/architecture/CODEBASE_DOCUMENTATION.md index fd67505d7d15..c794ec75a8fa 100644 --- a/docs/architecture/CODEBASE_DOCUMENTATION.md +++ b/docs/architecture/CODEBASE_DOCUMENTATION.md @@ -177,6 +177,7 @@ src/app/api/ ├── token-health/ ├── translator/ ├── tunnels/ +├── services/ Embedded service management (9router, cliproxy) — LOCAL_ONLY ├── upstream-proxy/ ├── usage/ ├── v1/ OpenAI-compatible public API @@ -185,6 +186,44 @@ src/app/api/ └── webhooks/ ``` +#### 3.1.2a `src/app/api/services/` — Embedded Services management + +Routes for installing, starting, stopping, and monitoring 9Router and CLIProxyAPI. +All paths are classified **LOCAL_ONLY** (loopback only, hard rule #17) because they +can invoke `npm install` and spawn child processes. + +``` +src/app/api/services/ +├── 9router/ +│ ├── _lib.ts getOrInitSupervisor() helper +│ ├── install/route.ts POST — npm install via execFile +│ ├── start/route.ts POST — supervisor.start() +│ ├── stop/route.ts POST — supervisor.stop() +│ ├── restart/route.ts POST — supervisor.restart() +│ ├── update/route.ts POST — npm install newer version +│ ├── rotate-key/route.ts POST — generate new API key + restart +│ ├── status/route.ts GET — live + DB status + version metadata +│ └── auto-start/route.ts POST — toggle auto_start flag +├── cliproxy/ +│ ├── _lib.ts getOrInitSupervisor() helper +│ ├── install/route.ts POST — npm install +│ ├── start/route.ts POST — supervisor.start() +│ ├── stop/route.ts POST — supervisor.stop() +│ ├── restart/route.ts POST — supervisor.restart() +│ ├── update/route.ts POST — npm install newer version +│ ├── status/route.ts GET — live + DB status + version metadata +│ └── auto-start/route.ts POST — toggle auto_start flag +└── [name]/ + └── logs/route.ts GET — SSE log tail (shared by all services) +``` + +Corresponding dashboard UI: +`src/app/(dashboard)/dashboard/providers/services/` — two-tab page (CLIProxyAPI + 9Router). +Reverse proxy for 9Router embedded UI: +`src/app/(dashboard)/dashboard/providers/services/[name]/embed/[...path]/route.ts` + +Deep-dive: `docs/frameworks/EMBEDDED-SERVICES.md` + #### 3.1.3 `src/app/api/v1/` — OpenAI-compatible public API ``` @@ -233,44 +272,45 @@ the same `open-sse/handlers/` pipeline). Always import data, sync, OAuth, skill, memory, etc. through these modules. The table groups the actual directories and notable top-level files. -| Module | Purpose | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `a2a/` | A2A protocol server: `taskManager.ts`, `streaming.ts`, `taskExecution.ts`, `routingLogger.ts`, `skills/` (5 skills: cost analysis, health report, provider discovery, quota management, smart routing) | -| `acp/` | Agent-Control-Protocol: `index.ts`, `manager.ts`, `registry.ts` | -| `api/` | Internal API helpers: `requireManagementAuth.ts`, `requireCliToolsAuth.ts`, `errorResponse.ts` | -| `auth/` | `managementPassword.ts` (password reset / hashing) | -| `batches/` | OpenAI Batches API service (`service.ts`) | -| `catalog/` | OpenRouter catalog sync (`openrouterCatalog.ts`) | -| `cloudAgent/` | Cloud agent registry: `api.ts`, `baseAgent.ts`, `db.ts`, `index.ts`, `registry.ts`, `types.ts`, `agents/{codex, devin, jules}.ts` | -| `combos/` | Combo resolution helpers | -| `compliance/` | Audit + provider audit: `index.ts`, `providerAudit.ts` | -| `config/` | Runtime config glue | -| `db/` | SQLite domain modules (see §3.2.1) | -| `display/` | UI/display helpers used by API responses | -| `embeddings/` | Embedding service registry | -| `env/` | Env loading + introspection | -| `evals/` | Eval runtime | -| `guardrails/` | `piiMasker.ts`, `promptInjection.ts`, `visionBridge.ts`, `visionBridgeHelpers.ts`, `registry.ts`, `base.ts` | -| `jobs/` | Background jobs (`autoUpdate.ts`, …) | -| `memory/` | Persistent memory: `store.ts`, `cache.ts`, `retrieval.ts`, `summarization.ts`, `extraction.ts`, `injection.ts`, `qdrant.ts`, `settings.ts`, `verify.ts`, `schemas.ts`, `types.ts` | -| `monitoring/` | `observability.ts` | -| `oauth/` | OAuth providers (14): `antigravity`, `claude`, `cline`, `codex`, `cursor`, `gemini`, `github`, `gitlab-duo`, `kilocode`, `kimi-coding`, `kiro`, `qoder`, `qwen`, `windsurf` plus `services/`, `utils/{pkce, server, banner, codexAuthFile, ui}`, `constants/oauth.ts` | -| `plugins/` | Plugin loader (`index.ts`) | -| `promptCache/` | `prefixAnalyzer.ts`, `index.ts` | -| `providerModels/` | Managed model lifecycle: `modelDiscovery.ts`, `managedModelImport.ts`, `managedAvailableModels.ts`, `cursorAgent.ts` | -| `providers/` | Provider helpers: `catalog.ts`, `validation.ts`, `imageValidation.ts`, `claudeExtraUsage.ts`, `codexConnectionDefaults.ts`, `codexFastTier.ts`, `webCookieAuth.ts`, `managedAvailableModels.ts`, `requestDefaults.ts` | -| `resilience/` | `settings.ts` — settings for circuit breaker, cooldown, lockout | -| `runtime/` | Runtime feature detection | -| `search/` | `executeWebSearch.ts` | -| `skills/` | Skill framework: `registry.ts`, `executor.ts`, `interception.ts`, `injection.ts`, `sandbox.ts`, `custom.ts`, `hybrid.ts`, `builtins.ts`, `a2a.ts`, `providerSettings.ts`, `schemas.ts`, `skillssh.ts`, `types.ts`, plus `builtin/browser.ts` | -| `spend/` | `batchWriter.ts` (write-behind buffer) | -| `sync/` | `bundle.ts`, `tokens.ts` (Cloud Sync) | -| `system/` | System-level helpers | -| `translator/` | Top-level translator glue (delegates into `open-sse/translator/`) | -| `usage/` | Usage accounting: `costCalculator.ts`, `tokenAccounting.ts`, `usageHistory.ts`, `aggregateHistory.ts`, `usageStats.ts`, `callLogs.ts`, `callLogArtifacts.ts`, `fetcher.ts`, `providerLimits.ts`, `migrations.ts` | -| `versionManager/` | Auto-update + version manifest | -| `ws/` | WebSocket bridge | -| `zed-oauth/` | Zed editor OAuth flow | +| Module | Purpose | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `a2a/` | A2A protocol server: `taskManager.ts`, `streaming.ts`, `taskExecution.ts`, `routingLogger.ts`, `skills/` (5 skills: cost analysis, health report, provider discovery, quota management, smart routing) | +| `acp/` | Agent-Control-Protocol: `index.ts`, `manager.ts`, `registry.ts` | +| `api/` | Internal API helpers: `requireManagementAuth.ts`, `requireCliToolsAuth.ts`, `errorResponse.ts` | +| `auth/` | `managementPassword.ts` (password reset / hashing) | +| `batches/` | OpenAI Batches API service (`service.ts`) | +| `catalog/` | OpenRouter catalog sync (`openrouterCatalog.ts`) | +| `cloudAgent/` | Cloud agent registry: `api.ts`, `baseAgent.ts`, `db.ts`, `index.ts`, `registry.ts`, `types.ts`, `agents/{codex, devin, jules}.ts` | +| `combos/` | Combo resolution helpers | +| `compliance/` | Audit + provider audit: `index.ts`, `providerAudit.ts` | +| `config/` | Runtime config glue | +| `db/` | SQLite domain modules (see §3.2.1) | +| `display/` | UI/display helpers used by API responses | +| `embeddings/` | Embedding service registry | +| `env/` | Env loading + introspection | +| `evals/` | Eval runtime | +| `guardrails/` | `piiMasker.ts`, `promptInjection.ts`, `visionBridge.ts`, `visionBridgeHelpers.ts`, `registry.ts`, `base.ts` | +| `jobs/` | Background jobs (`autoUpdate.ts`, …) | +| `memory/` | Persistent memory: `store.ts`, `cache.ts`, `retrieval.ts`, `summarization.ts`, `extraction.ts`, `injection.ts`, `qdrant.ts`, `settings.ts`, `verify.ts`, `schemas.ts`, `types.ts` | +| `monitoring/` | `observability.ts` | +| `oauth/` | OAuth providers (14): `antigravity`, `claude`, `cline`, `codex`, `cursor`, `gemini`, `github`, `gitlab-duo`, `kilocode`, `kimi-coding`, `kiro`, `qoder`, `qwen`, `windsurf` plus `services/`, `utils/{pkce, server, banner, codexAuthFile, ui}`, `constants/oauth.ts` | +| `plugins/` | Plugin loader (`index.ts`) | +| `promptCache/` | `prefixAnalyzer.ts`, `index.ts` | +| `providerModels/` | Managed model lifecycle: `modelDiscovery.ts`, `managedModelImport.ts`, `managedAvailableModels.ts`, `cursorAgent.ts` | +| `providers/` | Provider helpers: `catalog.ts`, `validation.ts`, `imageValidation.ts`, `claudeExtraUsage.ts`, `codexConnectionDefaults.ts`, `codexFastTier.ts`, `webCookieAuth.ts`, `managedAvailableModels.ts`, `requestDefaults.ts` | +| `resilience/` | `settings.ts` — settings for circuit breaker, cooldown, lockout | +| `runtime/` | Runtime feature detection | +| `search/` | `executeWebSearch.ts` | +| `services/` | Embedded services framework: `ServiceSupervisor.ts` (generic child-process supervisor with operation lock, ring buffer, health checker), `bootstrap.ts` (process-level registration and auto-start), `registry.ts` (tool → supervisor map), `apiKey.ts` (AES-256-GCM key store), `modelSync.ts` (periodic model sync), `ringBuffer.ts` (5 MB circular log buffer), `healthCheck.ts` (HTTP health probe), `types.ts`, `embedWsProxy.ts` (WebSocket proxy), `installers/{ninerouter,cliproxy}.ts`. See `docs/frameworks/EMBEDDED-SERVICES.md` | +| `skills/` | Skill framework: `registry.ts`, `executor.ts`, `interception.ts`, `injection.ts`, `sandbox.ts`, `custom.ts`, `hybrid.ts`, `builtins.ts`, `a2a.ts`, `providerSettings.ts`, `schemas.ts`, `skillssh.ts`, `types.ts`, plus `builtin/browser.ts` | +| `spend/` | `batchWriter.ts` (write-behind buffer) | +| `sync/` | `bundle.ts`, `tokens.ts` (Cloud Sync) | +| `system/` | System-level helpers | +| `translator/` | Top-level translator glue (delegates into `open-sse/translator/`) | +| `usage/` | Usage accounting: `costCalculator.ts`, `tokenAccounting.ts`, `usageHistory.ts`, `aggregateHistory.ts`, `usageStats.ts`, `callLogs.ts`, `callLogArtifacts.ts`, `fetcher.ts`, `providerLimits.ts`, `migrations.ts` | +| `versionManager/` | Auto-update + version manifest | +| `ws/` | WebSocket bridge | +| `zed-oauth/` | Zed editor OAuth flow | Top-level files in `src/lib/`: diff --git a/docs/frameworks/EMBEDDED-SERVICES.md b/docs/frameworks/EMBEDDED-SERVICES.md new file mode 100644 index 000000000000..eb05fb73b0be --- /dev/null +++ b/docs/frameworks/EMBEDDED-SERVICES.md @@ -0,0 +1,790 @@ +# Embedded Services + +> **Version:** v3.8.4 +> **Last updated:** 2026-05-25 +> **Audience:** Engineers adding, maintaining, or debugging embedded services (9Router, CLIProxyAPI). + +Embedded services are locally-installed process sidecar tools that OmniRoute installs, supervises, and +exposes as first-class routing targets. Unlike external providers (which are reached over the internet +via API keys), embedded services run on the same machine as OmniRoute and communicate over loopback. + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Architecture — 4 layers](#2-architecture--4-layers) +3. [Lifecycle state machine](#3-lifecycle-state-machine) +4. [API reference](#4-api-reference) +5. [Security](#5-security) +6. [Adding a new embedded service](#6-adding-a-new-embedded-service) +7. [Troubleshooting](#7-troubleshooting) +8. [FAQ](#8-faq) + +--- + +## 1. Overview + +### Why embedded services? + +Two services are embedded as of v3.8.4: + +| Service | npm package | Default port | Purpose | +| --------------- | ---------------------------------------------- | :----------: | ---------------------------------------------------------------------------------------------------- | +| **9Router** | `9router` | 20130 | AI router that OmniRoute can use as a sub-provider. Models exposed as `9router/{sub}/{model}` | +| **CLIProxyAPI** | `@anthropic/cli-proxy` (via `cliproxy` binary) | auto | Local proxy adapter for Anthropic CLI auth flows. Provides fallback routing when OAuth tokens expire | + +Both follow the same supervisory model: + +- OmniRoute installs them under `DATA_DIR/services/{name}/` (isolated from OmniRoute's own `package.json`) +- OmniRoute spawns and monitors them as child processes +- OmniRoute injects an ephemeral API key into the child's environment and rotates it without downtime +- All management routes (`/api/services/*`) are **LOCAL_ONLY** — accessible only from loopback (hard rule #17) + +### Key decisions (from design plan) + +| Decision | Value | +| ------------------------------------- | ------------------------------------------------------------------------ | +| Dashboard access to 9Router native UI | Reverse proxy at `/dashboard/providers/services/9router/embed/*` | +| Installation mechanism | `npm install {package}` via `execFile` (no shell interpolation) | +| Consumption mode | Provider registered as `9router/{sub}/{model}` in routing engine | +| API key management | OmniRoute generates, encrypts at-rest (AES-256-GCM), and injects via env | +| Dashboard location | `/dashboard/providers/services` (two tabs) | +| Auto-start | Toggle per service, default OFF | + +--- + +## 2. Architecture — 4 layers + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ Layer 1 — UI │ +│ /dashboard/providers/services (tabs: CLIProxyAPI | 9Router) │ +│ Logs live (SSE), Start/Stop/Restart/Update, Settings, Install │ +│ │ +│ src/app/(dashboard)/dashboard/providers/services/ │ +│ ├── page.tsx Shell + tab routing by ?tab= │ +│ ├── tabs/ CliproxyServiceTab, NinerouterServiceTab│ +│ └── components/ ServiceStatusCard, ServiceLifecycleButtons,│ +│ ServiceLogsPanel, ApiKeyCard, ... │ +└──────────────────────┬─────────────────────────────────────────────┘ + │ HTTP (Next.js fetch) +┌──────────────────────▼─────────────────────────────────────────────┐ +│ Layer 2 — API (LOCAL_ONLY — loopback only) │ +│ │ +│ /api/services/9router/{install|start|stop|restart|update| │ +│ rotate-key|status|auto-start|logs} │ +│ /api/services/cliproxy/{install|start|stop|restart|update| │ +│ status|auto-start|logs} │ +│ /dashboard/providers/services/9router/embed/[...path] │ +│ (reverse HTTP + WebSocket proxy → 9Router upstream) │ +│ │ +│ Gate: LOCAL_ONLY_API_PREFIXES includes "/api/services/" and │ +│ "/dashboard/providers/services/*/embed/" │ +└──────────────────────┬─────────────────────────────────────────────┘ + │ in-process calls +┌──────────────────────▼─────────────────────────────────────────────┐ +│ Layer 3 — ServiceSupervisor (src/lib/services/) │ +│ │ +│ ServiceSupervisor.ts Generic supervisor (child_process.spawn) │ +│ ├── install: execFile('npm', ['install', pkg, '--prefix']) │ +│ ├── start: spawn(node, [entrypoint], {env, cwd}) │ +│ ├── api_key: crypto.randomBytes(32) → env NINEROUTER_API_KEY │ +│ ├── port: 20130 for 9Router (configurable) │ +│ ├── logs: stdio ring buffer 5 MB → SSE events │ +│ ├── health: HTTP GET /health every 2–5 s, lazy recovery │ +│ └── lifecycle: SIGTERM 15 s → SIGKILL │ +│ │ +│ registry.ts getSupervisor(name) / registerSupervisor() │ +│ bootstrap.ts Bootstraps all SERVICES[] at process start │ +│ apiKey.ts getOrCreateApiKey(), generateServiceApiKey() │ +│ modelSync.ts Periodic GET /v1/models → service_models table │ +│ ringBuffer.ts Circular log buffer (5 MB per service) │ +│ healthCheck.ts Polling HTTP health probe │ +│ installers/ ninerouter.ts, cliproxy.ts (installer adapters)│ +└──────────────────────┬─────────────────────────────────────────────┘ + │ OpenAI-compatible HTTP (loopback) +┌──────────────────────▼─────────────────────────────────────────────┐ +│ Layer 4 — Provider / Routing │ +│ │ +│ open-sse/executors/ninerouter.ts │ +│ Re-looks up port and API key per-request (no caching). │ +│ Strips "9router/" prefix from model id before proxying. │ +│ Returns 503 service_not_running if supervisor not in "running". │ +│ │ +│ src/shared/constants/providers.ts │ +│ Entry for "9router": isEmbeddedService: true │ +│ │ +│ open-sse/config/providerRegistry.ts │ +│ Models stored as "9router/{sub}/{model}" (prefixed). │ +│ Synced every 5 min by modelSync.ts. │ +└────────────────────────────────────────────────────────────────────┘ +``` + +### Key source files + +| File | Role | +| ------------------------------------------- | ------------------------------------------------ | +| `src/lib/services/ServiceSupervisor.ts` | Core class: lifecycle, lock, health, ring buffer | +| `src/lib/services/bootstrap.ts` | Process-level registration and auto-start | +| `src/lib/services/registry.ts` | Singleton map `tool → supervisor` | +| `src/lib/services/apiKey.ts` | Key generation, AES-256-GCM encryption at-rest | +| `src/lib/services/modelSync.ts` | Periodic model sync (5 min) + on-demand | +| `src/lib/services/ringBuffer.ts` | 5 MB circular log buffer with SSE subscribe | +| `src/lib/services/healthCheck.ts` | HTTP health probe (configurable interval) | +| `src/lib/services/installers/ninerouter.ts` | npm install/update/uninstall for 9Router | +| `src/lib/services/installers/cliproxy.ts` | npm install/update/uninstall for CLIProxyAPI | +| `src/app/api/services/9router/_lib.ts` | `getOrInitSupervisor()` helper | +| `src/app/api/services/[name]/logs/route.ts` | Shared SSE logs endpoint | +| `open-sse/executors/ninerouter.ts` | Provider executor (Layer 4) | + +--- + +## 3. Lifecycle state machine + +``` + install() + ┌─────────────┐ ──────────► ┌─────────────┐ + │ not_installed│ │ stopped │◄──────────────────┐ + └─────────────┘ └──────┬──────┘ │ + │ start() │ + ▼ │ stop() + ┌──────────┐ │ + │ starting │ │ + └────┬─────┘ │ + health probe ok │ crash / SIGTERM │ + ┌────▼─────┐ (exit within 5s) │ + │ running │──── crash ──────────►┤ + └────┬─────┘ ┌─▼────┐ + stop() │ │error │ + ▼ └──────┘ + ┌──────────┐ + │ stopping │ + └──────────┘ +``` + +States stored in the `version_manager` DB table (`status` column) and mirrored +in `ServiceSupervisor` in-memory state. The in-memory state is authoritative for +a running process; the DB state is the durable fallback at boot. + +### State transitions + +| From | Event | To | +| --------------- | ---------------------------------- | ---------------------- | +| `not_installed` | `install()` succeeds | `stopped` | +| `stopped` | `start()` called | `starting` | +| `starting` | health probe returns 200 | `running` | +| `starting` | process exits before healthy | `error` | +| `running` | `stop()` called | `stopping` → `stopped` | +| `running` | process exits unexpectedly (< 5 s) | `error` (fast crash) | +| `running` | process exits unexpectedly (> 5 s) | `error` | +| `error` | `start()` called | `starting` | +| any | `stop()` while `stopping` | no-op | + +### Operation lock + +`ServiceSupervisor` serializes lifecycle operations through an async operation lock +(`withLock()`). Concurrent `start()` calls on the same supervisor result in exactly +one spawn; the second caller waits and returns the existing status. This prevents +race conditions when, for example, auto-start and a UI button fire simultaneously. + +--- + +## 4. API reference + +All routes under `/api/services/` are **LOCAL_ONLY** (loopback only, hard rule #17). +Non-loopback requests receive `403 LOCAL_ONLY` regardless of auth token. + +### 4.1 9Router endpoints (8 routes) + +#### `POST /api/services/9router/install` + +Install 9Router from npm. Creates `DATA_DIR/services/9router/` with its own +`package.json` and `node_modules/`. Does not conflict with OmniRoute's own deps. + +**Request body** (all optional): + +```json +{ "version": "latest" } +``` + +| Field | Type | Default | Description | +| --------- | -------- | ---------- | ------------------------------------ | +| `version` | `string` | `"latest"` | npm version tag or semver to install | + +**Responses:** + +| Status | Description | +| ------ | ------------------------------------------------------ | +| `200` | `{ ok: true, installedVersion: "x.y.z", path: "..." }` | +| `400` | Invalid request body (Zod validation failure) | +| `409` | Already installing (lock held) | +| `500` | npm install failed — see `message` for friendly error | + +**Notes:** Uses `execFile('npm', [...])` — no shell, no interpolation (hard rule #13). +EACCES errors are surfaced as friendly messages. + +--- + +#### `POST /api/services/9router/start` + +Start 9Router. Registers a supervisor if not already registered, then calls +`supervisor.start()`. Idempotent when already running. + +**Request body:** none + +**Responses:** + +| Status | Description | +| ------ | ---------------------------------------------------- | +| `200` | `ServiceStatus` object (see schema below) | +| `409` | 9Router is not installed (`status: "not_installed"`) | +| `503` | Start failed (process error — see `lastError`) | + +**ServiceStatus schema:** + +```json +{ + "tool": "9router", + "state": "running", + "pid": 12345, + "port": 20130, + "health": "healthy", + "startedAt": "2026-05-25T10:00:00.000Z", + "lastError": null +} +``` + +--- + +#### `POST /api/services/9router/stop` + +Gracefully stop 9Router. Sends SIGTERM, waits 15 s, then SIGKILL if still alive. +Idempotent when already stopped. + +**Request body:** none + +**Responses:** + +| Status | Description | +| ------ | ---------------------------------- | +| `200` | `ServiceStatus` (state: "stopped") | +| `503` | Stop failed unexpectedly | + +--- + +#### `POST /api/services/9router/restart` + +Equivalent to `stop()` then `start()` under the operation lock. + +**Request body:** none + +**Responses:** same as `start` (returns final `ServiceStatus`). + +--- + +#### `POST /api/services/9router/update` + +Updates 9Router to a newer npm version. If the service is running, it is stopped +first, npm install is run (installing the newer version in-place), and then the +service is restarted. + +**Request body** (all optional): + +```json +{ "version": "latest" } +``` + +**Responses:** + +| Status | Description | +| ------ | --------------------------------------------------------------- | +| `200` | `{ ok: true, previousVersion: "...", installedVersion: "..." }` | +| `400` | Invalid body | +| `500` | npm update failed | + +--- + +#### `POST /api/services/9router/rotate-key` + +Generates a new API key for 9Router, encrypts it at-rest, and restarts the service +(if running) so it picks up the new key from its environment. The old key is +invalidated immediately. + +**Request body:** none + +**Responses:** + +| Status | Description | +| ------ | ------------------------------------------ | +| `200` | `{ keyRotated: true, restarted: boolean }` | +| `500` | Rotation failed | + +**Security:** The new key is never returned in the response (no credential leak). +It is stored encrypted (AES-256-GCM) in the `version_manager` table. + +--- + +#### `GET /api/services/9router/status` + +Returns combined live + DB status including version metadata and API key preview. + +**Responses:** + +| Status | Description | +| ------ | ------------------ | +| `200` | See schema below | +| `500` | Status read failed | + +**Response schema:** + +```json +{ + "tool": "9router", + "state": "running", + "pid": 12345, + "port": 20130, + "health": "healthy", + "startedAt": "2026-05-25T10:00:00.000Z", + "lastError": null, + "installedVersion": "1.2.3", + "latestVersion": "1.2.4", + "updateAvailable": true, + "apiKeyMasked": "nr_****abcd", + "autoStart": false, + "providerExpose": false +} +``` + +--- + +#### `POST /api/services/9router/auto-start` + +Toggle the auto-start flag. When `enabled: true`, the service starts automatically +the next time OmniRoute boots (if the service is installed). + +**Request body:** + +```json +{ "enabled": true } +``` + +**Responses:** + +| Status | Description | +| ------ | --------------------- | +| `200` | `{ autoStart: true }` | +| `400` | Invalid body | + +--- + +#### `GET /api/services/9router/logs` + +SSE stream of live logs from 9Router's stdout/stderr ring buffer. + +**Query parameters:** + +| Param | Type | Default | Description | +| -------- | --------- | ------- | --------------------------------------------------------- | +| `tail` | `integer` | 200 | How many historical lines to send first (max 1000) | +| `filter` | `string` | none | Case-insensitive substring filter (no regex — ReDoS-safe) | + +**SSE events:** + +| Event | Data | Description | +| ----------- | ----------- | ----------------------- | +| `snapshot` | `LogLine[]` | Initial historical tail | +| `log` | `LogLine` | Live log line | +| `heartbeat` | `{}` | Keep-alive every 15 s | + +**LogLine schema:** + +```json +{ "ts": 1716633600000, "stream": "stdout", "line": "[9router] Listening on :20130" } +``` + +**Responses:** + +| Status | Description | +| ------ | --------------------------------------------- | +| `200` | `text/event-stream` | +| `400` | `filter` parameter too long (> 200 chars) | +| `404` | Service not found (supervisor not registered) | + +--- + +### 4.2 CLIProxyAPI endpoints (7 routes) + +CLIProxyAPI has the same endpoint shape as 9Router minus `rotate-key` (CLIProxyAPI +does not require an injected API key; it authenticates via the host's existing CLI +config) and `status` includes fewer fields. + +| Method | Path | Description | +| ------ | ----------------------------------- | ------------------------------------ | +| `POST` | `/api/services/cliproxy/install` | Install CLIProxyAPI from npm | +| `POST` | `/api/services/cliproxy/start` | Start CLIProxyAPI | +| `POST` | `/api/services/cliproxy/stop` | Stop CLIProxyAPI | +| `POST` | `/api/services/cliproxy/restart` | Restart CLIProxyAPI | +| `POST` | `/api/services/cliproxy/update` | Update to newer version | +| `GET` | `/api/services/cliproxy/status` | Live + DB status (no `apiKeyMasked`) | +| `POST` | `/api/services/cliproxy/auto-start` | Toggle auto-start | + +The shared `GET /api/services/{name}/logs` endpoint (see §4.1) works for both +services using the `[name]` dynamic segment. + +--- + +### 4.3 Reverse proxy (9Router dashboard embed) + +The dashboard embeds the 9Router web UI inside an iframe via an internal reverse +proxy at: + +``` +GET|POST|... /dashboard/providers/services/9router/embed/[...path] +``` + +This proxy: + +- Forwards the request to `http://127.0.0.1:{port}/{path}` (loopback only) +- Strips incoming `cookie` and `authorization` headers (no leakage of OmniRoute session) +- Injects `Authorization: Bearer {apiKey}` for 9Router authentication +- Strips `set-cookie`, `content-security-policy`, `x-frame-options`, `cross-origin-*` from the response +- Rewrites HTML responses to inject `` and normalize absolute paths (`/foo` → `/dashboard/.../embed/foo`) + +WebSocket upgrades for the embedded dashboard are handled by a companion server on a +dedicated port (see `src/lib/services/embedWsProxy.ts`). + +**Security:** The embed proxy routes are classified under `LOCAL_ONLY_API_PREFIXES` +and can only be reached from loopback. An attacker who obtains a JWT via a +Cloudflare/Ngrok tunnel cannot proxy into embedded services. + +--- + +## 5. Security + +### LOCAL_ONLY enforcement (hard rule #17) + +All routes under `/api/services/` and `/dashboard/providers/services/*/embed/` are +classified as LOCAL_ONLY in `src/server/authz/routeGuard.ts`. The loopback check +runs unconditionally before any auth branch: + +``` +request arrives + → isLocalOnlyPath(path)? + → non-loopback → 403 LOCAL_ONLY (always, before auth check) + → loopback → fall through to normal auth +``` + +This prevents a leaked JWT (e.g., via a tunnel) from triggering `npm install` or +process spawning. See `docs/security/ROUTE_GUARD_TIERS.md` for the full tier +matrix. + +### API key injection + +9Router requires an API key for its own HTTP endpoints. OmniRoute: + +1. Generates a key via `crypto.randomBytes(32).toString("base64url")` with a + service-specific prefix (`nr_` for 9Router). +2. Encrypts it at-rest using AES-256-GCM (same cipher used for provider credentials). +3. Decrypts and injects it as `NINEROUTER_API_KEY` environment variable at spawn time. +4. Never returns the plaintext key in any HTTP response. + +### SSRF defense + +The reverse HTTP proxy (`/dashboard/.../embed/[...path]`) is hardcoded to forward +only to `http://127.0.0.1:{port}`. It never follows redirects to non-loopback +destinations. The `ssrf-req-filter` library is used to reject any upstream URL that +resolves outside the loopback range. + +### Shell safety (hard rule #13) + +`npm install` is invoked via `execFile('npm', ['install', pkg, '--prefix', dir])` — +no template literals, no shell, no interpolation of external paths into the command +string. Runtime values (ports, API keys) are passed via the child's `env` object. + +### Error sanitization (hard rule #12) + +All error responses from `/api/services/*` go through `buildErrorBody()` or +`sanitizeErrorMessage()`. Raw `err.stack` and `err.message` are never returned +verbatim to the caller. + +--- + +## 6. Adding a new embedded service + +Follow these 8 steps. Read the existing implementations in `src/lib/services/installers/` +and `src/app/api/services/` as the canonical reference. + +### Step 1 — Create the installer + +Create `src/lib/services/installers/{name}.ts` modeled on `ninerouter.ts`: + +```typescript +export const NAME_PACKAGE = "your-npm-package"; +export const NAME_DEFAULT_PORT = 20132; // pick a free port + +export async function install(version = "latest"): Promise { ... } +export async function update(version = "latest"): Promise { ... } +export async function uninstall(): Promise { ... } +export function resolveSpawnArgs(apiKey: string, port: number): SpawnArgs { ... } +export async function getInstalledVersion(): Promise { ... } +export async function getLatestVersion(): Promise { ... } +``` + +Use `runNpm(['install', NAME_PACKAGE, '--prefix', dir])` from `installers/utils.ts` +— never `execSync` or shell interpolation. + +### Step 2 — Register in bootstrap + +Add a `ServiceEntry` to the `SERVICES` array in `src/lib/services/bootstrap.ts`: + +```typescript +{ + tool: "myservice", + port: NAME_DEFAULT_PORT, + healthPath: "/health", + healthIntervalMs: 5_000, + stopTimeoutMs: 15_000, + logsBufferBytes: 5_242_880, + needsApiKey: true, // false if no API key needed +} +``` + +Extend `buildSpawnArgsFactory()` to handle `cfg.tool === "myservice"`. + +### Step 3 — Add migration and DB seed + +Ensure the service has a row in `version_manager` via a migration in +`src/lib/db/migrations/`. The row should have: + +```sql +INSERT OR IGNORE INTO version_manager (tool, status, auto_start, provider_expose) +VALUES ('myservice', 'not_installed', 0, 0); +``` + +### Step 4 — Create the 7 API endpoints + +Under `src/app/api/services/{name}/`: + +``` +_lib.ts getOrInitSupervisor() helper +install/route.ts POST — calls installer.install() +start/route.ts POST — calls supervisor.start() +stop/route.ts POST — calls supervisor.stop() +restart/route.ts POST — calls supervisor.restart() +update/route.ts POST — calls installer.update() +status/route.ts GET — merges live + DB status +auto-start/route.ts POST — toggles auto_start flag +``` + +The shared `GET /api/services/[name]/logs` route is already wired — no changes +needed there. + +Delegate all error responses through `createErrorResponse()` / `buildErrorBody()`. + +### Step 5 — Add to LOCAL_ONLY_API_PREFIXES + +In `src/server/authz/routeGuard.ts`, verify that `/api/services/` is already listed. +If you introduce a new prefix (e.g., `/api/tools/`), add it to both +`LOCAL_ONLY_API_PREFIXES` and, if it spawns processes, to `SPAWN_CAPABLE_PREFIXES`. +Add a test in `tests/unit/authz/routeGuard.test.ts`. + +### Step 6 — Add the UI tab + +Create `src/app/(dashboard)/dashboard/providers/services/tabs/{Name}ServiceTab.tsx`. +Reuse shared components: + +- `ServiceStatusCard` — live state + health badge +- `ServiceLifecycleButtons` — Start / Stop / Restart / Update +- `ServiceLogsPanel` — SSE log tail (connects to `/api/services/{name}/logs`) +- `ApiKeyCard` — key reveal + rotate (if `needsApiKey: true`) + +Register the tab in `ServicesPageShell.tsx`. + +### Step 7 — Add the provider entry (if the service is a routing target) + +If the embedded service exposes an OpenAI-compatible `/v1/chat/completions` endpoint: + +1. Add a provider entry in `src/shared/constants/providers.ts` with `isEmbeddedService: true`. +2. Create `open-sse/executors/{name}.ts` extending `BaseExecutor`. Re-lookup port and + API key per-request (never cache in the constructor). Return a `503 service_not_running` + response when the supervisor state is not `"running"`. +3. Register models in `open-sse/config/providerRegistry.ts` with the service prefix + (e.g., `myservice/sub/model`). `modelSync.ts` will keep them updated. + +### Step 8 — Document and test + +1. Update `docs/frameworks/EMBEDDED-SERVICES.md` (this file) — add the service to the + table in §1 and any new endpoints to §4. +2. Add unit tests in `tests/unit/services/` (lifecycle, installer, API shape). +3. Add integration test in `tests/integration/services/` (behind `RUN_SERVICES_INT=1`). +4. Update `docs/reference/openapi.yaml` with the new endpoints. + +--- + +## 7. Troubleshooting + +### Service does not start + +**Symptoms:** Start button returns 503, state stays `"error"` or `"starting"`. + +**Checklist:** + +1. Check `GET /api/services/{name}/logs` (or the Logs panel in the dashboard). Look + for lines like `Error: ENOENT`, `address already in use`, or `Cannot find module`. +2. Verify `npm` is in PATH: `which npm` from the same user account that runs OmniRoute. +3. Verify the service is installed: check `GET /api/services/{name}/status` for + `installedVersion`. If `null`, run install first. +4. Check `DATA_DIR/services/{name}/node_modules/` exists and is not empty. +5. Check the `lastError` field in the status response for the sanitized exit reason. + +--- + +### Cold start is slow (> 10 s to reach `running`) + +**Symptoms:** State stays `"starting"` for a long time before going to `"running"` or `"error"`. + +**Explanation:** 9Router's cold start includes importing large dependency trees (DNS, +tunnel, MITM modules). Default health interval is 2 s with 3 attempts before the +supervisor declares a timeout (but continues polling). + +**Fix:** The `healthIntervalMs` and the `waitForHealthy` timeout +(`healthIntervalMs * 3`) are configurable in `bootstrap.ts`. For services with longer +startup times, increase `healthIntervalMs` to 5000 and `stopTimeoutMs` to 30 000. + +--- + +### Port collision (`EADDRINUSE`) + +**Symptoms:** Logs show `address already in use :::20130`. + +**Causes:** + +- Another process is already using port 20130. +- A previous 9Router process was not fully stopped (zombie PID). + +**Fix:** + +1. Change the default port via `NINEROUTER_PORT` environment variable in `.env`. +2. Find and kill the conflicting process: `lsof -ti :20130 | xargs kill -9`. +3. The port is configurable per service in `bootstrap.ts` via the `port` field. + +**Note:** 9Router defaults to port 20130 specifically to avoid colliding with +OmniRoute's default port 20128. + +--- + +### Permission denied (EACCES) on install + +**Symptoms:** Install returns 500, logs show `EACCES` or `permission denied`. + +**Causes:** + +- `DATA_DIR` or its parent is not writable by the OmniRoute process. +- Running inside Docker rootless without write access to the mapped volume. + +**Fix:** + +1. Check `DATA_DIR` (default: `~/.omniroute/`): `ls -la ~/.omniroute/` +2. Ensure the OmniRoute process user owns the directory: `chown -R $USER ~/.omniroute/` +3. In Docker, ensure the volume mount has the correct permissions for the container user. + +--- + +### Update fails (`npm install` timeout or network error) + +**Symptoms:** Update returns 500 with `InstallError`, logs show network timeout. + +**Checklist:** + +1. Confirm npm registry is reachable: `npm ping`. +2. Check for corporate proxy: `npm config get proxy`, `npm config get https-proxy`. +3. Try the install manually: `npm install {package}@latest --prefix ~/.omniroute/services/{name}/`. +4. If behind an air-gap, pre-download the tarball and use `npm install /path/to/tarball.tgz`. + +--- + +### Service shows `"error"` state immediately after start (fast crash) + +**Symptoms:** State transitions from `"starting"` to `"error"` in under 5 seconds. +`lastError` shows `"Fast crash (exited with code 1)"`. + +**Checklist:** + +1. Read the full log tail: `GET /api/services/{name}/logs?tail=500`. +2. Common cause: missing environment variables expected by the service. +3. For 9Router: verify `NINEROUTER_DISABLE_MITM=true` and + `NINEROUTER_DISABLE_TUNNEL=true` are in the env passed at spawn (see + `installers/ninerouter.ts` `resolveSpawnArgs`). + +--- + +## 8. FAQ + +**Q: Can I expose the embedded services endpoints to non-loopback clients?** + +No. The LOCAL_ONLY tier is intentional (hard rule #17). Routes that can invoke +`npm install` or spawn `node` processes must not be reachable from non-loopback +traffic, because a leaked JWT via a tunnel (Cloudflare, Ngrok, Tailscale) would +otherwise allow arbitrary process spawning. There is no opt-out carve-out for +`/api/services/` — unlike `/api/mcp/`, it is excluded from the manage-scope bypass +list. See `docs/security/ROUTE_GUARD_TIERS.md`. + +--- + +**Q: Will 9Router and CLIProxyAPI be available in production/cloud deployments?** + +Yes. Both services follow the same local-first model as OmniRoute itself. They run +on the same machine and communicate over loopback. "Production" here means the VPS +or local server where OmniRoute is deployed, not a remote cloud provider. + +--- + +**Q: How do I debug the supervisor?** + +1. Tail the SSE log stream: `curl -N http://localhost:20128/api/services/9router/logs`. +2. Check structured logs in OmniRoute's pino output filtered by + `service:supervisor` namespace. +3. Inspect the DB row: `sqlite3 ~/.omniroute/omniroute.db "SELECT * FROM version_manager WHERE tool='9router'"`. +4. Use `GET /api/services/9router/status` to see the current live state, PID, health, + and `lastError` in one call. + +--- + +**Q: The supervisor shows `health: "degraded"` or `health: "unknown"` but state is `"running"`. Is that a problem?** + +`"degraded"` means the health probe returned a non-200 response. `"unknown"` means no +probe has completed yet (race with first poll). Both are transient during startup. +If health stays `"degraded"` for more than `healthIntervalMs * 3` ms after +`"running"`, the embedded service is running but its HTTP API is not responding. Check +whether the port is correct in the status response and whether the service is actually +listening on that port. + +--- + +**Q: Can I change the 9Router API key without a full restart?** + +No. The API key is passed to 9Router via an environment variable at spawn time. +Environment variables cannot be changed in a running process. `POST .../rotate-key` +automatically stops and restarts the service to apply the new key. The key rotation +takes effect within the service's `stopTimeoutMs` (default 15 s) plus its startup +time. + +--- + +**Q: What is the ring buffer limit and what happens when it fills?** + +Each service has a dedicated 5 MB ring buffer. When the buffer is full, the oldest +log lines are evicted to make room for new ones. The SSE `snapshot` event returns +the most recent lines within the `tail` limit. Logs are not persisted to disk unless +`logsBufferPath` is set in the DB row. + +--- + +## See also + +- `docs/security/ROUTE_GUARD_TIERS.md` — LOCAL_ONLY tier details +- `docs/architecture/CODEBASE_DOCUMENTATION.md` — §3.2 Embedded Services module mapping +- `docs/architecture/ARCHITECTURE.md` — system-level context +- `docs/reference/openapi.yaml` — machine-readable endpoint definitions +- `CLAUDE.md` §"Adding a New Embedded Service" — quick-reference checklist diff --git a/docs/i18n/ar/CHANGELOG.md b/docs/i18n/ar/CHANGELOG.md index ed8e96b15d22..e4ec1f60e2c7 100644 --- a/docs/i18n/ar/CHANGELOG.md +++ b/docs/i18n/ar/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/az/CHANGELOG.md b/docs/i18n/az/CHANGELOG.md index 4d36609c6d97..61abf8c13885 100644 --- a/docs/i18n/az/CHANGELOG.md +++ b/docs/i18n/az/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/bg/CHANGELOG.md b/docs/i18n/bg/CHANGELOG.md index 4d36609c6d97..61abf8c13885 100644 --- a/docs/i18n/bg/CHANGELOG.md +++ b/docs/i18n/bg/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/bn/CHANGELOG.md b/docs/i18n/bn/CHANGELOG.md index 9a0bf382b0dc..e6870e5f08f2 100644 --- a/docs/i18n/bn/CHANGELOG.md +++ b/docs/i18n/bn/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/cs/CHANGELOG.md b/docs/i18n/cs/CHANGELOG.md index 3d24dd2d034a..78452adfcd92 100644 --- a/docs/i18n/cs/CHANGELOG.md +++ b/docs/i18n/cs/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/da/CHANGELOG.md b/docs/i18n/da/CHANGELOG.md index 662ce9573d4e..5ddaf0f8f3db 100644 --- a/docs/i18n/da/CHANGELOG.md +++ b/docs/i18n/da/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/de/CHANGELOG.md b/docs/i18n/de/CHANGELOG.md index b37a747566ac..a786a8db71e5 100644 --- a/docs/i18n/de/CHANGELOG.md +++ b/docs/i18n/de/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/es/CHANGELOG.md b/docs/i18n/es/CHANGELOG.md index 129c2ce99331..fdc84441d636 100644 --- a/docs/i18n/es/CHANGELOG.md +++ b/docs/i18n/es/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/fa/CHANGELOG.md b/docs/i18n/fa/CHANGELOG.md index 7fc6963516fa..fce3d9a86895 100644 --- a/docs/i18n/fa/CHANGELOG.md +++ b/docs/i18n/fa/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/fi/CHANGELOG.md b/docs/i18n/fi/CHANGELOG.md index 1c394ee86457..33b83a670df4 100644 --- a/docs/i18n/fi/CHANGELOG.md +++ b/docs/i18n/fi/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/fr/CHANGELOG.md b/docs/i18n/fr/CHANGELOG.md index c0f90a047e8a..d25e573e994c 100644 --- a/docs/i18n/fr/CHANGELOG.md +++ b/docs/i18n/fr/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/gu/CHANGELOG.md b/docs/i18n/gu/CHANGELOG.md index 0636e8b85cf6..173e35de5fca 100644 --- a/docs/i18n/gu/CHANGELOG.md +++ b/docs/i18n/gu/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/he/CHANGELOG.md b/docs/i18n/he/CHANGELOG.md index 5e7a38a381ba..14eba4f2ae72 100644 --- a/docs/i18n/he/CHANGELOG.md +++ b/docs/i18n/he/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/hi/CHANGELOG.md b/docs/i18n/hi/CHANGELOG.md index 015f64978b3c..bfa8ad68f6f3 100644 --- a/docs/i18n/hi/CHANGELOG.md +++ b/docs/i18n/hi/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/hu/CHANGELOG.md b/docs/i18n/hu/CHANGELOG.md index bc20263da8df..c0bc76b1a38e 100644 --- a/docs/i18n/hu/CHANGELOG.md +++ b/docs/i18n/hu/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/id/CHANGELOG.md b/docs/i18n/id/CHANGELOG.md index e32a0ea06bf9..b41fd38e06e4 100644 --- a/docs/i18n/id/CHANGELOG.md +++ b/docs/i18n/id/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/in/CHANGELOG.md b/docs/i18n/in/CHANGELOG.md index edfd9e0e3b3c..8eb8afa0d5ed 100644 --- a/docs/i18n/in/CHANGELOG.md +++ b/docs/i18n/in/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/it/CHANGELOG.md b/docs/i18n/it/CHANGELOG.md index fd05f1830b63..000c080ffa50 100644 --- a/docs/i18n/it/CHANGELOG.md +++ b/docs/i18n/it/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/ja/CHANGELOG.md b/docs/i18n/ja/CHANGELOG.md index 9b9b374dbc1c..c3e153579ac0 100644 --- a/docs/i18n/ja/CHANGELOG.md +++ b/docs/i18n/ja/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/ko/CHANGELOG.md b/docs/i18n/ko/CHANGELOG.md index aa2ee9326cba..c2dd0705fc50 100644 --- a/docs/i18n/ko/CHANGELOG.md +++ b/docs/i18n/ko/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/mr/CHANGELOG.md b/docs/i18n/mr/CHANGELOG.md index 648543f607ac..84d6728bbbb7 100644 --- a/docs/i18n/mr/CHANGELOG.md +++ b/docs/i18n/mr/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/ms/CHANGELOG.md b/docs/i18n/ms/CHANGELOG.md index 883f8259fd5e..4e4818db7261 100644 --- a/docs/i18n/ms/CHANGELOG.md +++ b/docs/i18n/ms/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/nl/CHANGELOG.md b/docs/i18n/nl/CHANGELOG.md index 0c5bbeea0476..430c806ccda3 100644 --- a/docs/i18n/nl/CHANGELOG.md +++ b/docs/i18n/nl/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/no/CHANGELOG.md b/docs/i18n/no/CHANGELOG.md index d90b67dbe136..e0d186dd1f5d 100644 --- a/docs/i18n/no/CHANGELOG.md +++ b/docs/i18n/no/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/phi/CHANGELOG.md b/docs/i18n/phi/CHANGELOG.md index ec0f54f4135a..07c38a325318 100644 --- a/docs/i18n/phi/CHANGELOG.md +++ b/docs/i18n/phi/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/pl/CHANGELOG.md b/docs/i18n/pl/CHANGELOG.md index 12a7e140f615..fda50f7d95d4 100644 --- a/docs/i18n/pl/CHANGELOG.md +++ b/docs/i18n/pl/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/pt-BR/CHANGELOG.md b/docs/i18n/pt-BR/CHANGELOG.md index 646d709469c0..6a0c886c7a89 100644 --- a/docs/i18n/pt-BR/CHANGELOG.md +++ b/docs/i18n/pt-BR/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/pt/CHANGELOG.md b/docs/i18n/pt/CHANGELOG.md index d8a0421aeff3..c182f3c7ecd5 100644 --- a/docs/i18n/pt/CHANGELOG.md +++ b/docs/i18n/pt/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/ro/CHANGELOG.md b/docs/i18n/ro/CHANGELOG.md index 899c9053f54b..2ea2c6e1a19d 100644 --- a/docs/i18n/ro/CHANGELOG.md +++ b/docs/i18n/ro/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/ru/CHANGELOG.md b/docs/i18n/ru/CHANGELOG.md index 6005c58bfc56..2dadc9d6babb 100644 --- a/docs/i18n/ru/CHANGELOG.md +++ b/docs/i18n/ru/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/sk/CHANGELOG.md b/docs/i18n/sk/CHANGELOG.md index 53e2810c88a2..22e8618918fc 100644 --- a/docs/i18n/sk/CHANGELOG.md +++ b/docs/i18n/sk/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/sv/CHANGELOG.md b/docs/i18n/sv/CHANGELOG.md index 6f63031338d7..ac1b9ab37efd 100644 --- a/docs/i18n/sv/CHANGELOG.md +++ b/docs/i18n/sv/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/sw/CHANGELOG.md b/docs/i18n/sw/CHANGELOG.md index ad86a7ad16ec..4e06d1be6ab1 100644 --- a/docs/i18n/sw/CHANGELOG.md +++ b/docs/i18n/sw/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/ta/CHANGELOG.md b/docs/i18n/ta/CHANGELOG.md index 354a06276528..710f8ed939fe 100644 --- a/docs/i18n/ta/CHANGELOG.md +++ b/docs/i18n/ta/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/te/CHANGELOG.md b/docs/i18n/te/CHANGELOG.md index a9e2f816ed68..9ff6b41223c4 100644 --- a/docs/i18n/te/CHANGELOG.md +++ b/docs/i18n/te/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/th/CHANGELOG.md b/docs/i18n/th/CHANGELOG.md index a886e831293d..e37988d07b44 100644 --- a/docs/i18n/th/CHANGELOG.md +++ b/docs/i18n/th/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/tr/CHANGELOG.md b/docs/i18n/tr/CHANGELOG.md index 258f91bc2d52..f86f6ad2a5d6 100644 --- a/docs/i18n/tr/CHANGELOG.md +++ b/docs/i18n/tr/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/uk-UA/CHANGELOG.md b/docs/i18n/uk-UA/CHANGELOG.md index 6a37651b53cb..42423784ec87 100644 --- a/docs/i18n/uk-UA/CHANGELOG.md +++ b/docs/i18n/uk-UA/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/ur/CHANGELOG.md b/docs/i18n/ur/CHANGELOG.md index cb617c7add03..4f8dbabb437f 100644 --- a/docs/i18n/ur/CHANGELOG.md +++ b/docs/i18n/ur/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/vi/CHANGELOG.md b/docs/i18n/vi/CHANGELOG.md index 8022f2240fae..332c6f326c3c 100644 --- a/docs/i18n/vi/CHANGELOG.md +++ b/docs/i18n/vi/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/i18n/zh-CN/CHANGELOG.md b/docs/i18n/zh-CN/CHANGELOG.md index 98fbba90ed76..3ff82eb4113e 100644 --- a/docs/i18n/zh-CN/CHANGELOG.md +++ b/docs/i18n/zh-CN/CHANGELOG.md @@ -12,13 +12,11 @@ --- -## [3.8.4] — 2026-05-24 +## [3.8.4] — 2026-05-25 -### ✨ New Features - -- **feat(providers):** enhance Google Gemini, CLI, and Antigravity resilience and features — introduces explicit TypeScript typing to translation layers, adds new Gemini 2.0 models, implements backoff and retry logic in the Gemini CLI executor, extracts Google Search grounding metadata into standard `citations`, and adds backend definitions for the `vertex-partner` provider. ([#2676](https://github.com/diegosouzapw/OmniRoute/pull/2676) — thanks @alltomatos) +### Added -### 🔧 Bug Fixes +- Embedded services (work in progress — 9Router, CLIProxyAPI; see T-15 for full entry). --- diff --git a/docs/ops/RELEASE_CHECKLIST.md b/docs/ops/RELEASE_CHECKLIST.md index c923a20aa718..e10cd83078a9 100644 --- a/docs/ops/RELEASE_CHECKLIST.md +++ b/docs/ops/RELEASE_CHECKLIST.md @@ -188,6 +188,36 @@ If `electron/` changed: - [ ] Open milestone for next version - [ ] If critical: pin discussion or post in `news.json` for in-app banner +## Embedded Services smoke (v3.8.4+) + +Before shipping any release that includes embedded services changes, verify: + +### 9Router + +- [ ] `POST /api/services/9router/install` returns 200 with `installedVersion` in under 2 min +- [ ] `POST /api/services/9router/start` returns 200 and `state: "running"` in under 30 s +- [ ] `GET /api/services/9router/status` reports `health: "healthy"` +- [ ] `POST /v1/chat/completions` with `"model": "9router/auto/..."` returns 200 (end-to-end routing through 9Router) +- [ ] `GET /dashboard/providers/services/9router/embed/dashboard` renders the 9Router native UI inside the proxy (no direct `127.0.0.1:port` iframe) +- [ ] `POST /api/services/9router/rotate-key` returns `{ keyRotated: true }` and service restarts cleanly +- [ ] `POST /api/services/9router/stop` returns 200 and `state: "stopped"` +- [ ] `GET /api/services/9router/logs?tail=50` returns SSE stream with `snapshot` event containing recent lines +- [ ] Install in environment without `npm` in PATH returns 500 with a friendly (non-stack-trace) error message + +### CLIProxyAPI + +- [ ] `POST /api/services/cliproxy/install` returns 200 in under 2 min +- [ ] `POST /api/services/cliproxy/start` returns 200 and `state: "running"` in under 30 s +- [ ] `GET /api/services/cliproxy/status` reports `health: "healthy"` +- [ ] `POST /api/services/cliproxy/stop` returns 200 and `state: "stopped"` +- [ ] `GET /api/services/cliproxy/logs?tail=50` returns SSE stream + +### Security regression + +- [ ] `curl -H "X-Forwarded-For: 1.2.3.4" http://localhost:20128/api/services/9router/start` returns `403 LOCAL_ONLY` +- [ ] `curl -H "X-Forwarded-For: 1.2.3.4" http://localhost:20128/api/services/cliproxy/start` returns `403 LOCAL_ONLY` +- [ ] Error responses from `/api/services/*` do not contain `err.stack` or absolute file paths + ## v3.8.0+ checks Before shipping any v3.8.x release, verify these additional items: diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 18ca1f3083be..78eceae7c982 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -660,6 +660,10 @@ Automatic model pricing data synchronization from external sources. | `SEARCH_CACHE_TTL_MS` | `300000` (5 min) | `open-sse/services/searchCache.ts` | TTL for search API (Perplexity, Brave, etc.) response caching. | | `ALLOW_MULTI_CONNECTIONS_PER_COMPAT_NODE` | `false` | `src/app/api/providers/route.ts` | Allow multiple simultaneous connections per OpenAI-compatible provider. | | `ENABLE_CC_COMPATIBLE_PROVIDER` | `false` | `src/shared/utils/featureFlags.ts` | Reveal the experimental CC-compatible provider UI for Claude Code-only relays. | +| `NINEROUTER_HOST` | `127.0.0.1` | `open-sse/executors/ninerouter.ts` | Override the host where the embedded 9router instance listens. | +| `NINEROUTER_PORT` | `20130` | `open-sse/executors/ninerouter.ts` | Override the port where the embedded 9router instance listens. | +| `EMBED_WS_PROXY_HOST` | `127.0.0.1` | `src/lib/services/embedWsProxy.ts` | Bind host for the embedded-service WebSocket proxy (loopback only by default). | +| `EMBED_WS_PROXY_PORT` | `20131` | `src/lib/services/embedWsProxy.ts` | Port for the embedded-service WebSocket proxy server. | | `CLIPROXYAPI_HOST` | `127.0.0.1` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge host (legacy integration). | | `CLIPROXYAPI_PORT` | `5544` | `open-sse/executors/cliproxyapi.ts` | CLIProxyAPI bridge port. | | `CLIPROXYAPI_CONFIG_DIR` | `~/.cli-proxy-api` | `src/lib/versionManager/processManager.ts` | CLIProxyAPI config directory. | diff --git a/docs/reference/openapi.yaml b/docs/reference/openapi.yaml index 0fb3542328af..64ecbdcdc61c 100644 --- a/docs/reference/openapi.yaml +++ b/docs/reference/openapi.yaml @@ -22,20 +22,6 @@ servers: - url: http://localhost:20128 description: Local development -# Vendor extensions — OmniRoute security tier annotations (OpenAPI 3.x compatible) -x-tier-definitions: - loopback-only: - description: "Route is enforced as loopback-only (127.0.0.1, ::1) regardless of auth. - Cannot be authenticated remotely. Spawn-capable routes are classified here. - Reference: docs/security/ROUTE_GUARD_TIERS.md" - always-protected: - description: "Route requires authentication unconditionally, even when REQUIRE_API_KEY=false. - Destructive/irreversible operations are classified here. - Reference: docs/security/ROUTE_GUARD_TIERS.md" - internal: - description: "Internal route not part of the public API surface. - Hidden from /dashboard/api-endpoints by default." - tags: - name: Chat description: OpenAI-compatible chat completions @@ -73,6 +59,10 @@ tags: description: Format translation debug & testing - name: CLI Tools description: CLI tool configuration management + - name: Embedded Services + description: >- + Install, start, stop, and monitor locally-running embedded services (9Router, CLIProxyAPI). + All routes are LOCAL_ONLY — accessible from loopback only (hard rule #17). - name: OAuth description: OAuth flows for provider authentication - name: System @@ -85,30 +75,6 @@ tags: description: Fallback chain management - name: Telemetry description: Telemetry and token health monitoring - - name: MCP - description: Model Context Protocol server (LOCAL_ONLY — loopback access only) - - name: Webhooks - description: Webhook endpoint management and testing - - name: Tunnels - description: Tunnel (ngrok, Cloudflare, Tailscale) management - - name: Skills - description: Skill framework — install, list, execute - - name: Memory - description: Persistent conversational memory (FTS5 + Qdrant) - - name: Logs - description: Application and request log access - - name: Version Manager - description: Version check and update management - - name: Analytics - description: Internal analytics and routing statistics - - name: A2A - description: Agent-to-Agent JSON-RPC 2.0 protocol - - name: Evals - description: Evaluation suites - - name: Files - description: File uploads and management - - name: Batches - description: Batch request processing paths: # ─── Proxy Endpoints ────────────────────────────────────────── @@ -237,47 +203,6 @@ paths: "401": $ref: "#/components/responses/Unauthorized" - /api/v1/responses/{...path}: - get: - tags: [Responses] - summary: Responses API catch-all (GET) - description: Generic Responses API passthrough for sub-paths. - parameters: - - name: path - in: path - required: true - schema: - type: string - security: - - BearerAuth: [] - responses: - "200": - description: Responses API result - "401": - $ref: "#/components/responses/Unauthorized" - post: - tags: [Responses] - summary: Responses API catch-all (POST) - parameters: - - name: path - in: path - required: true - schema: - type: string - security: - - BearerAuth: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - responses: - "200": - description: Responses API result - "401": - $ref: "#/components/responses/Unauthorized" - /api/v1/embeddings: post: tags: [Embeddings] @@ -1259,91 +1184,6 @@ paths: "200": description: Updated thinking budget configuration - /api/settings/database: - get: - tags: [Settings] - summary: Get database settings - description: Returns SQLite database configuration (WAL mode, journal, paths). - x-always-protected: true - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Database settings - "401": - $ref: "#/components/responses/Unauthorized" - patch: - tags: [Settings] - summary: Update database settings - description: Updates SQLite WAL or journal mode settings. Requires restart to take effect. - x-always-protected: true - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Settings updated - "401": - $ref: "#/components/responses/Unauthorized" - put: - tags: [Settings] - summary: Update database settings (alias for PATCH) - description: >- - Alias for PATCH /api/settings/database. Updates SQLite WAL or journal mode - settings. Requires restart to take effect. - x-always-protected: true - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Settings updated - "401": - $ref: "#/components/responses/Unauthorized" - - /api/settings/database/refresh-stats: - post: - tags: [Settings] - summary: Refresh database statistics - description: Runs ANALYZE to refresh SQLite query planner statistics. - x-always-protected: true - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Stats refreshed - "401": - $ref: "#/components/responses/Unauthorized" - - /api/settings/database/vacuum: - post: - tags: [Settings] - summary: Vacuum database - description: Runs VACUUM FULL on the SQLite database to reclaim disk space. Destructive — cannot be interrupted. - x-always-protected: true - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Vacuum completed - "401": - $ref: "#/components/responses/Unauthorized" - /api/rate-limit: get: tags: [Settings] @@ -1617,11 +1457,6 @@ paths: get: tags: [CLI Tools] summary: Get runtime status for a CLI tool - description: >- - Gets runtime status for a CLI tool. - LOCAL_ONLY — spawn-capable route, blocked unconditionally from non-loopback. - CVE class: GHSA-fhh6-4qxv-rpqj. - x-loopback-only: true parameters: - name: toolId in: path @@ -1631,8 +1466,6 @@ paths: responses: "200": description: Runtime status - "403": - description: Forbidden — request not from loopback /api/cli-tools/guide-settings/{toolId}: get: @@ -1888,400 +1721,410 @@ paths: "200": description: OpenClaw CLI settings reset - /api/cli-tools/apply: + # ─── Embedded Services ───────────────────────────────────────── + # All routes LOCAL_ONLY (loopback only) — hard rule #17. + # See docs/frameworks/EMBEDDED-SERVICES.md for full reference. + + /api/services/9router/install: post: - tags: [CLI Tools] - summary: Apply CLI tool configuration - x-internal: true - security: - - BearerAuth: [] + tags: [Embedded Services] + summary: Install 9Router from npm + description: >- + Installs the `9router` npm package under DATA_DIR/services/9router/. + Uses execFile (no shell interpolation — hard rule #13). + **LOCAL_ONLY** — loopback only. requestBody: - required: true + required: false content: application/json: schema: type: object + properties: + version: + type: string + default: latest + description: npm version tag or semver to install responses: "200": - description: Configuration applied - "401": - $ref: "#/components/responses/Unauthorized" + description: Install succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + path: + type: string + "400": + description: Invalid request body + "500": + description: npm install failed - /api/cli-tools/config: - get: - tags: [CLI Tools] - summary: Get CLI tools global configuration - x-internal: true - security: - - BearerAuth: [] - responses: - "200": - description: CLI tools configuration - "401": - $ref: "#/components/responses/Unauthorized" + /api/services/9router/start: post: - tags: [CLI Tools] - summary: Update CLI tools global configuration - x-internal: true - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object + tags: [Embedded Services] + summary: Start 9Router + description: >- + Spawns the 9Router process. Idempotent if already running. + **LOCAL_ONLY** — loopback only. responses: "200": - description: Configuration updated - "401": - $ref: "#/components/responses/Unauthorized" + description: Service started (or already running) + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + "409": + description: 9Router is not installed + "503": + description: Start failed - /api/cli-tools/detect: - get: - tags: [CLI Tools] - summary: Detect locally installed CLI tools - description: Scans the local system and returns a map of detected CLI tools and their versions. - security: - - BearerAuth: [] + /api/services/9router/stop: + post: + tags: [Embedded Services] + summary: Stop 9Router + description: >- + Gracefully stops 9Router (SIGTERM → 15 s → SIGKILL). Idempotent. + **LOCAL_ONLY** — loopback only. responses: "200": - description: Map of detected tools with version info - "401": - $ref: "#/components/responses/Unauthorized" + description: Service stopped + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + "503": + description: Stop failed - /api/cli-tools/hermes-agent-settings: - get: - tags: [CLI Tools] - summary: Get Hermes agent CLI settings - x-internal: true - security: - - BearerAuth: [] + /api/services/9router/restart: + post: + tags: [Embedded Services] + summary: Restart 9Router + description: >- + Equivalent to stop() then start() under the operation lock. + **LOCAL_ONLY** — loopback only. responses: "200": - description: Hermes agent CLI configuration - "401": - $ref: "#/components/responses/Unauthorized" + description: Service restarted + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/9router/update: post: - tags: [CLI Tools] - summary: Apply Hermes agent CLI settings - x-internal: true - security: - - BearerAuth: [] + tags: [Embedded Services] + summary: Update 9Router to a newer npm version + description: >- + Stops the service (if running), installs the newer npm version, then restarts. + **LOCAL_ONLY** — loopback only. requestBody: - required: true + required: false content: application/json: schema: type: object + properties: + version: + type: string + default: latest responses: "200": - description: Hermes agent CLI settings applied - "401": - $ref: "#/components/responses/Unauthorized" + description: Update succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + previousVersion: + type: string + installedVersion: + type: string + "400": + description: Invalid request body + "500": + description: Update failed - /api/cli-tools/keys: - get: - tags: [CLI Tools] - summary: Get CLI tool API keys - x-internal: true - security: - - BearerAuth: [] - responses: - "200": - description: CLI tool API key configuration - "401": - $ref: "#/components/responses/Unauthorized" + /api/services/9router/rotate-key: post: - tags: [CLI Tools] - summary: Set CLI tool API keys - x-internal: true - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object + tags: [Embedded Services] + summary: Rotate the 9Router API key + description: >- + Generates a new API key, encrypts it at-rest, and restarts the service to + apply it. The plaintext key is never returned. + **LOCAL_ONLY** — loopback only. responses: "200": - description: CLI tool API keys updated - "401": - $ref: "#/components/responses/Unauthorized" + description: Key rotated + content: + application/json: + schema: + type: object + properties: + keyRotated: + type: boolean + restarted: + type: boolean + "500": + description: Rotation failed - /api/cli-tools/openclaw/auto-order: + /api/services/9router/status: get: - tags: [CLI Tools] - summary: Get OpenClaw auto-order configuration - x-internal: true - security: - - BearerAuth: [] + tags: [Embedded Services] + summary: Get 9Router status + description: >- + Returns combined live supervisor state and DB metadata. + **LOCAL_ONLY** — loopback only. responses: "200": - description: OpenClaw auto-order configuration - "401": - $ref: "#/components/responses/Unauthorized" + description: Status response + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatusExtended" + "500": + description: Status read failed + + /api/services/9router/auto-start: post: - tags: [CLI Tools] - summary: Update OpenClaw auto-order configuration - x-internal: true - security: - - BearerAuth: [] + tags: [Embedded Services] + summary: Toggle 9Router auto-start + description: >- + When enabled, 9Router starts automatically on the next OmniRoute boot. + **LOCAL_ONLY** — loopback only. requestBody: required: true content: application/json: schema: type: object + required: [enabled] + properties: + enabled: + type: boolean responses: "200": - description: OpenClaw auto-order configuration updated - "401": - $ref: "#/components/responses/Unauthorized" + description: Auto-start flag updated + content: + application/json: + schema: + type: object + properties: + autoStart: + type: boolean + "400": + description: Invalid request body - /api/cli-tools/qwen-settings: - get: - tags: [CLI Tools] - summary: Get Qwen CLI settings - x-internal: true - security: - - BearerAuth: [] - responses: - "200": - description: Qwen CLI configuration - "401": - $ref: "#/components/responses/Unauthorized" + /api/services/cliproxy/install: post: - tags: [CLI Tools] - summary: Apply Qwen CLI settings - x-internal: true - security: - - BearerAuth: [] + tags: [Embedded Services] + summary: Install CLIProxyAPI from npm + description: >- + Installs the CLIProxyAPI package under DATA_DIR/services/cliproxy/. + **LOCAL_ONLY** — loopback only. requestBody: - required: true + required: false content: application/json: schema: type: object + properties: + version: + type: string + default: latest responses: "200": - description: Qwen CLI settings applied - "401": - $ref: "#/components/responses/Unauthorized" - - /api/cli-tools/status: - get: - tags: [CLI Tools] - summary: CLI tools service status - description: Returns health and status of the CLI tools subsystem. - security: - - BearerAuth: [] + description: Install succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "400": + description: Invalid request body + "500": + description: npm install failed + + /api/services/cliproxy/start: + post: + tags: [Embedded Services] + summary: Start CLIProxyAPI + description: >- + Spawns the CLIProxyAPI process. Idempotent if already running. + **LOCAL_ONLY** — loopback only. responses: "200": - description: CLI tools status - "401": - $ref: "#/components/responses/Unauthorized" + description: Service started + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + "409": + description: CLIProxyAPI is not installed + "503": + description: Start failed - /api/upstream-proxy/{providerId}: - get: - tags: [Internal] - summary: Upstream proxy passthrough (GET) - description: Proxies GET requests to the upstream provider. Internal use only. - x-internal: true - parameters: - - name: providerId - in: path - required: true - schema: - type: string - security: - - BearerAuth: [] + /api/services/cliproxy/stop: + post: + tags: [Embedded Services] + summary: Stop CLIProxyAPI + description: >- + Gracefully stops CLIProxyAPI. Idempotent. + **LOCAL_ONLY** — loopback only. responses: "200": - description: Proxied response from upstream provider - "401": - $ref: "#/components/responses/Unauthorized" + description: Service stopped + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/cliproxy/restart: post: - tags: [Internal] - summary: Upstream proxy passthrough (POST) - x-internal: true - parameters: - - name: providerId - in: path - required: true - schema: - type: string - security: - - BearerAuth: [] - requestBody: - required: false - content: - application/json: - schema: - type: object + tags: [Embedded Services] + summary: Restart CLIProxyAPI + description: >- + stop() then start() under the operation lock. + **LOCAL_ONLY** — loopback only. responses: "200": - description: Proxied response from upstream provider - "401": - $ref: "#/components/responses/Unauthorized" - put: - tags: [Internal] - summary: Upstream proxy passthrough (PUT) - x-internal: true - parameters: - - name: providerId - in: path - required: true - schema: - type: string - security: - - BearerAuth: [] + description: Service restarted + content: + application/json: + schema: + $ref: "#/components/schemas/ServiceStatus" + + /api/services/cliproxy/update: + post: + tags: [Embedded Services] + summary: Update CLIProxyAPI to a newer npm version + description: >- + Stops, installs newer version, restarts. + **LOCAL_ONLY** — loopback only. requestBody: required: false content: application/json: schema: type: object + properties: + version: + type: string + default: latest responses: "200": - description: Proxied response from upstream provider - "401": - $ref: "#/components/responses/Unauthorized" - delete: - tags: [Internal] - summary: Upstream proxy passthrough (DELETE) - x-internal: true - parameters: - - name: providerId - in: path - required: true - schema: - type: string - security: - - BearerAuth: [] - responses: - "200": - description: Proxied response from upstream provider - "401": - $ref: "#/components/responses/Unauthorized" - - # ─── MCP Server (LOCAL_ONLY — spawn-capable) ───────────────────── - # These routes start/control the MCP server subprocess. They are - # enforced as loopback-only by routeGuard.ts regardless of auth state. - # CVE class: GHSA-fhh6-4qxv-rpqj — never expose to non-loopback traffic. + description: Update succeeded + content: + application/json: + schema: + type: object + properties: + ok: + type: boolean + installedVersion: + type: string + "500": + description: Update failed - /api/mcp/sse: + /api/services/cliproxy/status: get: - tags: [MCP] - summary: MCP Server-Sent Events stream + tags: [Embedded Services] + summary: Get CLIProxyAPI status description: >- - SSE transport for the MCP server. Streams tool call results and events. - LOCAL_ONLY — blocked unconditionally from non-loopback IPs. - x-loopback-only: true - security: - - BearerAuth: [] + Returns live supervisor state and DB metadata (no apiKeyMasked — CLIProxyAPI + does not use an injected API key). + **LOCAL_ONLY** — loopback only. responses: "200": - description: SSE event stream + description: Status response content: - text/event-stream: + application/json: schema: - type: string - "403": - description: Forbidden — request not from loopback + $ref: "#/components/schemas/ServiceStatus" - /api/mcp/stream: - get: - tags: [MCP] - summary: MCP HTTP streaming transport - description: >- - HTTP streaming (chunked) transport for the MCP server. - LOCAL_ONLY — blocked unconditionally from non-loopback IPs. - x-loopback-only: true - security: - - BearerAuth: [] - responses: - "200": - description: Chunked stream - "403": - description: Forbidden — request not from loopback + /api/services/cliproxy/auto-start: post: - tags: [MCP] - summary: MCP HTTP streaming transport (POST) + tags: [Embedded Services] + summary: Toggle CLIProxyAPI auto-start description: >- - HTTP streaming (chunked) transport for the MCP server — POST variant. - LOCAL_ONLY — blocked unconditionally from non-loopback IPs. - x-loopback-only: true - security: - - BearerAuth: [] + When enabled, CLIProxyAPI starts automatically on the next OmniRoute boot. + **LOCAL_ONLY** — loopback only. requestBody: - required: false + required: true content: application/json: schema: type: object + required: [enabled] + properties: + enabled: + type: boolean responses: "200": - description: MCP response - "403": - description: Forbidden — request not from loopback - - /api/mcp/status: - get: - tags: [MCP] - summary: MCP server status - description: Returns running/stopped state of the MCP server process. LOCAL_ONLY. - x-loopback-only: true - security: - - BearerAuth: [] - responses: - "200": - description: MCP server status object - "403": - description: Forbidden — request not from loopback - - /api/mcp/tools: - get: - tags: [MCP] - summary: List MCP tools - description: Returns the registered tool manifest. LOCAL_ONLY. - x-loopback-only: true - security: - - BearerAuth: [] - responses: - "200": - description: Tool list - "403": - description: Forbidden — request not from loopback - - /api/mcp/audit: - get: - tags: [MCP] - summary: MCP audit log - description: Returns recent MCP tool invocation log entries. LOCAL_ONLY. - x-loopback-only: true - security: - - BearerAuth: [] - responses: - "200": - description: Audit log entries - "403": - description: Forbidden — request not from loopback + description: Auto-start flag updated + content: + application/json: + schema: + type: object + properties: + autoStart: + type: boolean + "400": + description: Invalid request body - /api/mcp/audit/stats: + /api/services/{name}/logs: get: - tags: [MCP] - summary: MCP audit statistics - description: Returns aggregate MCP tool usage statistics. LOCAL_ONLY. - x-loopback-only: true - security: - - BearerAuth: [] + tags: [Embedded Services] + summary: Stream service logs via SSE + description: >- + Returns a Server-Sent Events stream from the service's in-memory ring buffer + (5 MB, circular). Sends a `snapshot` event with historical lines first, then + live `log` events, plus a `heartbeat` every 15 s. + **LOCAL_ONLY** — loopback only. + parameters: + - name: name + in: path + required: true + schema: + type: string + enum: [9router, cliproxy] + - name: tail + in: query + schema: + type: integer + default: 200 + maximum: 1000 + description: Number of historical lines to include in the initial snapshot + - name: filter + in: query + schema: + type: string + maxLength: 200 + description: >- + Case-insensitive substring filter applied to log lines. + No regex — ReDoS-safe by design. responses: "200": - description: Audit stats - "403": - description: Forbidden — request not from loopback + description: SSE log stream + content: + text/event-stream: + schema: + type: string + description: >- + Events: `snapshot` (LogLine[]), `log` (LogLine), `heartbeat` ({}) + "400": + description: filter parameter exceeds maximum length + "404": + description: Service not found # ─── OAuth ───────────────────────────────────────────────────── @@ -2511,3934 +2354,214 @@ paths: "200": description: Fallback chain deleted - # ─── Webhooks ───────────────────────────────────────────────────── + # ─── System ──────────────────────────────────────────────────── - /api/webhooks: - get: - tags: [Webhooks] - summary: List webhooks - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Webhook list - "401": - $ref: "#/components/responses/Unauthorized" + /api/auth/login: post: - tags: [Webhooks] - summary: Create webhook - security: - - BearerAuth: [] - - ManagementSessionAuth: [] + tags: [System] + summary: Authenticate user requestBody: required: true content: application/json: schema: type: object + required: [password] properties: - url: - type: string - format: uri - events: - type: array - items: - type: string - secret: + password: type: string responses: - "201": - description: Webhook created + "200": + description: JWT token returned "401": - $ref: "#/components/responses/Unauthorized" + description: Invalid password - /api/webhooks/{id}: - get: - tags: [Webhooks] - summary: Get webhook - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string + /api/auth/logout: + post: + tags: [System] + summary: Log out responses: "200": - description: Webhook details - "404": - description: Not found - "401": - $ref: "#/components/responses/Unauthorized" - patch: - tags: [Webhooks] - summary: Update webhook - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object + description: Session cleared + + /api/init: + get: + tags: [System] + summary: Initialize application responses: "200": - description: Webhook updated - "401": - $ref: "#/components/responses/Unauthorized" - delete: - tags: [Webhooks] - summary: Delete webhook - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string + description: Init status + + /api/restart: + post: + tags: [System] + summary: Restart the application responses: "200": - description: Webhook deleted - "401": - $ref: "#/components/responses/Unauthorized" + description: Restart initiated - /api/webhooks/{id}/test: + /api/shutdown: post: - tags: [Webhooks] - summary: Test webhook delivery - description: Sends a test event payload to the webhook URL. - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string + tags: [System] + summary: Shutdown the application responses: "200": - description: Test delivery result - "401": - $ref: "#/components/responses/Unauthorized" - - # ─── Tunnels ────────────────────────────────────────────────────── + description: Shutdown initiated - /api/tunnels/ngrok: + /api/db-backups: get: - tags: [Tunnels] - summary: Get ngrok tunnel status - security: - - BearerAuth: [] - - ManagementSessionAuth: [] + tags: [System] + summary: List database backups responses: "200": - description: ngrok tunnel status - "401": - $ref: "#/components/responses/Unauthorized" + description: Backup list post: - tags: [Tunnels] - summary: Start/configure ngrok tunnel - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - responses: - "200": - description: Tunnel started - "401": - $ref: "#/components/responses/Unauthorized" - delete: - tags: [Tunnels] - summary: Stop ngrok tunnel - security: - - BearerAuth: [] - - ManagementSessionAuth: [] + tags: [System] + summary: Create database backup responses: "200": - description: Tunnel stopped + description: Backup created - /api/tunnels/cloudflared: + /api/storage/health: get: - tags: [Tunnels] - summary: Get Cloudflare Tunnel status - security: - - BearerAuth: [] - - ManagementSessionAuth: [] + tags: [System] + summary: Check storage health responses: "200": - description: Cloudflare tunnel status - "401": - $ref: "#/components/responses/Unauthorized" + description: Storage health status + + /api/sync/cloud: post: - tags: [Tunnels] - summary: Start/configure Cloudflare Tunnel - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - responses: - "200": - description: Tunnel started - delete: - tags: [Tunnels] - summary: Stop Cloudflare Tunnel - security: - - BearerAuth: [] - - ManagementSessionAuth: [] + tags: [System] + summary: Sync with cloud responses: "200": - description: Tunnel stopped + description: Sync result - /api/tunnels/tailscale: - get: - tags: [Tunnels] - summary: Get Tailscale status - security: - - BearerAuth: [] - - ManagementSessionAuth: [] + /api/sync/initialize: + post: + tags: [System] + summary: Initialize cloud sync responses: "200": - description: Tailscale status - "401": - $ref: "#/components/responses/Unauthorized" + description: Sync initialized - /api/tunnels/tailscale/install: - post: - tags: [Tunnels] - summary: Install Tailscale - security: - - BearerAuth: [] - - ManagementSessionAuth: [] + # ─── Resilience & Monitoring ──────────────────────────────────── + + /api/resilience: + get: + tags: [System] + summary: Get resilience configuration responses: "200": - description: Install initiated - - /api/tunnels/tailscale/login: - post: - tags: [Tunnels] - summary: Tailscale login / auth key - security: - - BearerAuth: [] - - ManagementSessionAuth: [] + description: Request queue, connection cooldown, provider breaker, and wait settings + patch: + tags: [System] + summary: Update resilience configuration requestBody: - required: false + required: true content: application/json: schema: type: object - properties: - authKey: - type: string - responses: - "200": - description: Login result - - /api/tunnels/tailscale/start-daemon: - post: - tags: [Tunnels] - summary: Start Tailscale daemon - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Daemon started - - /api/tunnels/tailscale/enable: - post: - tags: [Tunnels] - summary: Enable Tailscale routing - security: - - BearerAuth: [] - - ManagementSessionAuth: [] responses: "200": - description: Routing enabled + description: Updated resilience configuration - /api/tunnels/tailscale/disable: + /api/resilience/reset: post: - tags: [Tunnels] - summary: Disable Tailscale routing - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Routing disabled - - /api/tunnels/tailscale/check: - get: - tags: [Tunnels] - summary: Check Tailscale connectivity - security: - - BearerAuth: [] - - ManagementSessionAuth: [] + tags: [System] + summary: Reset circuit breakers responses: "200": - description: Connectivity check result - - # ─── Skills ─────────────────────────────────────────────────────── + description: Circuit breakers reset - /api/skills: + /api/monitoring/health: get: - tags: [Skills] - summary: List installed skills - security: - - BearerAuth: [] - - ManagementSessionAuth: [] + tags: [System] + summary: System health check + description: Returns system health including uptime, memory, circuit breakers, rate limits responses: "200": - description: Skill list - "401": - $ref: "#/components/responses/Unauthorized" + description: Health status - /api/skills/{id}: + /api/rate-limits: get: - tags: [Skills] - summary: Get skill details - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Skill details - "404": - description: Skill not found - "401": - $ref: "#/components/responses/Unauthorized" - delete: - tags: [Skills] - summary: Uninstall skill - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string + tags: [System] + summary: Get per-account rate limit status responses: "200": - description: Skill uninstalled - - /api/skills/install: - post: - tags: [Skills] - summary: Install skill from ZIP or URL - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - source: - type: string - description: URL or base64 ZIP - responses: - "201": - description: Skill installed - "401": - $ref: "#/components/responses/Unauthorized" + description: Rate limit status by account - /api/skills/marketplace: + /api/sessions: get: - tags: [Skills] - summary: Browse skill marketplace - security: - - BearerAuth: [] - - ManagementSessionAuth: [] + tags: [System] + summary: Get active sessions responses: "200": - description: Available skills - - /api/skills/marketplace/install: - post: - tags: [Skills] - summary: Install skill from marketplace - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - skillId: - type: string - responses: - "201": - description: Skill installed + description: Active session list - /api/skills/executions: + /api/cache: get: - tags: [Skills] - summary: List skill execution history - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Execution history - - # ─── System ──────────────────────────────────────────────────── - - /api/auth/login: - post: tags: [System] - summary: Authenticate user - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [password] - properties: - password: - type: string + summary: Get cache statistics responses: "200": - description: JWT token returned - "401": - description: Invalid password - - /api/auth/logout: - post: + description: Semantic cache and idempotency stats + delete: tags: [System] - summary: Log out + summary: Clear all caches responses: "200": - description: Session cleared + description: Caches cleared - /api/init: + /api/cache/stats: get: tags: [System] - summary: Initialize application + summary: Get detailed cache statistics + description: Returns detailed statistics for all cache layers. responses: "200": - description: Init status - - /api/restart: - post: + description: Detailed cache stats + delete: tags: [System] - summary: Restart the application + summary: Clear cache statistics responses: "200": - description: Restart initiated - - /api/shutdown: - post: - tags: [System] - summary: Shutdown the OmniRoute service - description: >- - Initiates a graceful shutdown. Always requires authentication regardless of - the `requireLogin` setting — classified as ALWAYS_PROTECTED in routeGuard.ts. - x-always-protected: true - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "202": - description: Shutdown initiated - "401": - $ref: "#/components/responses/Unauthorized" - - /api/db-backups: - get: - tags: [System] - summary: List database backups - responses: - "200": - description: Backup list - post: - tags: [System] - summary: Create database backup - responses: - "200": - description: Backup created - - /api/db-backups/export: - post: - tags: [System] - summary: Export database to file - description: Creates a snapshot export of the SQLite database. - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Export file path or download - "401": - $ref: "#/components/responses/Unauthorized" - - /api/db-backups/exportAll: - post: - tags: [System] - summary: Export all data to ZIP - description: Exports the full database including attachments to a ZIP archive. - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: ZIP archive - "401": - $ref: "#/components/responses/Unauthorized" - - /api/db-backups/import: - post: - tags: [System] - summary: Import database from backup - description: >- - Restores database from a backup file. Destructive — overwrites current data. - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - properties: - file: - type: string - format: binary - responses: - "200": - description: Import successful - "401": - $ref: "#/components/responses/Unauthorized" - - /api/storage/health: - get: - tags: [System] - summary: Check storage health - responses: - "200": - description: Storage health status - - /api/sync/cloud: - post: - tags: [System] - summary: Sync with cloud - responses: - "200": - description: Sync result - - /api/sync/initialize: - post: - tags: [System] - summary: Initialize cloud sync - responses: - "200": - description: Sync initialized - - # ─── Resilience & Monitoring ──────────────────────────────────── - - /api/resilience: - get: - tags: [System] - summary: Get resilience configuration - responses: - "200": - description: Request queue, connection cooldown, provider breaker, and wait settings - patch: - tags: [System] - summary: Update resilience configuration - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Updated resilience configuration - - /api/resilience/reset: - post: - tags: [System] - summary: Reset circuit breakers - responses: - "200": - description: Circuit breakers reset - - /api/monitoring/health: - get: - tags: [System] - summary: System health check - description: Returns system health including uptime, memory, circuit breakers, rate limits - responses: - "200": - description: Health status - - /api/rate-limits: - get: - tags: [System] - summary: Get per-account rate limit status - responses: - "200": - description: Rate limit status by account - - /api/sessions: - get: - tags: [System] - summary: Get active sessions - responses: - "200": - description: Active session list - - /api/cache: - get: - tags: [System] - summary: Get cache statistics - responses: - "200": - description: Semantic cache and idempotency stats - delete: - tags: [System] - summary: Clear all caches - responses: - "200": - description: Caches cleared - - /api/cache/stats: - get: - tags: [System] - summary: Get detailed cache statistics - description: Returns detailed statistics for all cache layers. - responses: - "200": - description: Detailed cache stats - delete: - tags: [System] - summary: Clear cache statistics - responses: - "200": - description: Cache stats cleared + description: Cache stats cleared # ─── Telemetry & Token Health ─────────────────────────────────── - /api/telemetry/summary: - get: - tags: [Telemetry] - summary: Get telemetry summary - description: Returns aggregated telemetry data including request metrics and performance stats. - responses: - "200": - description: Telemetry summary data - - /api/token-health: - get: - tags: [Telemetry] - summary: Get token health status - description: Returns health status of OAuth tokens across all providers. - responses: - "200": - description: Token health status - - # ─── Evals & Policies ────────────────────────────────────────── - - /api/evals: - get: - tags: [System] - summary: List eval suites - responses: - "200": - description: Eval suite list - post: - tags: [System] - summary: Run evaluation - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Eval results - - /api/evals/{suiteId}: - get: - tags: [System] - summary: Get eval suite details - parameters: - - name: suiteId - in: path - required: true - schema: - type: string - responses: - "200": - description: Eval suite details - - /api/policies: - get: - tags: [System] - summary: List routing policies - responses: - "200": - description: Policy list - post: - tags: [System] - summary: Create routing policy - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Created policy - delete: - tags: [System] - summary: Delete routing policy - responses: - "200": - description: Policy deleted - - /api/compliance/audit-log: - get: - tags: [System] - summary: Get compliance audit log - parameters: - - name: limit - in: query - schema: - type: integer - default: 100 - responses: - "200": - description: Audit log entries - - # ─── v1beta (Gemini-Compatible) ───────────────────────────────── - - /api/v1beta/models: - get: - tags: [Models] - summary: List models (Gemini format) - description: Returns models in Gemini v1beta format for native SDK compatibility - security: - - BearerAuth: [] - responses: - "200": - description: Model list in Gemini format - - /api/v1beta/models/{...path}: - post: - tags: [Models] - summary: Gemini generateContent - description: Gemini-compatible generateContent endpoint - security: - - BearerAuth: [] - parameters: - - name: path - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Generated content - - # ─── OpenAPI Spec ────────────────────────────────────────────── - - /api/openapi/spec: - get: - tags: [System] - summary: Get OpenAPI specification catalog - description: >- - Returns a structured JSON catalog parsed from this `openapi.yaml`, - including info, servers, tags, schemas, and a flat list of endpoints - (method, path, tags, summary, security, parameters, responses). - Used by the in-app API explorer. - responses: - "200": - description: Parsed OpenAPI catalog - content: - application/json: - schema: - type: object - properties: - info: - type: object - servers: - type: array - items: - type: object - tags: - type: array - items: - type: object - endpoints: - type: array - items: - type: object - properties: - method: - type: string - path: - type: string - tags: - type: array - items: - type: string - summary: - type: string - description: - type: string - security: - type: boolean - parameters: - type: array - items: - type: object - requestBody: - type: boolean - responses: - type: array - items: - type: string - schemas: - type: array - items: - type: string - "404": - description: openapi.yaml file not found on disk - "500": - description: Failed to parse OpenAPI spec - - # ─── Memory ─────────────────────────────────────────────────────── - - /api/memory: - get: - tags: [Memory] - summary: List memory entries - description: Returns conversational memory entries (FTS5 + Qdrant). - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Memory entries - "401": - $ref: "#/components/responses/Unauthorized" - post: - tags: [Memory] - summary: Create memory entry - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - content: - type: string - tags: - type: array - items: - type: string - responses: - "201": - description: Memory entry created - "401": - $ref: "#/components/responses/Unauthorized" - - /api/memory/{id}: - get: - tags: [Memory] - summary: Get memory entry - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Memory entry - "404": - description: Not found - patch: - tags: [Memory] - summary: Update memory entry - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Memory entry updated - delete: - tags: [Memory] - summary: Delete memory entry - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Memory entry deleted - - /api/memory/health: - get: - tags: [Memory] - summary: Memory system health - description: Returns health of FTS5 index and Qdrant vector store. - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Memory health status - - # ─── Logs ───────────────────────────────────────────────────────── - - /api/logs/console: - get: - tags: [Logs] - summary: Get application console logs - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Console log entries - "401": - $ref: "#/components/responses/Unauthorized" - - /api/logs/active: - get: - tags: [Logs] - summary: Get active request logs - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Active request log entries - - /api/logs/detail: - get: - tags: [Logs] - summary: Get detailed log entries - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: query - schema: - type: string - responses: - "200": - description: Detailed log entry - - /api/logs/export: - get: - tags: [Logs] - summary: Export logs as file - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Log file download - - # ─── v1 Missing Routes ──────────────────────────────────────────── - - /api/v1/completions: - post: - tags: [Chat] - summary: Legacy completions (text) - description: OpenAI legacy text completions endpoint. - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Completion response - - /api/v1/batches: - get: - tags: [Batches] - summary: List batch jobs - security: - - BearerAuth: [] - responses: - "200": - description: Batch job list - "401": - $ref: "#/components/responses/Unauthorized" - post: - tags: [Batches] - summary: Create batch job - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Batch job created - - /api/v1/batches/{id}: - get: - tags: [Batches] - summary: Get batch job - security: - - BearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Batch job details - delete: - tags: [Batches] - summary: Cancel batch job - security: - - BearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Batch cancelled - - /api/v1/batches/{id}/cancel: - post: - tags: [Batches] - summary: Cancel batch job (explicit) - security: - - BearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Batch cancelled - - /api/v1/files: - get: - tags: [Files] - summary: List uploaded files - security: - - BearerAuth: [] - responses: - "200": - description: File list - post: - tags: [Files] - summary: Upload file - security: - - BearerAuth: [] - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - properties: - file: - type: string - format: binary - purpose: - type: string - responses: - "201": - description: File uploaded - - /api/v1/files/{id}: - get: - tags: [Files] - summary: Get file metadata - security: - - BearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: File metadata - delete: - tags: [Files] - summary: Delete file - security: - - BearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: File deleted - - /api/v1/files/{id}/content: - get: - tags: [Files] - summary: Get file content - security: - - BearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: File content - - /api/v1/images/edits: - post: - tags: [Images] - summary: Edit image - security: - - BearerAuth: [] - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - responses: - "200": - description: Edited image - - /api/v1/music/generations: - post: - tags: [Audio] - summary: Generate music - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Generated music - - /api/v1/videos/generations: - post: - tags: [Images] - summary: Generate video - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Generated video - - /api/v1/quotas/check: - get: - tags: [API Keys] - summary: Check quota usage - security: - - BearerAuth: [] - responses: - "200": - description: Quota status - - /api/v1/registered-keys: - get: - tags: [API Keys] - summary: List registered API keys - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Registered keys - post: - tags: [API Keys] - summary: Register API key - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Key registered - - /api/v1/registered-keys/{id}: - get: - tags: [API Keys] - summary: Get registered key - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Key details - delete: - tags: [API Keys] - summary: Delete registered key - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Key deleted - - /api/v1/registered-keys/{id}/revoke: - post: - tags: [API Keys] - summary: Revoke registered key - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Key revoked - - # ─── Usage Extensions ───────────────────────────────────────────── - - /api/usage/quota: - get: - tags: [Usage] - summary: Get quota usage - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Quota usage stats - - /api/usage/utilization: - get: - tags: [Usage] - summary: Get provider utilization metrics - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Utilization data - - /api/usage/combo-health: - get: - tags: [Usage] - summary: Get combo health metrics - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Combo health metrics - - /api/usage/provider-limits: - get: - tags: [Usage] - summary: Get provider rate limit status - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Provider limit status - - /api/usage/budget/bulk: - patch: - tags: [Usage] - summary: Bulk update usage budgets - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Budgets updated - - # ─── Provider Extensions ────────────────────────────────────────── - - /api/providers/bulk: - post: - tags: [Providers] - summary: Bulk provider operations - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Bulk operation result - - /api/providers/{id}/refresh: - post: - tags: [Providers] - summary: Refresh provider connection - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Provider refreshed - - /api/providers/{id}/sync-models: - post: - tags: [Providers] - summary: Sync provider models - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Models synced - - /api/providers/expiration: - get: - tags: [Providers] - summary: Get provider credential expiration status - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Expiration status - - /api/providers/quota-windows: - get: - tags: [Providers] - summary: Get provider quota windows - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Quota window data - - # ─── Settings Extensions ────────────────────────────────────────── - - /api/settings/feature-flags: - get: - tags: [Settings] - summary: Get feature flags - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Feature flags - patch: - tags: [Settings] - summary: Update feature flags - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Flags updated - - /api/settings/memory: - get: - tags: [Settings] - summary: Get memory settings - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Memory settings - patch: - tags: [Settings] - summary: Update memory settings - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Settings updated - - /api/settings/model-aliases: - get: - tags: [Settings] - summary: Get model aliases - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Model alias map - post: - tags: [Settings] - summary: Create model alias - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Alias created - delete: - tags: [Settings] - summary: Delete model alias - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Alias deleted - - /api/settings/export-json: - get: - tags: [Settings] - summary: Export settings as JSON - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Settings JSON export - - /api/settings/import-json: - post: - tags: [Settings] - summary: Import settings from JSON - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Settings imported - - /api/settings/purge-call-logs: - post: - tags: [Settings] - summary: Purge call logs - description: Deletes all call log records. Destructive. - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Logs purged - - /api/settings/purge-logs: - post: - tags: [Settings] - summary: Purge all logs - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Logs purged - - /api/settings/cache-config: - get: - tags: [Settings] - summary: Get cache configuration - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Cache config - patch: - tags: [Settings] - summary: Update cache configuration - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Config updated - - /api/settings/task-routing: - get: - tags: [Settings] - summary: Get task routing settings - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Task routing config - patch: - tags: [Settings] - summary: Update task routing settings - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Settings updated - - # ─── Internal Routes (x-internal: true — hidden from dashboard by default) ─ - - /api/analytics/auto-routing: - get: - tags: [Analytics] - x-internal: true - summary: Auto-routing decision analytics - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Auto-routing analytics - - /api/analytics/compression: - get: - tags: [Analytics] - x-internal: true - summary: Compression analytics - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Compression analytics - - /api/analytics/diversity: - get: - tags: [Analytics] - x-internal: true - summary: Provider diversity analytics - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Diversity analytics - - /api/assess: - post: - tags: [System] - x-internal: true - summary: Assess request (internal) - security: - - BearerAuth: [] - responses: - "200": - description: Assessment result - - /api/admin/concurrency: - get: - tags: [System] - x-internal: true - summary: Admin concurrency status - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Concurrency info - - /api/copilot/chat: - post: - tags: [Chat] - x-internal: true - summary: Copilot chat relay (internal) - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Chat response - - /api/gamification/leaderboard: - get: - tags: [System] - x-internal: true - summary: Local gamification leaderboard - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Leaderboard - - /api/gamification/federation/leaderboard: - get: - tags: [System] - x-internal: true - summary: Federated gamification leaderboard - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Federation leaderboard - - /api/gamification/federation/score: - post: - tags: [System] - x-internal: true - summary: Submit federation score - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Score submitted - - /api/gamification/anomalies: - get: - tags: [System] - x-internal: true - summary: Gamification anomaly detection - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Anomaly data - - /api/gamification/invite: - get: - tags: [System] - x-internal: true - summary: Get invite code - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Invite code - post: - tags: [System] - x-internal: true - summary: Create invite - security: - - ManagementSessionAuth: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - responses: - "201": - description: Invite created - - /api/gamification/invite/redeem: - post: - tags: [System] - x-internal: true - summary: Redeem invite code - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Invite redeemed - - /api/gamification/notifications: - get: - tags: [System] - x-internal: true - summary: Gamification notifications - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Notifications - - /api/gamification/rotate: - post: - tags: [System] - x-internal: true - summary: Rotate gamification key - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Key rotated - - /api/gamification/servers: - get: - tags: [System] - x-internal: true - summary: Gamification server list - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Server list - - /api/gamification/stream: - get: - tags: [System] - x-internal: true - summary: Gamification event stream - security: - - ManagementSessionAuth: [] - responses: - "200": - description: SSE stream - - /api/gamification/transfer: - post: - tags: [System] - x-internal: true - summary: Transfer gamification data - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Transfer result - - /api/health/degradation: - get: - tags: [System] - x-internal: true - summary: Degradation health check - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Degradation status - - /api/network/info: - get: - tags: [System] - x-internal: true - summary: Network info - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Network info - - /api/playground/simulate-route: - post: - tags: [System] - x-internal: true - summary: Simulate routing decision (playground) - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Routing simulation result - - /api/provider-metrics: - get: - tags: [Providers] - x-internal: true - summary: Raw provider performance metrics - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Metrics - - /api/relay/tokens: - get: - tags: [System] - x-internal: true - summary: List relay tokens - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Relay tokens - post: - tags: [System] - x-internal: true - summary: Create relay token - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Token created - - /api/relay/tokens/{id}: - delete: - tags: [System] - x-internal: true - summary: Delete relay token - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Token deleted - - /api/search/providers: - get: - tags: [Providers] - x-internal: true - summary: Search providers (internal) - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Search results - - /api/search/stats: - get: - tags: [System] - x-internal: true - summary: Search subsystem stats - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Stats - - /api/sync/bundle: - get: - tags: [System] - x-internal: true - summary: Get sync bundle - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Sync bundle - - /api/sync/tokens: - get: - tags: [System] - x-internal: true - summary: List sync tokens - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Sync tokens - post: - tags: [System] - x-internal: true - summary: Create sync token - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Token created - - /api/sync/tokens/{id}: - delete: - tags: [System] - x-internal: true - summary: Delete sync token - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Token deleted - - /api/synced-available-models: - get: - tags: [Models] - x-internal: true - summary: Synced available models (internal cache) - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Model list - - /api/system/env/repair: - post: - tags: [System] - x-internal: true - summary: Repair environment configuration - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Repair result - - /api/system/version: - get: - tags: [System] - x-internal: true - summary: Get system version info - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Version info - - /api/v1/accounts/{id}/limits: - get: - tags: [API Keys] - x-internal: true - summary: Get account limits (internal) - security: - - BearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Account limits - - /api/v1/agents/credentials: - get: - tags: [A2A] - x-internal: true - summary: Get agent credentials - security: - - BearerAuth: [] - responses: - "200": - description: Agent credentials - post: - tags: [A2A] - x-internal: true - summary: Create agent credentials - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Credentials created - - /api/v1/agents/health: - get: - tags: [A2A] - x-internal: true - summary: Agent health check - security: - - BearerAuth: [] - responses: - "200": - description: Agent health - - /api/v1/agents/tasks: - get: - tags: [A2A] - x-internal: true - summary: List agent tasks - security: - - BearerAuth: [] - responses: - "200": - description: Agent tasks - post: - tags: [A2A] - x-internal: true - summary: Create agent task - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Task created - - /api/v1/agents/tasks/{id}: - get: - tags: [A2A] - x-internal: true - summary: Get agent task - security: - - BearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Task details - patch: - tags: [A2A] - x-internal: true - summary: Update agent task - security: - - BearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Task updated - - /api/v1/batches/delete-completed: - post: - tags: [Batches] - x-internal: true - summary: Delete completed batch jobs - security: - - BearerAuth: [] - responses: - "200": - description: Completed batches deleted - - /api/v1/chatgpt-web/image/{id}: - get: - tags: [Images] - x-internal: true - summary: Retrieve ChatGPT web image (internal) - security: - - BearerAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Image data - - /api/v1/combos: - get: - tags: [Combos] - x-internal: true - summary: List combos (v1 proxy) - security: - - BearerAuth: [] - responses: - "200": - description: Combo list - - /api/v1/issues/report: - post: - tags: [System] - x-internal: true - summary: Submit issue report - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Issue reported - - /api/v1/management/proxies: - get: - tags: [Providers] - x-internal: true - summary: Management proxy list - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - responses: - "200": - description: Proxy list - post: - tags: [Providers] - x-internal: true - summary: Create management proxy - security: - - BearerAuth: [] - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Proxy created - - /api/v1/management/proxies/assignments: - get: - tags: [Providers] - x-internal: true - summary: List proxy assignments - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Assignments - - /api/v1/management/proxies/bulk-assign: - post: - tags: [Providers] - x-internal: true - summary: Bulk assign proxies - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Bulk assignment result - - /api/v1/management/proxies/health: - get: - tags: [Providers] - x-internal: true - summary: Management proxy health - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Health status - - /api/v1/providers/{provider}/limits: - get: - tags: [Providers] - x-internal: true - summary: Get provider-specific rate limits (v1) - security: - - BearerAuth: [] - parameters: - - name: provider - in: path - required: true - schema: - type: string - responses: - "200": - description: Rate limits - - /api/v1/relay/chat/completions: - post: - tags: [Chat] - x-internal: true - summary: Relay chat completions (internal) - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Relay response - - /api/v1/search: - get: - tags: [System] - x-internal: true - summary: Internal search endpoint - security: - - BearerAuth: [] - responses: - "200": - description: Search results - - /api/v1/search/analytics: - get: - tags: [Analytics] - x-internal: true - summary: Search analytics - security: - - BearerAuth: [] - responses: - "200": - description: Search analytics - - /api/v1/web/fetch: - post: - tags: [System] - x-internal: true - summary: Web fetch proxy (internal) - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Fetched content - - /api/v1/ws: - get: - tags: [System] - x-internal: true - summary: WebSocket upgrade endpoint - security: - - BearerAuth: [] - responses: - "101": - description: WebSocket upgrade - - /api/version-manager/status: - get: - tags: [Version Manager] - x-internal: true - summary: Version manager status - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Version manager state - - /api/version-manager/check-update: - get: - tags: [Version Manager] - x-internal: true - summary: Check for updates - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Update availability - - /api/version-manager/install: - post: - tags: [Version Manager] - x-internal: true - summary: Install update - security: - - ManagementSessionAuth: [] - requestBody: - required: false - content: - application/json: - schema: - type: object - responses: - "200": - description: Install initiated - - /api/version-manager/start: - post: - tags: [Version Manager] - x-internal: true - summary: Start version manager - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Started - - /api/version-manager/stop: - post: - tags: [Version Manager] - x-internal: true - summary: Stop version manager - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Stopped - - /api/version-manager/restart: - post: - tags: [Version Manager] - x-internal: true - summary: Restart version manager - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Restarted - - /api/models/openrouter-catalog: - get: - tags: [Models] - x-internal: true - summary: OpenRouter model catalog (cached) - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Model catalog - - /api/models/test: - post: - tags: [Models] - x-internal: true - summary: Test model routing - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Test result - - /api/cache/entries: - get: - tags: [System] - x-internal: true - summary: List cache entries - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Cache entries - - /api/cache/reasoning: - get: - tags: [System] - x-internal: true - summary: Reasoning cache entries - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Reasoning cache - - /api/context/analytics: - get: - tags: [Settings] - x-internal: true - summary: Context analytics - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Context analytics - - /api/context/caveman/config: - get: - tags: [Settings] - x-internal: true - summary: Caveman context config - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Config - patch: - tags: [Settings] - x-internal: true - summary: Update caveman context config - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Config updated - - /api/context/combos: - get: - tags: [Settings] - x-internal: true - summary: List context combos - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Context combos - post: - tags: [Settings] - x-internal: true - summary: Create context combo - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Combo created - - /api/context/combos/{id}: - get: - tags: [Settings] - x-internal: true - summary: Get context combo - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Context combo - patch: - tags: [Settings] - x-internal: true - summary: Update context combo - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Combo updated - delete: - tags: [Settings] - x-internal: true - summary: Delete context combo - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Combo deleted - - /api/context/combos/{id}/assignments: - get: - tags: [Settings] - x-internal: true - summary: Get context combo assignments - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Assignments - post: - tags: [Settings] - x-internal: true - summary: Update context combo assignments - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Assignments updated - - /api/middleware/hooks: - get: - tags: [Settings] - x-internal: true - summary: List middleware hooks - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Hook list - post: - tags: [Settings] - x-internal: true - summary: Create middleware hook - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Hook created - - /api/middleware/hooks/{name}: - get: - tags: [Settings] - x-internal: true - summary: Get middleware hook - security: - - ManagementSessionAuth: [] - parameters: - - name: name - in: path - required: true - schema: - type: string - responses: - "200": - description: Hook details - patch: - tags: [Settings] - x-internal: true - summary: Update middleware hook - security: - - ManagementSessionAuth: [] - parameters: - - name: name - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Hook updated - delete: - tags: [Settings] - x-internal: true - summary: Delete middleware hook - security: - - ManagementSessionAuth: [] - parameters: - - name: name - in: path - required: true - schema: - type: string - responses: - "200": - description: Hook deleted - - /api/settings/auto-disable-accounts: - get: - tags: [Settings] - x-internal: true - summary: Get auto-disable accounts settings - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Auto-disable settings - patch: - tags: [Settings] - x-internal: true - summary: Update auto-disable accounts settings - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Settings updated - - /api/settings/background-degradation: - get: - tags: [Settings] - x-internal: true - summary: Get background degradation settings - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Degradation settings - patch: - tags: [Settings] - x-internal: true - summary: Update background degradation settings - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Settings updated - - /api/settings/authz-inventory: - get: - tags: [Settings] - x-internal: true - summary: Get authorization inventory - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Authz inventory - - /api/settings/cache-metrics: - get: - tags: [Settings] - x-internal: true - summary: Get cache performance metrics - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Cache metrics - - /api/settings/favicon: - get: - tags: [Settings] - x-internal: true - summary: Get custom favicon setting - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Favicon config - post: - tags: [Settings] - x-internal: true - summary: Update custom favicon - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - responses: - "200": - description: Favicon updated - - /api/settings/lkgp-cache: - get: - tags: [Settings] - x-internal: true - summary: Get LKGP cache state - security: - - ManagementSessionAuth: [] - responses: - "200": - description: LKGP cache state - delete: - tags: [Settings] - x-internal: true - summary: Clear LKGP cache - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Cache cleared - - /api/settings/mitm: - get: - tags: [Settings] - x-internal: true - summary: Get MITM proxy settings - security: - - ManagementSessionAuth: [] - responses: - "200": - description: MITM settings - patch: - tags: [Settings] - x-internal: true - summary: Update MITM proxy settings - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Settings updated - - /api/settings/models-dev: - get: - tags: [Settings] - x-internal: true - summary: Get dev model overrides - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Dev model overrides - patch: - tags: [Settings] - x-internal: true - summary: Update dev model overrides - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Overrides updated - - /api/settings/oneproxy: - get: - tags: [Settings] - x-internal: true - summary: Get OneProxy settings - security: - - ManagementSessionAuth: [] - responses: - "200": - description: OneProxy settings - patch: - tags: [Settings] - x-internal: true - summary: Update OneProxy settings - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Settings updated - - /api/settings/oneproxy/rotate: - post: - tags: [Settings] - x-internal: true - summary: Rotate OneProxy credentials - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Credentials rotated - - /api/settings/proxies: - get: - tags: [Settings] - x-internal: true - summary: List proxy configurations - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Proxy configurations - post: - tags: [Settings] - x-internal: true - summary: Create proxy configuration - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Proxy created - - /api/settings/proxies/assignments: - get: - tags: [Settings] - x-internal: true - summary: List proxy assignments - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Proxy assignments - post: - tags: [Settings] - x-internal: true - summary: Update proxy assignments - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Assignments updated - - /api/settings/proxies/bulk-assign: - post: - tags: [Settings] - x-internal: true - summary: Bulk assign proxies - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Bulk assignment result - - /api/settings/proxies/bulk-import: - post: - tags: [Settings] - x-internal: true - summary: Bulk import proxy configurations - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Import result - - /api/settings/proxies/health: - get: - tags: [Settings] - x-internal: true - summary: Proxy configuration health check - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Health status - - /api/settings/proxies/migrate: - post: - tags: [Settings] - x-internal: true - summary: Migrate proxy configurations - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Migration result - - /api/settings/purge-detailed-logs: - post: - tags: [Settings] - x-internal: true - summary: Purge detailed logs - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Logs purged - - /api/settings/purge-quota-snapshots: - post: - tags: [Settings] - x-internal: true - summary: Purge quota snapshots - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Snapshots purged - - /api/settings/qdrant/embedding-models: - get: - tags: [Settings] - x-internal: true - summary: List Qdrant embedding models - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Embedding model list - - /api/settings/compression/rules: - get: - tags: [Settings] - x-internal: true - summary: Get compression rules (settings sub-path) - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Compression rules - - /api/keys/groups: - get: - tags: [API Keys] - x-internal: true - summary: List API key groups - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Key groups - post: - tags: [API Keys] - x-internal: true - summary: Create API key group - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Group created - - /api/keys/groups/{id}: - get: - tags: [API Keys] - x-internal: true - summary: Get API key group - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Group details - patch: - tags: [API Keys] - x-internal: true - summary: Update API key group - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Group updated - delete: - tags: [API Keys] - x-internal: true - summary: Delete API key group - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Group deleted - - /api/keys/groups/{id}/keys: - get: - tags: [API Keys] - x-internal: true - summary: List keys in group - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Keys in group - post: - tags: [API Keys] - x-internal: true - summary: Add key to group - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Key added - - /api/keys/groups/{id}/permissions: - get: - tags: [API Keys] - x-internal: true - summary: Get group permissions - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Permissions - patch: - tags: [API Keys] - x-internal: true - summary: Update group permissions - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Permissions updated - - /api/keys/{id}/regenerate: - post: - tags: [API Keys] - x-internal: true - summary: Regenerate API key secret - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: New key value - - /api/keys/{id}/reveal: - post: - tags: [API Keys] - x-internal: true - summary: Reveal API key value - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Key value (one-time reveal) - - /api/resilience/model-cooldowns: - get: - tags: [System] - x-internal: true - summary: List model cooldown state - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Model cooldown entries - - /api/a2a/status: - get: - tags: [A2A] - x-internal: true - summary: A2A server status - security: - - ManagementSessionAuth: [] - responses: - "200": - description: A2A status - - /api/a2a/tasks: - get: - tags: [A2A] - x-internal: true - summary: List A2A tasks - security: - - ManagementSessionAuth: [] - responses: - "200": - description: A2A task list - post: - tags: [A2A] - x-internal: true - summary: Create A2A task - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Task created - - /api/a2a/tasks/{id}: - get: - tags: [A2A] - x-internal: true - summary: Get A2A task - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Task details - - /api/a2a/tasks/{id}/cancel: - post: - tags: [A2A] - x-internal: true - summary: Cancel A2A task - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Task cancelled - - /api/acp/agents: - get: - tags: [A2A] - x-internal: true - summary: List ACP agents - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Agent list - - /api/db/health: - get: - tags: [System] - x-internal: true - summary: Database health check - security: - - ManagementSessionAuth: [] - responses: - "200": - description: DB health - - /api/evals/suites: - get: - tags: [Evals] - x-internal: true - summary: List eval suites - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Suite list - post: - tags: [Evals] - x-internal: true - summary: Create eval suite - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Suite created - - /api/evals/suites/{suiteId}: - get: - tags: [Evals] - x-internal: true - summary: Get eval suite details - security: - - ManagementSessionAuth: [] - parameters: - - name: suiteId - in: path - required: true - schema: - type: string - responses: - "200": - description: Suite details - patch: - tags: [Evals] - x-internal: true - summary: Update eval suite - security: - - ManagementSessionAuth: [] - parameters: - - name: suiteId - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Suite updated - - /api/files: - get: - tags: [Files] - x-internal: true - summary: List files (management view) - security: - - ManagementSessionAuth: [] - responses: - "200": - description: File list - post: - tags: [Files] - x-internal: true - summary: Upload file (management) - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - responses: - "201": - description: File uploaded - - /api/files/{id}/content: - get: - tags: [Files] - x-internal: true - summary: Get file content (management) - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: File content - - /api/batches: - get: - tags: [Batches] - x-internal: true - summary: List batch jobs (management) - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Batch list - post: - tags: [Batches] - x-internal: true - summary: Create batch job (management) - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Batch created - - /api/batches/{id}: - get: - tags: [Batches] - x-internal: true - summary: Get batch job (management) - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Batch details - delete: - tags: [Batches] - x-internal: true - summary: Delete batch job (management) - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Batch deleted - - /api/internal/codex-responses-ws: - get: - tags: [System] - x-internal: true - summary: Codex responses WebSocket (internal) - security: - - BearerAuth: [] - responses: - "101": - description: WebSocket upgrade - - /api/openapi/try: - post: - tags: [System] - x-internal: true - summary: Try It — execute an API request via proxy - description: Internal endpoint used by the API Explorer "Try It" feature. - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - method: - type: string - path: - type: string - body: - type: object - responses: - "200": - description: Try result - - /api/pricing/sync: - post: - tags: [Pricing] - x-internal: true - summary: Sync pricing data - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Pricing synced - - /api/auth/status: - get: - tags: [System] - x-internal: true - summary: Auth session status - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Auth status - - /api/model-combo-mappings: - get: - tags: [Combos] - x-internal: true - summary: List model combo mappings - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Mappings - post: - tags: [Combos] - x-internal: true - summary: Create model combo mapping - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "201": - description: Mapping created - - /api/model-combo-mappings/{id}: - patch: - tags: [Combos] - x-internal: true - summary: Update model combo mapping - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Mapping updated - delete: - tags: [Combos] - x-internal: true - summary: Delete model combo mapping - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - responses: - "200": - description: Mapping deleted - - /api/combos/builder/options: - get: - tags: [Combos] - x-internal: true - summary: Combo builder options - security: - - ManagementSessionAuth: [] - responses: - "200": - description: Builder options - - /api/combos/reorder: - patch: - tags: [Combos] - x-internal: true - summary: Reorder combos - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Reordered - - /api/providers/claude-auth/import: - post: - tags: [Providers] - x-internal: true - summary: Import Claude auth session - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Import result - - /api/providers/claude-auth/import-bulk: - post: - tags: [Providers] - x-internal: true - summary: Bulk import Claude auth sessions - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Bulk import result - - /api/providers/claude-auth/zip-extract: - post: - tags: [Providers] - x-internal: true - summary: Extract Claude auth ZIP - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - responses: - "200": - description: Extract result - - /api/providers/codex-auth/import: - post: - tags: [Providers] - x-internal: true - summary: Import Codex auth session - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Import result - - /api/providers/codex-auth/import-bulk: - post: - tags: [Providers] - x-internal: true - summary: Bulk import Codex auth sessions - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Bulk import result - - /api/providers/codex-auth/zip-extract: - post: - tags: [Providers] - x-internal: true - summary: Extract Codex auth ZIP - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - responses: - "200": - description: Extract result - - /api/providers/command-code/auth/start: - post: - tags: [Providers] - x-internal: true - summary: Start Command Code OAuth flow - security: - - ManagementSessionAuth: [] - responses: - "200": - description: OAuth flow started - - /api/providers/command-code/auth/callback: - get: - tags: [Providers] - x-internal: true - summary: Command Code OAuth callback - responses: - "302": - description: OAuth redirect - - /api/providers/command-code/auth/apply: - post: - tags: [Providers] - x-internal: true - summary: Apply Command Code OAuth token - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Token applied - - /api/providers/command-code/auth/status: - get: - tags: [Providers] - x-internal: true - summary: Command Code OAuth status - security: - - ManagementSessionAuth: [] - responses: - "200": - description: OAuth status - - /api/providers/gemini-cli-auth/import: - post: - tags: [Providers] - x-internal: true - summary: Import Gemini CLI auth session - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object + /api/telemetry/summary: + get: + tags: [Telemetry] + summary: Get telemetry summary + description: Returns aggregated telemetry data including request metrics and performance stats. responses: "200": - description: Import result + description: Telemetry summary data - /api/providers/gemini-cli-auth/import-bulk: - post: - tags: [Providers] - x-internal: true - summary: Bulk import Gemini CLI auth sessions - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object + /api/token-health: + get: + tags: [Telemetry] + summary: Get token health status + description: Returns health status of OAuth tokens across all providers. responses: "200": - description: Bulk import result + description: Token health status - /api/providers/gemini-cli-auth/zip-extract: - post: - tags: [Providers] - x-internal: true - summary: Extract Gemini CLI auth ZIP - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object + # ─── Evals & Policies ────────────────────────────────────────── + + /api/evals: + get: + tags: [System] + summary: List eval suites responses: "200": - description: Extract result - - /api/providers/{id}/claude-auth/apply-local: + description: Eval suite list post: - tags: [Providers] - x-internal: true - summary: Apply local Claude auth to provider - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string + tags: [System] + summary: Run evaluation requestBody: required: true content: @@ -6447,112 +2570,88 @@ paths: type: object responses: "200": - description: Auth applied + description: Eval results - /api/providers/{id}/claude-auth/export: - post: - tags: [Providers] - x-internal: true - summary: Export Claude auth from provider - security: - - ManagementSessionAuth: [] + /api/evals/{suiteId}: + get: + tags: [System] + summary: Get eval suite details parameters: - - name: id + - name: suiteId in: path required: true schema: type: string responses: "200": - description: Auth exported + description: Eval suite details - /api/providers/{id}/codex-auth/apply-local: + /api/policies: + get: + tags: [System] + summary: List routing policies + responses: + "200": + description: Policy list post: - tags: [Providers] - x-internal: true - summary: Apply local Codex auth to provider - security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string + tags: [System] + summary: Create routing policy requestBody: required: true content: application/json: schema: type: object + responses: + "201": + description: Created policy + delete: + tags: [System] + summary: Delete routing policy responses: "200": - description: Auth applied + description: Policy deleted - /api/providers/{id}/codex-auth/export: - post: - tags: [Providers] - x-internal: true - summary: Export Codex auth from provider - security: - - ManagementSessionAuth: [] + /api/compliance/audit-log: + get: + tags: [System] + summary: Get compliance audit log parameters: - - name: id - in: path - required: true + - name: limit + in: query schema: - type: string + type: integer + default: 100 responses: "200": - description: Auth exported + description: Audit log entries - /api/providers/{id}/gemini-cli-auth/apply-local: - post: - tags: [Providers] - x-internal: true - summary: Apply local Gemini CLI auth to provider + # ─── v1beta (Gemini-Compatible) ───────────────────────────────── + + /api/v1beta/models: + get: + tags: [Models] + summary: List models (Gemini format) + description: Returns models in Gemini v1beta format for native SDK compatibility security: - - ManagementSessionAuth: [] - parameters: - - name: id - in: path - required: true - schema: - type: string - requestBody: - required: true - content: - application/json: - schema: - type: object + - BearerAuth: [] responses: "200": - description: Auth applied + description: Model list in Gemini format - /api/providers/{id}/gemini-cli-auth/export: + /api/v1beta/models/{path}: post: - tags: [Providers] - x-internal: true - summary: Export Gemini CLI auth from provider + tags: [Models] + summary: Gemini generateContent + description: Gemini-compatible generateContent endpoint security: - - ManagementSessionAuth: [] + - BearerAuth: [] parameters: - - name: id + - name: path in: path required: true schema: type: string - responses: - "200": - description: Auth exported - - /api/providers/zed/import: - post: - tags: [Providers] - x-internal: true - summary: Import Zed auth session - security: - - ManagementSessionAuth: [] requestBody: required: true content: @@ -6561,63 +2660,74 @@ paths: type: object responses: "200": - description: Import result + description: Generated content - /api/providers/zed/manual-import: - post: - tags: [Providers] - x-internal: true - summary: Manual Zed auth import - security: - - ManagementSessionAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object - responses: - "200": - description: Import result + # ─── OpenAPI Spec ────────────────────────────────────────────── - /api/skills/skillssh: + /api/openapi/spec: get: - tags: [Skills] - x-internal: true - summary: SkillSSH status - security: - - ManagementSessionAuth: [] - responses: - "200": - description: SkillSSH status - - /api/skills/skillssh/install: - post: - tags: [Skills] - x-internal: true - summary: Install SkillSSH - security: - - ManagementSessionAuth: [] - responses: - "200": - description: SkillSSH installed - - /api/translator/transform-stream: - post: - tags: [Translator] - x-internal: true - summary: Transform stream (internal) - security: - - BearerAuth: [] - requestBody: - required: true - content: - application/json: - schema: - type: object + tags: [System] + summary: Get OpenAPI specification catalog + description: >- + Returns a structured JSON catalog parsed from this `openapi.yaml`, + including info, servers, tags, schemas, and a flat list of endpoints + (method, path, tags, summary, security, parameters, responses). + Used by the in-app API explorer. responses: "200": - description: Transform result + description: Parsed OpenAPI catalog + content: + application/json: + schema: + type: object + properties: + info: + type: object + servers: + type: array + items: + type: object + tags: + type: array + items: + type: object + endpoints: + type: array + items: + type: object + properties: + method: + type: string + path: + type: string + tags: + type: array + items: + type: string + summary: + type: string + description: + type: string + security: + type: boolean + parameters: + type: array + items: + type: object + requestBody: + type: boolean + responses: + type: array + items: + type: string + schemas: + type: array + items: + type: string + "404": + description: openapi.yaml file not found on disk + "500": + description: Failed to parse OpenAPI spec components: securitySchemes: @@ -6680,6 +2790,62 @@ components: $ref: "#/components/schemas/ValidationErrorResponse" schemas: + ServiceStatus: + type: object + description: Live supervisor state for an embedded service + properties: + tool: + type: string + example: 9router + state: + type: string + enum: [not_installed, stopped, starting, running, stopping, error] + pid: + type: integer + nullable: true + port: + type: integer + example: 20130 + health: + type: string + enum: [unknown, healthy, degraded] + startedAt: + type: string + format: date-time + nullable: true + lastError: + type: string + nullable: true + + ServiceStatusExtended: + allOf: + - $ref: "#/components/schemas/ServiceStatus" + - type: object + description: >- + Extended status including version metadata and (for 9Router) API key preview. + properties: + installedVersion: + type: string + nullable: true + latestVersion: + type: string + nullable: true + updateAvailable: + type: boolean + apiKeyMasked: + type: string + nullable: true + description: >- + Masked API key preview (e.g. "nr_****abcd"). + Present only for services that use an injected API key (9Router). + autoStart: + type: boolean + providerExpose: + type: boolean + description: >- + Whether models from this service are exposed as a routing provider. + 9Router only. + ApiErrorResponse: type: object properties: diff --git a/docs/security/ROUTE_GUARD_TIERS.md b/docs/security/ROUTE_GUARD_TIERS.md index 83f12310c129..4a4329eed508 100644 --- a/docs/security/ROUTE_GUARD_TIERS.md +++ b/docs/security/ROUTE_GUARD_TIERS.md @@ -24,10 +24,11 @@ non-loopback traffic would allow an attacker who obtained a valid JWT (e.g., via a Cloudflared/Ngrok tunnel) to trigger process spawning — a known CVE class (GHSA-fhh6-4qxv-rpqj). -| Prefix | Reason | Bypassable by `manage`? | -| ------------------------- | -------------------------------------------------- | ----------------------- | -| `/api/mcp/` | MCP server — spawns stdio bridges and SSE handlers | Yes | -| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | No (strict-loopback) | +| Prefix | Reason | Bypassable by `manage`? | +| ------------------------- | --------------------------------------------------------- | ----------------------- | +| `/api/mcp/` | MCP server — spawns stdio bridges and SSE handlers | Yes | +| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | No (strict-loopback) | +| `/api/services/` | Embedded services (9router, CLIProxy) — npm install+spawn | No (strict-loopback) | **Response on violation:** `403 LOCAL_ONLY` @@ -41,9 +42,10 @@ default for any new LOCAL_ONLY path remains strict-loopback. Unauthenticated requests and requests with non-manage keys are still rejected with `403 LOCAL_ONLY`. -Today the only bypassable prefix is `/api/mcp/`. `/api/cli-tools/runtime/` -is intentionally excluded because it can spawn arbitrary subprocesses, which -is the exact CVE class the LOCAL_ONLY tier exists to prevent. +Today the only bypassable prefix is `/api/mcp/`. `/api/cli-tools/runtime/` and +`/api/services/` are intentionally excluded because they can spawn arbitrary +subprocesses (`npm install`, `node`), which is the exact CVE class the +LOCAL_ONLY tier exists to prevent. | Request | Path | Result | | ------------------------------------------- | -------------------------- | ------------------- | diff --git a/next.config.mjs b/next.config.mjs index 2c5dc8a7c8f3..a7298f6e9fd3 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -170,6 +170,13 @@ const nextConfig = { source: "/:path*", headers: securityHeaders, }, + // G-10: allow OmniRoute's own dashboard to embed the 9Router UI via our reverse proxy. + // `frame-ancestors 'self'` overrides the global `frame-ancestors 'none'` only for this + // path. The route is already LOCAL_ONLY (routeGuard.ts) so remote origins cannot reach it. + { + source: "/dashboard/providers/services/:name/embed/:path*", + headers: [{ key: "Content-Security-Policy", value: "frame-ancestors 'self'" }], + }, ]; }, diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index ab849deda06a..52770253acc2 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -13,6 +13,7 @@ import { OpencodeExecutor } from "./opencode.ts"; import { PuterExecutor } from "./puter.ts"; import { VertexExecutor } from "./vertex.ts"; import { CliproxyapiExecutor } from "./cliproxyapi.ts"; +import { NineRouterExecutor } from "./ninerouter.ts"; import { PerplexityWebExecutor } from "./perplexity-web.ts"; import { GrokWebExecutor } from "./grok-web.ts"; import { GeminiWebExecutor } from "./gemini-web.ts"; @@ -68,6 +69,8 @@ const executors = { "vertex-partner": new VertexExecutor(), cliproxyapi: new CliproxyapiExecutor(), cpa: new CliproxyapiExecutor(), // Alias + "9router": new NineRouterExecutor(), + nr: new NineRouterExecutor(), // Alias "perplexity-web": new PerplexityWebExecutor(), "pplx-web": new PerplexityWebExecutor(), // Alias "grok-web": new GrokWebExecutor(), @@ -126,6 +129,7 @@ export { CloudflareAIExecutor } from "./cloudflare-ai.ts"; export { OpencodeExecutor } from "./opencode.ts"; export { PuterExecutor } from "./puter.ts"; export { CliproxyapiExecutor } from "./cliproxyapi.ts"; +export { NineRouterExecutor } from "./ninerouter.ts"; export { VertexExecutor } from "./vertex.ts"; export { PerplexityWebExecutor } from "./perplexity-web.ts"; export { GrokWebExecutor } from "./grok-web.ts"; diff --git a/open-sse/executors/ninerouter.ts b/open-sse/executors/ninerouter.ts new file mode 100644 index 000000000000..0c6bfd8bc62d --- /dev/null +++ b/open-sse/executors/ninerouter.ts @@ -0,0 +1,212 @@ +/** + * NineRouterExecutor — routes requests to a locally-managed 9router instance. + * + * 9router exposes both OpenAI-compatible (/v1/chat/completions) and + * Anthropic-compatible (/v1/messages) endpoints. The executor detects the + * wire shape from the request body and selects the matching endpoint so the + * response format is always consistent with what the upstream client expects. + * + * Auth: the 9router API key (nr_xxx) stored per-service, passed as a Bearer token. + * The service is local-only (loopback enforced by routeGuard.ts), so no TLS or + * identity cloaking is needed — 9router handles its own upstream auth internally. + * + * G-01: port and apiKey are re-read per request from the supervisor registry + * and DB respectively — never cached in the constructor — because rotate-key + * and update (new port) can change them between calls. + * + * G-02: when the supervisor is not running, the executor returns a 503 with + * header X-Omni-Fallback-Hint: connection_cooldown so accountFallback.ts + * applies a short 5s cooldown without tripping the provider circuit breaker. + */ + +import { + BaseExecutor, + mergeUpstreamExtraHeaders, + mergeAbortSignals, + type ProviderCredentials, + type ExecuteInput, +} from "./base.ts"; +import { FETCH_TIMEOUT_MS } from "../config/constants.ts"; +import { buildErrorBody } from "../utils/error.ts"; +import { getSupervisor } from "@/lib/services/registry"; +import { getOrCreateApiKey } from "@/lib/services/apiKey"; + +const DEFAULT_PORT = 20130; +const DEFAULT_HOST = "127.0.0.1"; +const HEALTH_CHECK_TIMEOUT_MS = 3_000; + +/** Fallback hint header value that tells accountFallback.ts to use 5s cooldown, no breaker trip. */ +export const NINEROUTER_FALLBACK_HINT = "connection_cooldown"; +export const NINEROUTER_FALLBACK_HINT_HEADER = "X-Omni-Fallback-Hint"; + +export function resolveNineRouterBaseUrl(): string { + const host = process.env.NINEROUTER_HOST || DEFAULT_HOST; + const port = parseInt(process.env.NINEROUTER_PORT || String(DEFAULT_PORT), 10); + return `http://${host}:${port}`; +} + +export class NineRouterExecutor extends BaseExecutor { + private readonly upstreamBaseUrl: string; + + constructor(baseUrl?: string) { + const effectiveBase = baseUrl ?? resolveNineRouterBaseUrl(); + super("9router", { + id: "9router", + baseUrl: `${effectiveBase}/v1/chat/completions`, + headers: { "Content-Type": "application/json" }, + }); + this.upstreamBaseUrl = effectiveBase; + } + + buildUrl( + _model: string, + _stream: boolean, + _urlIndex = 0, + _credentials: ProviderCredentials | null = null + ): string { + return `${this.upstreamBaseUrl}/v1/chat/completions`; + } + + /** + * Build a 503 service_not_running Response with the fallback hint header. + * Message goes through buildErrorBody to satisfy hard rule #12 (no raw err.message). + */ + private buildServiceUnavailableResponse(message: string): Response { + const body = buildErrorBody(503, message); + body.error.code = "service_not_running"; + return new Response(JSON.stringify(body), { + status: 503, + headers: { + "Content-Type": "application/json", + [NINEROUTER_FALLBACK_HINT_HEADER]: NINEROUTER_FALLBACK_HINT, + }, + }); + } + + /** + * True when the body matches the Anthropic Messages wire shape. + * The same heuristic used by CliproxyapiExecutor — see comments there for + * the reasoning behind each signal. + */ + private isAnthropicShape(body: unknown): boolean { + if (!body || typeof body !== "object") return false; + const b = body as Record; + if (b.system !== undefined) return true; + if (b.thinking !== undefined) return true; + if ( + b.metadata && + typeof b.metadata === "object" && + (b.metadata as Record).user_id !== undefined + ) + return true; + const msgs = b.messages; + if (Array.isArray(msgs) && msgs.length > 0) { + const first = msgs[0] as Record; + if (Array.isArray(first?.content)) return true; + } + return false; + } + + private selectEndpoint(body: unknown): "/v1/messages" | "/v1/chat/completions" { + return this.isAnthropicShape(body) ? "/v1/messages" : "/v1/chat/completions"; + } + + buildHeaders(credentials: ProviderCredentials | null, stream = true): Record { + const key = credentials?.apiKey ?? credentials?.accessToken; + const headers: Record = { "Content-Type": "application/json" }; + if (key) headers["Authorization"] = `Bearer ${key}`; + if (stream) headers["Accept"] = "text/event-stream"; + return headers; + } + + transformRequest( + model: string, + body: unknown, + _stream: boolean, + _credentials: ProviderCredentials | null + ): unknown { + if (!body || typeof body !== "object") return body; + const transformed = { ...(body as Record) }; + if (transformed.model !== model) transformed.model = model; + return transformed; + } + + async execute(input: ExecuteInput) { + // G-01: re-lookup supervisor state per request (port may change on restart/update) + const supervisor = getSupervisor("9router"); + const status = supervisor?.getStatus(); + if (!supervisor || status?.state !== "running") { + const stateLabel = status?.state ?? "unknown"; + const msg = `9router is not running (state: ${stateLabel})`; + input.log?.warn?.("9ROUTER", msg); + return { + response: this.buildServiceUnavailableResponse(msg), + url: "", + headers: {}, + transformedBody: null, + }; + } + const dynamicPort = status.port; + const dynamicBaseUrl = `http://127.0.0.1:${dynamicPort}`; + + // G-01: re-read apiKey per request — never cached in constructor + const apiKey = await getOrCreateApiKey("9router"); + const dynamicCredentials: ProviderCredentials = { ...input.credentials, apiKey }; + + // G-01: strip "9router/" prefix before forwarding to upstream + const innerModel = input.model.replace(/^9router\//, ""); + + const endpoint = this.selectEndpoint(input.body); + const url = `${dynamicBaseUrl}${endpoint}`; + const shape = endpoint === "/v1/messages" ? "anthropic" : "openai"; + const headers = this.buildHeaders(dynamicCredentials, input.stream); + const transformedBody = this.transformRequest( + innerModel, + input.body, + input.stream, + dynamicCredentials + ); + mergeUpstreamExtraHeaders(headers, input.upstreamExtraHeaders ?? null); + + const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS); + const combinedSignal = input.signal + ? mergeAbortSignals(input.signal, timeoutSignal) + : timeoutSignal; + + input.log?.info?.( + "9ROUTER", + `→ ${url} (model: ${innerModel}, shape: ${shape}, port: ${dynamicPort})` + ); + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(transformedBody), + signal: combinedSignal, + }); + + return { response, url, headers, transformedBody }; + } + + async healthCheck(): Promise<{ ok: boolean; latencyMs: number; error?: string }> { + const start = Date.now(); + try { + const res = await fetch(`${this.upstreamBaseUrl}/api/health`, { + signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS), + }); + return { + ok: res.ok, + latencyMs: Date.now() - start, + ...(!res.ok ? { error: `HTTP ${res.status}` } : {}), + }; + } catch (err) { + return { + ok: false, + latencyMs: Date.now() - start, + error: err instanceof Error ? err.message : String(err), + }; + } + } +} + +export default NineRouterExecutor; diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 2959df016082..01f31fce1510 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -1082,7 +1082,32 @@ export function checkFallbackError( permanent?: boolean; creditsExhausted?: boolean; dailyQuotaExhausted?: boolean; + /** G-02: true when the error originates from an embedded service supervisor (not the upstream AI + * provider itself). Callers should apply connection cooldown only — do NOT record a provider + * circuit-breaker failure when this flag is set. */ + skipProviderBreaker?: boolean; } { + // G-02: detect embedded service supervisor failures (X-Omni-Fallback-Hint: connection_cooldown). + // These are NOT upstream AI provider failures — they are local supervisor state changes. + // Apply a short 5s connection cooldown without tripping the provider circuit breaker. + if (status === 503 && headers) { + const hintValue = + typeof (headers as Headers).get === "function" + ? (headers as Headers).get("x-omni-fallback-hint") + : (headers as Record)["x-omni-fallback-hint"] || + (headers as Record)["X-Omni-Fallback-Hint"]; + if (typeof hintValue === "string" && hintValue.toLowerCase() === "connection_cooldown") { + return { + shouldFallback: true, + cooldownMs: 5_000, + baseCooldownMs: 5_000, + newBackoffLevel: 0, + reason: "service_not_running", + skipProviderBreaker: true, + }; + } + } + const errorStr = (errorText || "").toString(); const profile = profileOverride ?? (provider ? getProviderProfile(provider) : null); const maxBackoffSteps = profile?.maxBackoffSteps ?? BACKOFF_CONFIG.maxLevel; diff --git a/package-lock.json b/package-lock.json index c44e14d232b4..e3e9ea905e86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "node-machine-id": "^1.1.12", "open": "^11.0.0", "ora": "^9.4.0", + "parse5": "^7.3.0", "pino": "^10.3.1", "pino-abstract-transport": "^3.0.0", "pino-pretty": "^13.1.3", @@ -9261,13 +9262,12 @@ } }, "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "license": "BSD-2-Clause", "engines": { - "node": ">=20.19.0" + "node": ">=0.12" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -11395,30 +11395,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-raw/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/hast-util-raw/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/hast-util-to-estree": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", @@ -13116,6 +13092,19 @@ } } }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/jsdom/node_modules/lru-cache": { "version": "11.3.5", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", @@ -13126,6 +13115,19 @@ "node": "20 || >=22" } }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsdom/node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -16037,13 +16039,12 @@ } }, "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "license": "MIT", "dependencies": { - "entities": "^8.0.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" diff --git a/package.json b/package.json index 87b7e0e7877d..b33e23618fe9 100644 --- a/package.json +++ b/package.json @@ -174,6 +174,7 @@ "node-machine-id": "^1.1.12", "open": "^11.0.0", "ora": "^9.4.0", + "parse5": "^7.3.0", "pino": "^10.3.1", "pino-abstract-transport": "^3.0.0", "pino-pretty": "^13.1.3", diff --git a/scripts/check/check-env-doc-sync.mjs b/scripts/check/check-env-doc-sync.mjs index 6c35b9605ead..f7819f726a55 100644 --- a/scripts/check/check-env-doc-sync.mjs +++ b/scripts/check/check-env-doc-sync.mjs @@ -112,6 +112,9 @@ const IGNORE_FROM_CODE = new Set([ "OMNIROUT", // Static config alias path (the canonical var is OMNIROUTE_PAYLOAD_RULES_PATH). "PAYLOAD_RULES_PATH", + // Node.js module resolution path — OS/Node internal, not an OmniRoute config var. + // Referenced in resolveSpawnArgs (ninerouter) to pass bundled native modules to subprocess. + "NODE_PATH", ]); // Vars documented in ENVIRONMENT.md but intentionally absent from .env.example. diff --git a/src/app/(dashboard)/dashboard/providers/services/[name]/embed/[...path]/route.ts b/src/app/(dashboard)/dashboard/providers/services/[name]/embed/[...path]/route.ts new file mode 100644 index 000000000000..dd4e69082061 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/services/[name]/embed/[...path]/route.ts @@ -0,0 +1,78 @@ +/** + * Reverse-proxy handler for embedded service UIs. + * + * Route: /dashboard/providers/services/[name]/embed/[...path] + * + * Thin wrapper — all proxy logic lives in @/lib/services/reverseProxy.ts. + * + * Security: + * - Target URL is constructed from the service's registered port — never + * from user input — eliminating SSRF risk. + * - The route is classified LOCAL_ONLY in routeGuard.ts; the management + * policy blocks all non-loopback access before this handler runs. + * - See reverseProxy.ts for full security documentation. + */ + +import { proxyRequest } from "@/lib/services/reverseProxy"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { name: string; path: string[] }; + +async function handleProxy(request: Request, params: RouteParams): Promise { + const { name, path } = params; + return proxyRequest(request, path, { + name, + publicPrefix: `/dashboard/providers/services/${name}/embed`, + htmlRewrite: true, + }); +} + +export async function GET( + request: Request, + { params }: { params: Promise } +): Promise { + return handleProxy(request, await params); +} + +export async function POST( + request: Request, + { params }: { params: Promise } +): Promise { + return handleProxy(request, await params); +} + +export async function PUT( + request: Request, + { params }: { params: Promise } +): Promise { + return handleProxy(request, await params); +} + +export async function PATCH( + request: Request, + { params }: { params: Promise } +): Promise { + return handleProxy(request, await params); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise } +): Promise { + return handleProxy(request, await params); +} + +export async function HEAD( + request: Request, + { params }: { params: Promise } +): Promise { + return handleProxy(request, await params); +} + +export async function OPTIONS( + request: Request, + { params }: { params: Promise } +): Promise { + return handleProxy(request, await params); +} diff --git a/src/app/(dashboard)/dashboard/providers/services/components/ApiKeyField.tsx b/src/app/(dashboard)/dashboard/providers/services/components/ApiKeyField.tsx new file mode 100644 index 000000000000..e346304166ac --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/services/components/ApiKeyField.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card, Button, ConfirmModal } from "@/shared/components"; +import { useServiceStatus } from "../hooks/useServiceStatus"; + +interface ApiKeyFieldProps { + name: string; + serviceLabel?: string; + showReveal?: boolean; +} + +export function ApiKeyField({ name, serviceLabel, showReveal = false }: ApiKeyFieldProps) { + const { data, mutate } = useServiceStatus(name); + const [pending, setPending] = useState(false); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + + // Reveal state + const [revealModalOpen, setRevealModalOpen] = useState(false); + const [revealPending, setRevealPending] = useState(false); + const [plainKey, setPlainKey] = useState(null); + const hideTimerRef = useRef | null>(null); + + const label = serviceLabel ?? name; + + // Auto-hide plain key after 30s + useEffect(() => { + if (plainKey) { + if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + hideTimerRef.current = setTimeout(() => { + setPlainKey(null); + }, 30_000); + } + return () => { + if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + }; + }, [plainKey]); + + async function rotateKey() { + setPending(true); + setMsg(null); + setPlainKey(null); + try { + const res = await fetch(`/api/services/${name}/rotate-key`, { method: "POST" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setMsg({ ok: true, text: `Key rotated — ${label} restarted to apply the new key` }); + mutate(); + } catch { + setMsg({ ok: false, text: "Failed to rotate key" }); + } finally { + setPending(false); + } + } + + async function confirmReveal() { + setRevealPending(true); + try { + const res = await fetch(`/api/services/${name}/status?reveal=key`, { + headers: { "X-Reveal-Confirm": "yes" }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + if (typeof body.apiKeyPlain === "string") { + setPlainKey(body.apiKeyPlain); + setMsg(null); + } else { + setMsg({ ok: false, text: "Reveal failed: key not returned" }); + } + } catch { + setMsg({ ok: false, text: "Failed to reveal key" }); + } finally { + setRevealPending(false); + setRevealModalOpen(false); + } + } + + return ( + <> + +
+
+ key +
+
+

API Key

+

+ Key used by OmniRoute to authenticate with {label} +

+
+
+ + {msg && ( +
+ + {msg.ok ? "check_circle" : "error"} + + {msg.text} +
+ )} + +
+ + {plainKey ?? data?.apiKeyMasked ?? "—"} + + {showReveal && !plainKey && ( + + )} + {plainKey && ( + + )} + +
+ + {plainKey && ( +

+ Key will be hidden automatically in 30 seconds. +

+ )} +
+ + {showReveal && ( + setRevealModalOpen(false)} + onConfirm={confirmReveal} + title="Reveal API Key" + message="Revealing the API key will be logged in the audit trail. Continue?" + confirmText={revealPending ? "Revealing…" : "Reveal"} + cancelText="Cancel" + variant="secondary" + loading={revealPending} + /> + )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/services/components/AutoStartToggle.tsx b/src/app/(dashboard)/dashboard/providers/services/components/AutoStartToggle.tsx new file mode 100644 index 000000000000..14f1a3aca652 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/services/components/AutoStartToggle.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; +import { Card, Toggle } from "@/shared/components"; +import { useServiceStatus } from "../hooks/useServiceStatus"; + +interface AutoStartToggleProps { + name: string; + label?: string; + description?: string; +} + +export function AutoStartToggle({ name, label, description }: AutoStartToggleProps) { + const { data, mutate } = useServiceStatus(name); + const [pending, setPending] = useState(false); + + const displayLabel = label ?? "Auto-start"; + const displayDescription = description ?? `Launch ${name} automatically when OmniRoute starts`; + + async function handleToggle(enabled: boolean) { + setPending(true); + try { + await fetch(`/api/services/${name}/auto-start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + mutate(); + } finally { + setPending(false); + } + } + + return ( + +
+
+

{displayLabel}

+

{displayDescription}

+
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/services/components/CliproxyConnectionPanel.tsx b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyConnectionPanel.tsx new file mode 100644 index 000000000000..2a6b130f88f3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyConnectionPanel.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card, Toggle, Input } from "@/shared/components"; + +function isValidUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +interface FallbackSettings { + cliproxyapi_fallback_enabled: boolean; + cliproxyapi_url: string; + cliproxyapi_fallback_codes: string; +} + +export function CliproxyConnectionPanel() { + const [settings, setSettings] = useState({ + cliproxyapi_fallback_enabled: false, + cliproxyapi_url: "http://127.0.0.1:8317", + cliproxyapi_fallback_codes: "502,401,403,429,503", + }); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + + useEffect(() => { + fetch("/api/settings") + .then((r) => r.json()) + .then((data: Record) => { + setSettings({ + cliproxyapi_fallback_enabled: data.cliproxyapi_fallback_enabled === true, + cliproxyapi_url: + typeof data.cliproxyapi_url === "string" + ? data.cliproxyapi_url + : "http://127.0.0.1:8317", + cliproxyapi_fallback_codes: + typeof data.cliproxyapi_fallback_codes === "string" + ? data.cliproxyapi_fallback_codes + : "502,401,403,429,503", + }); + setLoaded(true); + }) + .catch(() => setLoaded(true)); + }, []); + + const saveSetting = useCallback(async (key: string, value: boolean | string) => { + if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") { + if (!isValidUrl(value)) { + setMsg({ ok: false, text: "Invalid URL — must start with http:// or https://" }); + return; + } + } + setSaving(true); + setMsg(null); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [key]: value }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setSettings((prev) => ({ ...prev, [key]: value })); + setMsg({ ok: true, text: "Saved" }); + } catch { + setMsg({ ok: false, text: "Failed to save setting" }); + } finally { + setSaving(false); + } + }, []); + + if (!loaded) return null; + + return ( + +
+
+ swap_horiz +
+
+

Fallback Routing

+

+ Retry failed provider requests through CLIProxyAPI +

+
+
+ + {msg && ( +
+ + {msg.ok ? "check_circle" : "error"} + + {msg.text} +
+ )} + +
+
+ + saveSetting("cliproxyapi_fallback_enabled", v)} + disabled={saving} + /> +
+ + {settings.cliproxyapi_fallback_enabled && ( + <> +
+ + saveSetting("cliproxyapi_url", e.target.value)} + placeholder="http://127.0.0.1:8317" + /> +
+
+ + saveSetting("cliproxyapi_fallback_codes", e.target.value)} + placeholder="502,401,403,429,503" + /> +
+ + )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx new file mode 100644 index 000000000000..8e13697304a3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx @@ -0,0 +1,199 @@ +/** + * G-08 — CLIProxyAPI Model Mapping editor. + * Renders inside CliproxyServiceTab, between FallbackRoutingCard and ServiceLogsPanel. + * Persists to upstream_proxy_config via PATCH /api/settings { cliproxyapi_model_mapping }. + */ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { Card } from "@/shared/components"; + +// ── Pure validator (exported for unit tests) ────────────────────────────────── + +/** Result of parsing the textarea value. */ +export type MappingParseResult = + | { ok: true; value: Record } + | { ok: false; error: string }; + +/** + * Parse and validate the raw textarea string. + * Valid: JSON object whose every key and value is a string. + */ +export function parseMappingJson(raw: string): MappingParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e) { + const msg = e instanceof SyntaxError ? e.message : "Invalid JSON"; + return { ok: false, error: msg }; + } + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { ok: false, error: "Must be a JSON object (not an array or primitive)" }; + } + + const obj = parsed as Record; + for (const [key, val] of Object.entries(obj)) { + if (typeof val !== "string") { + return { + ok: false, + error: `Value for key "${key}" must be a string, got ${Array.isArray(val) ? "array" : typeof val}`, + }; + } + } + + return { ok: true, value: obj as Record }; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +const EMPTY_MAPPING = "{}"; + +function formatMapping(value: Record | null): string { + if (!value || Object.keys(value).length === 0) return EMPTY_MAPPING; + return JSON.stringify(value, null, 2); +} + +export function CliproxyModelMappingEditor() { + const [rawText, setRawText] = useState(EMPTY_MAPPING); + const [savedText, setSavedText] = useState(EMPTY_MAPPING); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + const msgTimerRef = useRef | null>(null); + + // Load current mapping from /api/settings on mount + useEffect(() => { + fetch("/api/settings") + .then((r) => r.json()) + .then((data: Record) => { + const mapping = + data.cliproxyapi_model_mapping && + typeof data.cliproxyapi_model_mapping === "object" && + !Array.isArray(data.cliproxyapi_model_mapping) + ? (data.cliproxyapi_model_mapping as Record) + : null; + const formatted = formatMapping(mapping); + setRawText(formatted); + setSavedText(formatted); + }) + .catch(() => { + // leave defaults if fetch fails + }) + .finally(() => setLoaded(true)); + }, []); + + function showMsg(ok: boolean, text: string) { + if (msgTimerRef.current) clearTimeout(msgTimerRef.current); + setMsg({ ok, text }); + if (ok) { + msgTimerRef.current = setTimeout(() => setMsg(null), 3000); + } + } + + const parseResult = parseMappingJson(rawText); + const isValid = parseResult.ok; + const isDirty = rawText !== savedText; + const canSave = isValid && isDirty && !saving; + + async function handleSave() { + if (!parseResult.ok) return; + setSaving(true); + setMsg(null); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cliproxyapi_model_mapping: parseResult.value }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + // Re-format to canonical form after save + const formatted = formatMapping(parseResult.value); + setSavedText(formatted); + setRawText(formatted); + showMsg(true, "Mapping saved"); + } catch { + showMsg(false, "Failed to save mapping"); + } finally { + setSaving(false); + } + } + + if (!loaded) return null; + + return ( + +
+
+ account_tree +
+
+

Model Mapping

+

+ Map OmniRoute model IDs to CLIProxyAPI model IDs (e.g.{" "} + + {'"gpt-4o": "openai-gpt-4o"'} + + ) +

+
+
+ + {msg && ( +
+ + {msg.ok ? "check_circle" : "error"} + + {msg.text} +
+ )} + +