From 64f040bdddd7ef207de057be0ab97d029e3f958b Mon Sep 17 00:00:00 2001 From: Oleg Saprykin Date: Fri, 20 Mar 2026 18:07:56 +0300 Subject: [PATCH 1/5] fix: drop Bottleneck queue on 429 instead of waiting for reservoir refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a provider returns 429 (rate limit exceeded), the rate limit manager was setting reservoir=0 and waiting for reservoirRefreshInterval before releasing queued requests. For providers with long rate limit windows (e.g. Codex with hours-long resets), this caused all queued requests to hang indefinitely — they never timed out or returned an error. This prevented upstream callers (e.g. LiteLLM) from triggering fallback to alternative providers, effectively making the entire model unavailable until the rate limit window expired. Fix: on 429, call limiter.stop({ dropWaitingJobs: true }) to immediately fail all queued requests, then delete the limiter from the Map so getLimiter() creates a fresh instance for subsequent requests. Co-Authored-By: Claude Opus 4.6 (1M context) --- open-sse/services/rateLimitManager.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index a41612e096..3d8facc53d 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -339,14 +339,19 @@ export function updateFromHeaders(provider, connectionId, headers, status, model // Handle 429 — rate limited if (status === 429) { const retryAfterMs = parseResetTime(retryAfterStr) || 60000; // Default 60s + const counts = limiter.counts(); + const limiterKey = `${provider}:${connectionId}`; console.log( - `🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — 429 received, pausing for ${Math.ceil(retryAfterMs / 1000)}s` + `🚫 [RATE-LIMIT] ${provider}:${connectionId.slice(0, 8)} — 429 received, pausing for ${Math.ceil(retryAfterMs / 1000)}s, dropping ${counts.QUEUED} queued request(s)` ); - limiter.updateSettings({ - reservoir: 0, - reservoirRefreshAmount: limit || 60, - reservoirRefreshInterval: retryAfterMs, + // Stop the limiter and drop all waiting jobs so they fail immediately + // instead of hanging in the queue until reservoir refreshes (which can + // be hours for providers like Codex with long rate limit windows). + // This lets upstream callers (e.g. LiteLLM) trigger fallback to other providers. + // After stop, delete from Map so getLimiter() creates a fresh instance. + limiter.stop({ dropWaitingJobs: true }).then(() => { + limiters.delete(limiterKey); }); return; } From 38266bf2fff13e5cf6e683843e2b8f18d7d0a620 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 20 Mar 2026 12:26:27 -0300 Subject: [PATCH 2/5] =?UTF-8?q?release:=20v2.8.6=20=E2=80=94=20MiniMax=20r?= =?UTF-8?q?ole=20fix=20(PR=20#494),=20KIRO=20MITM=20card=20(#487),=20triag?= =?UTF-8?q?e=208=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 32 ++++++ ZWS_README_V2.md | 97 ------------------- docs/openapi.yaml | 2 +- package-lock.json | 4 +- package.json | 2 +- .../cli-tools/CLIToolsPageClient.tsx | 12 +++ .../components/AntigravityToolCard.tsx | 10 +- 7 files changed, 53 insertions(+), 106 deletions(-) delete mode 100644 ZWS_README_V2.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd82d2ee2..46fc4bd6b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,38 @@ --- +## [2.8.6] — 2026-03-20 + +> Sprint: Merge PR #494 (MiniMax role fix), fix KIRO MITM dashboard, triage 8 issues. + +### Features + +- **MiniMax developer→system role fix** (PR #494 by @zhangqiang8vip): Per-model `preserveDeveloperRole` toggle. Adds "Compatibility" UI in providers page. Fixes 422 "role param error" for MiniMax and similar gateways. +- **roleNormalizer**: `normalizeDeveloperRole()` now accepts `preserveDeveloperRole` parameter with tri-state behavior (undefined=keep, true=keep, false=convert). +- **DB**: New `getModelPreserveOpenAIDeveloperRole()` and `mergeModelCompatOverride()` in `models.ts`. + +### Bug Fixes + +- **KIRO MITM dashboard** (#481/#487): `CLIToolsPageClient` now routes any `configType: "mitm"` tool to `AntigravityToolCard` (MITM Start/Stop controls). Previously only Antigravity was hardcoded. +- **AntigravityToolCard generic**: Uses `tool.image`, `tool.description`, `tool.id` instead of hardcoded Antigravity values. Guards against missing `defaultModels`. + +### Cleanup + +- Removed `ZWS_README_V2.md` (development-only docs from PR #494). + +### Issues Triaged (8) + +- **#487** — Closed (KIRO MITM fixed in this release) +- **#486** — needs-info (Windows REG.exe PATH issue) +- **#489** — needs-info (Antigravity projectId missing, OAuth reconnect needed) +- **#492** — needs-info (missing app/server.js on mise-managed Node) +- **#490** — Acknowledged (streaming + context cache blocking, fix planned) +- **#491** — Acknowledged (Codex auth state inconsistency) +- **#493** — Acknowledged (Modal provider model name prefix, workaround provided) +- **#488** — Feature request backlog (auto-update model lists) + +--- + ## [2.8.5] — 2026-03-19 > Sprint: Fix zombie SSE streams, context cache first-turn, KIRO MITM, and triage 5 external issues. diff --git a/ZWS_README_V2.md b/ZWS_README_V2.md deleted file mode 100644 index cbed703397..0000000000 --- a/ZWS_README_V2.md +++ /dev/null @@ -1,97 +0,0 @@ -# ZWS_README_V2 — developer 角色与「role param error」修复说明 - -## 一、为什么要修 - -### 现象 - -- 使用 **OpenAI Responses API**(`/v1/responses`,body 带 `input`)经 OmniRoute 转发到 **MiniMax** 等 OpenAI 兼容网关时,上游返回 **422**,报错文案为 **`role param error`**。 - -### 原因 - -- **Responses API** 允许并会下发消息角色 **`developer`**(与 `system` 语义接近,用于模型指令)。 -- 多数 **OpenAI 兼容网关**(如 MiniMax)只接受 **`system` / `user` / `assistant` / `tool`**,不识别 `developer`,收到即报 422。 -- 修复前,OmniRoute 对这类请求**未做角色转换**,直接把带 `developer` 的 body 转给上游,因此触发 `role param error`。 - ---- - -## 二、怎么修的 - -### 1. 后端:统一用「是否保留 developer」控制是否转换 - -- **`open-sse/services/roleNormalizer.ts`** - - `normalizeDeveloperRole(messages, targetFormat, preserveDeveloperRole)`: - - 当 **`targetFormat === "openai"` 且 `preserveDeveloperRole !== false`** 时:**保留** `developer`(不转换),兼容官方 OpenAI 等支持 developer 的后端。 - - 否则:将消息中的 **`developer` 改为 `system`**,避免 MiniMax 等报 422。 - - 即:**默认保留**(与「以前没有此功能」时的行为一致);只有**显式关闭保留**(见下文「开关」)时才做 developer→system。 - -- **不在 translator 里硬编码** - - **`open-sse/translator/request/openai-responses.ts`** 中 **不再** 在从 `input` 构建 `messages` 时写死 developer→system,保持 `messages.push({ role: toString(item.role), content })`。 - - 所有「是否转换 developer」由 **`normalizeRoles`** 根据 `preserveDeveloperRole` 统一处理。 - -- **Responses 路径补跑一遍 role 管道** - - 在 **`open-sse/translator/index.ts`** 的 `translateRequest` 中:当 **`sourceFormat === OPENAI_RESPONSES`** 且已有 `result.messages` 时,在翻译完成后**再执行一次 `normalizeRoles`**,这样从 `input` 刚转出来的 `messages` 也会按开关做 developer→system,与 flag 一致。 - -- **三态与存储** - - **`src/lib/db/models.ts`** 中 **`getModelPreserveOpenAIDeveloperRole(providerId, modelId)`** 返回 **`boolean | undefined`**: - - **`undefined`**:未配置 → 路由侧视为「保留 developer」。 - - **`true`**:显式保留。 - - **`false`**:显式不保留(developer→system,修 MiniMax 422)。 - - 配置来源:**custom model 行** 或 **modelCompatOverrides**(无完整 custom 行时用 compat 存该模型的两项兼容选项);**`mergeModelCompatOverride`** 可写入 **`preserveOpenAIDeveloperRole: false`**,便于「不保留」持久化。 - -- **chatCore** - - 从 **`getModelPreserveOpenAIDeveloperRole`** 取值传给 **`translateRequest`** 的 `options.preserveDeveloperRole`,不再强制 `=== true`,从而支持 `undefined` 的默认保留语义。 - -### 2. 前端:兼容性入口与「不保留」开关 - -- **一个「兼容性」按钮 + 弹层** - - 每个模型(内置、OpenRouter/兼容、自定义)行上有一个 **「兼容性」** 按钮;点击后弹出**不透明**下拉面板(白/深色背景 + 阴影),内含: - - **工具 ID 9 位**:原有「将 tool call id 规范为 9 位」选项。 - - **不保留 developer 角色**:勾选 = 不保留 = 写入 **`preserveOpenAIDeveloperRole: false`**,路由时 developer→system;**默认不勾选** = 保留 = 与历史行为一致。 - - 弹层点击外部关闭;结构便于后续增加更多兼容项。 - -- **绑定关系** - - 后端仍只存「是否保留」:`preserveOpenAIDeveloperRole`(true/false/未设置)。 - - 弹层内「不保留 developer 角色」开关:**勾选 ⟺ `preserveDeveloperRole === false`**,`onChange(checked) => onPreserveChange(!checked)`,不改变后端字段含义。 - -- **角标** - - 当某模型为「不保留」时,在列表上显示短角标(如「不保留」),便于一眼看出该模型已开启 developer→system。 - -### 3. 文档与 i18n - -- 新增/沿用 i18n:**兼容性** 按钮、**不保留 developer 角色** 选项、角标「不保留」等(中/英),见 `src/i18n/messages/`。 - ---- - -## 三、使用方式(如何避免 422) - -- **默认**:不勾选「不保留 developer 角色」→ 保留 developer,行为与修复前一致。 -- **遇到 MiniMax 等 422**:在该模型(或对应兼容节点下的模型)上点击 **「兼容性」**,勾选 **「不保留 developer 角色」** 并保存;之后该模型请求会做 developer→system,422 消失。 -- 仅在使用**官方 OpenAI 且确实需要 developer 角色**时,再保持不勾选或显式保留。 - ---- - -## 四、当前范围与后续扩展(按协议区分兼容性) - -### 当前实现仅针对 OpenAI 协议 - -- 本轮的「兼容性」配置(**工具 ID 9 位**、**不保留 developer 角色**)在实现上**只对 OpenAI 系协议生效**:请求经 OpenAI Chat Completions / Responses 格式转发时,才会应用这些选项。 -- **同一个模型**可能被多种协议调用(例如同一物理模型既走 OpenAI 兼容端点,也走 Anthropic Messages、或其它网关),理想情况下**兼容性应按「协议」维度**配置,由用户为每个协议单独选择是否做 9 位 tool id、是否不保留 developer 等。 -- **当前页面的「兼容性」入口没有标明协议**,容易让用户误以为这些选项对该模型的所有调用方式都生效;实际上只影响 **OpenAI 协议** 下的行为。 - -### 后续计划 - -- **按协议维护兼容性**:将兼容项(如 `normalizeToolCallId`、`preserveOpenAIDeveloperRole`)归属到「协议」维度(如 `openai`、`anthropic` 等),存储与 UI 均按协议展示与编辑,避免误导并支持多协议并存时的差异化配置。 - ---- - -## 五、涉及文件摘要 - -| 区域 | 文件 | -| ---------- | ------------------------------------------------------------------------------------------------------------------------ | -| 角色转换 | `open-sse/services/roleNormalizer.ts`,`open-sse/translator/index.ts`,`open-sse/translator/request/openai-responses.ts` | -| 配置与读写 | `src/lib/db/models.ts`,`src/lib/localDb.ts`,`src/app/api/provider-models/route.ts` | -| 请求管线 | `open-sse/handlers/chatCore.ts` | -| 前端 UI | `src/app/(dashboard)/dashboard/providers/[id]/page.tsx`(兼容性按钮、弹层、不保留开关与角标) | -| 文案 | `src/i18n/messages/zh-CN.json`,`src/i18n/messages/en.json` | - -以上即为「为什么修」与「怎么修」的说明;按 CONTRIBUTING 流程在分支上提交即可。 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 70094e1f5e..b192f98885 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 2.8.5 + version: 2.8.6 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/package-lock.json b/package-lock.json index 21e1b69a02..d3640cfbcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "2.8.5", + "version": "2.8.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "2.8.5", + "version": "2.8.6", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 2ce552b6ed..711deb32a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "2.8.5", + "version": "2.8.6", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx index 7f8f1c5544..069e11a357 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx @@ -267,6 +267,18 @@ export default function CLIToolsPageClient({ machineId }) { /> ); default: + // #487: Any tool with configType "mitm" should use the MITM card (Start/Stop controls) + if (tool.configType === "mitm") { + return ( + + ); + } return ( { try { - const res = await fetch("/api/cli-tools/antigravity-mitm/alias?tool=antigravity"); + const res = await fetch(`/api/cli-tools/antigravity-mitm/alias?tool=${tool.id}`); if (res.ok) { const data = await res.json(); const aliases = data.aliases || {}; @@ -187,7 +187,7 @@ export default function AntigravityToolCard({ const res = await fetch("/api/cli-tools/antigravity-mitm/alias", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ tool: "antigravity", mappings: modelMappings }), + body: JSON.stringify({ tool: tool.id, mappings: modelMappings }), }); if (!res.ok) { @@ -211,7 +211,7 @@ export default function AntigravityToolCard({
{tool.name} )}
-

{t("toolDescriptions.antigravity")}

+

{tool.description}

- {tool.defaultModels.map((model) => ( + {(tool.defaultModels || []).map((model) => (
{model.name} From b29456c8e5914469ea5404c02487c31427254fa0 Mon Sep 17 00:00:00 2001 From: Oleg Saprykin Date: Fri, 20 Mar 2026 18:27:46 +0300 Subject: [PATCH 3/5] fix: catch stop() already called on concurrent 429s Multiple concurrent requests can receive 429 simultaneously, causing stop() to be called on an already-stopped limiter. Add .catch() to prevent unhandled rejection. Co-Authored-By: Claude Opus 4.6 (1M context) --- open-sse/services/rateLimitManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index 3d8facc53d..d7cebc4935 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -352,7 +352,7 @@ export function updateFromHeaders(provider, connectionId, headers, status, model // After stop, delete from Map so getLimiter() creates a fresh instance. limiter.stop({ dropWaitingJobs: true }).then(() => { limiters.delete(limiterKey); - }); + }).catch(() => {}); return; } From c009c4060629700a99e8554376b63ff1643bea63 Mon Sep 17 00:00:00 2001 From: Oleg Saprykin Date: Fri, 20 Mar 2026 18:31:36 +0300 Subject: [PATCH 4/5] refactor: use .finally() to always delete limiter from Map Address bot review feedback: use .finally() instead of .then()/.catch() so limiters.delete() runs regardless of whether stop() succeeds or throws (e.g. already stopped by concurrent 429). Co-Authored-By: Claude Opus 4.6 (1M context) --- open-sse/services/rateLimitManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/open-sse/services/rateLimitManager.ts b/open-sse/services/rateLimitManager.ts index d7cebc4935..70e0946cfb 100644 --- a/open-sse/services/rateLimitManager.ts +++ b/open-sse/services/rateLimitManager.ts @@ -350,9 +350,9 @@ export function updateFromHeaders(provider, connectionId, headers, status, model // be hours for providers like Codex with long rate limit windows). // This lets upstream callers (e.g. LiteLLM) trigger fallback to other providers. // After stop, delete from Map so getLimiter() creates a fresh instance. - limiter.stop({ dropWaitingJobs: true }).then(() => { + limiter.stop({ dropWaitingJobs: true }).finally(() => { limiters.delete(limiterKey); - }).catch(() => {}); + }); return; } From 48754fd9997871aa1b8876e04235048e2ccb1ee3 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Fri, 20 Mar 2026 12:57:08 -0300 Subject: [PATCH 5/5] =?UTF-8?q?release:=20v2.8.7=20=E2=80=94=20Bottleneck?= =?UTF-8?q?=20429=20drop=20(PR=20#495),=20custom=20embedding=20provider=20?= =?UTF-8?q?fix=20(#496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 18 +++++++++++++++ docs/openapi.yaml | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/app/api/v1/embeddings/route.ts | 36 +++++++++++++++++++++++++++--- 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46fc4bd6b4..7992aa32c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ --- +## [2.8.7] — 2026-03-20 + +> Sprint: Merge PR #495 (Bottleneck 429 drop), fix #496 (custom embedding providers), triage features. + +### Bug Fixes + +- **Bottleneck 429 infinite wait** (PR #495 by @xandr0s): On 429, `limiter.stop({ dropWaitingJobs: true })` immediately fails all queued requests so upstream callers can trigger fallback. Limiter is deleted from Map so next request creates a fresh instance. +- **Custom embedding models unresolvable** (#496): `POST /v1/embeddings` now resolves custom embedding models from ALL provider_nodes (not just localhost). Enables models like `google/gemini-embedding-001` added via dashboard. + +### Issues Responded + +- **#452** — Per-API-key request-count limits (acknowledged, on roadmap) +- **#464** — Auto-issue API keys with provider/account limits (needs more detail) +- **#488** — Auto-update model lists (acknowledged, on roadmap) +- **#496** — Custom embedding provider resolution (fixed) + +--- + ## [2.8.6] — 2026-03-20 > Sprint: Merge PR #494 (MiniMax role fix), fix KIRO MITM dashboard, triage 8 issues. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b192f98885..2790b17a25 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: OmniRoute API - version: 2.8.6 + version: 2.8.7 description: | OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible endpoint that routes requests to multiple AI providers with load balancing, diff --git a/package-lock.json b/package-lock.json index d3640cfbcf..eb665feefb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "omniroute", - "version": "2.8.6", + "version": "2.8.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "omniroute", - "version": "2.8.6", + "version": "2.8.7", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 711deb32a8..2f809f0d16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "omniroute", - "version": "2.8.6", + "version": "2.8.7", "description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.", "type": "module", "bin": { diff --git a/src/app/api/v1/embeddings/route.ts b/src/app/api/v1/embeddings/route.ts index 515cf2e28c..99c439cb05 100644 --- a/src/app/api/v1/embeddings/route.ts +++ b/src/app/api/v1/embeddings/route.ts @@ -12,6 +12,7 @@ import { getEmbeddingProvider, buildDynamicEmbeddingProvider, type EmbeddingProviderNodeRow, + type EmbeddingProvider, } from "@omniroute/open-sse/config/embeddingRegistry.ts"; import { errorResponse } from "@omniroute/open-sse/utils/error.ts"; import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts"; @@ -116,9 +117,9 @@ export async function POST(request) { // Load local provider_nodes for embedding routing (only localhost — prevents auth bypass/SSRF) let dynamicProviders: ReturnType[] = []; try { - const nodes = await getProviderNodes(); + const nodes = (await getProviderNodes()) as unknown as EmbeddingProviderNodeRow[]; dynamicProviders = (Array.isArray(nodes) ? nodes : []) - .filter((n: EmbeddingProviderNodeRow) => { + .filter((n) => { // provider_nodes apiType is "chat" or "responses" (not "embeddings") — local OpenAI-compatible // backends expose /embeddings under the same base URL as chat, so we build the URL as baseUrl + /embeddings. if (n.apiType !== "chat" && n.apiType !== "responses") return false; @@ -157,9 +158,38 @@ export async function POST(request) { } // Resolve provider config — dynamic first (local override), then hardcoded - const providerConfig = + let providerConfig: EmbeddingProvider | null = dynamicProviders.find((dp) => dp.id === provider) || getEmbeddingProvider(provider) || null; + // #496: Fallback — resolve from ALL provider_nodes (not just localhost) + // This enables custom embedding models (e.g. google/gemini-embedding-001) whose + // providers have remote baseUrls. Safe because getProviderCredentials() authenticates. + if (!providerConfig) { + try { + const allNodes = (await getProviderNodes()) as unknown as EmbeddingProviderNodeRow[]; + const matchingNode = (Array.isArray(allNodes) ? allNodes : []).find( + (n) => + n.prefix === provider && (n.apiType === "chat" || n.apiType === "responses") && n.baseUrl + ); + if (matchingNode) { + const baseUrl = String(matchingNode.baseUrl).replace(/\/+$/, ""); + providerConfig = { + id: matchingNode.prefix, + baseUrl: `${baseUrl}/embeddings`, + authType: "apikey", + authHeader: "bearer", + models: [], + }; + log.info( + "EMBED", + `Resolved custom embedding provider: ${provider} → ${providerConfig.baseUrl}` + ); + } + } catch (err) { + log.error("EMBED", `Failed to resolve custom embedding provider ${provider}: ${err}`); + } + } + if (!providerConfig) { return errorResponse( HTTP_STATUS.BAD_REQUEST,