diff --git a/CHANGELOG.md b/CHANGELOG.md index c70a55ffec..3686eb6e71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 🔧 Bug Fixes +- **dashboard (provider add):** two provider-add UX fixes. (1) #5420 — the "Import Models" button now stays hidden for **tool-only providers** (web search / web fetch), not just `*-search` ids: `firecrawl` and `jina-reader` (declared `serviceKinds: ["webFetch"]`) previously showed an Import button that hit the `400 "does not support models listing"` route. A new capability check (`providerLacksModelListing` over the resolved serviceKinds) gates the section without ever hiding an LLM/media provider. (2) #5426 — Coze key validation no longer leaks the raw upstream envelope (`{code,msg,logId,from}`) into the UI; the Coze-shaped error becomes a friendly `Coze rejected the key: (code )` message (scoped to `provider === "coze"` so no other provider is affected). Regression guards: `tests/unit/model-listing-capability-5420.test.ts`, `tests/unit/coze-validation-error-5426.test.ts`. ([#5420](https://github.com/diegosouzapw/OmniRoute/issues/5420), [#5426](https://github.com/diegosouzapw/OmniRoute/issues/5426)) - **providers (friendliai, novita):** fix two provider registry endpoints that rejected valid keys (verified live with real keys). **FriendliAI** pointed at `…/dedicated/v1/chat/completions`, which `403 Forbidden`s a serverless `flp_*` token — switched to `…/serverless/v1/chat/completions` (+ a serverless `modelsUrl`). **Novita** pointed at the legacy `…/v3/…` base with a typo’d model id `ai-ai/llama-3.1-8b-instruct` (both `404`) — switched to the OpenAI-compatible `…/openai/v1/…` base + the valid `meta-llama/llama-3.1-8b-instruct` id. Regression guard: `tests/unit/provider-endpoints-friendliai-novita.test.ts`. ([#5430](https://github.com/diegosouzapw/OmniRoute/issues/5430), [#5455](https://github.com/diegosouzapw/OmniRoute/issues/5455)) - **providers (muse-spark):** align the Muse Spark Web (Meta AI) cookie copy with the live cookie name. The default session cookie migrated from the retired `abra_sess` to `ecto_1_sess` (`META_AI_DEFAULT_COOKIE`), but the provider form hint and one 401 auth-failure message still told users to paste `abra_sess` — a cookie that no longer exists. Both strings now name `ecto_1_sess`. Regression guard: `tests/unit/muse-spark-cookie-copy-5449.test.ts`. ([#5449](https://github.com/diegosouzapw/OmniRoute/issues/5449)) - **dashboard (provider add):** fix three rough edges in the Add-API-Key / model-import flow reported across the provider-catalog audit. (1) The **Validation Model** and **Account ID** form fields shipped untranslated i18n stub copy (`"Validation Model Id Label"`, `"Account Id Placeholder"`, …) that surfaced verbatim in the modal — replaced with real labels/placeholders/hints in `en.json`. (2) Model import **silently fell back to the cached/local catalog**: the route already returned a `warning` ("API unavailable — using local catalog"), but `useModelImportHandlers` only read `models`/`error` and dropped it, so the user got local models with no indication — the warning is now surfaced as an import log line (new pure helper `extractImportWarning`). (3) The required connection-**name** field defaulted to `""`, which let browser autofill inject garbage (e.g. `wiw`) — it now defaults to `"main"`. Regression guard: `tests/unit/provider-add-ux-i18n-import-warning.test.ts`. ([#5421](https://github.com/diegosouzapw/OmniRoute/issues/5421), [#5428](https://github.com/diegosouzapw/OmniRoute/issues/5428), [#5429](https://github.com/diegosouzapw/OmniRoute/issues/5429), [#5431](https://github.com/diegosouzapw/OmniRoute/issues/5431), [#5435](https://github.com/diegosouzapw/OmniRoute/issues/5435)) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 7d4ef42403..76220671a8 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -19,6 +19,8 @@ import { compatibleProviderSupportsModelImport, getCompatibleFallbackModels, } from "@/lib/providers/managedAvailableModels"; +import { getProviderServiceKinds } from "@/lib/providers/serviceKindIndex"; +import { providerLacksModelListing } from "@/lib/providers/modelListingCapability"; import { normalizeModelCatalogSource } from "@/shared/utils/modelCatalogSearch"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; @@ -79,7 +81,17 @@ export default function ProviderDetailPageClient() { isAnthropicCompatibleProvider(providerId) && !isClaudeCodeCompatibleProvider(providerId); const isCompatible = isOpenAICompatible || isAnthropicCompatible || isCcCompatible; const isAnthropicProtocolCompatible = isAnthropicCompatible || isCcCompatible; - const isSearchProvider = providerId.endsWith("-search"); + // #5420: hide model listing for tool-only providers (web search / web fetch), + // not just `-search`-suffixed ids. Declared serviceKinds come from the static + // provider catalog (e.g. firecrawl → ["webFetch"]); compatible providers resolve + // to null here and fall through to the empty-kinds check (model listing stays on). + const declaredServiceKinds = ( + resolveDashboardProviderInfo(providerId) as { serviceKinds?: readonly string[] } | null + )?.serviceKinds; + const isSearchProvider = providerLacksModelListing( + providerId, + getProviderServiceKinds(providerId, declaredServiceKinds) + ); // ── Phase 1f hooks ──────────────────────────────────────────────────────── const { diff --git a/src/lib/providers/modelListingCapability.ts b/src/lib/providers/modelListingCapability.ts new file mode 100644 index 0000000000..3291b8517b --- /dev/null +++ b/src/lib/providers/modelListingCapability.ts @@ -0,0 +1,26 @@ +// #5420 — Tool-only providers (web search / web fetch) do not expose a model +// listing; their "Import Models" button hits the `400 "does not support models +// listing"` route. The old `-search` suffix heuristic caught `brave-search` but +// missed tool-only providers whose id has no suffix (e.g. `firecrawl`, declared +// `serviceKinds: ["webFetch"]`). This pure helper decides, from the provider id +// plus its resolved serviceKinds, whether to hide model listing — without ever +// hiding an LLM or media provider that genuinely lists models. Leaf module: it +// imports nothing, so it cannot create an import cycle with the page. + +/** Service kinds that, on their own, mean the provider lists no models. */ +const TOOL_ONLY_SERVICE_KINDS = new Set(["webSearch", "webFetch"]); + +/** + * True when the provider is tool-only and therefore has no model listing: + * - its id ends in `-search` (legacy search providers), OR + * - it declares at least one serviceKind and EVERY declared kind is a tool-only + * kind (`webSearch` / `webFetch`) — i.e. no `llm` and no media/embedding kind. + * + * Returns false for an empty `kinds` (most LLM providers declare nothing) and for + * any provider that has `llm`/image/video/music/tts/stt/embedding. + */ +export function providerLacksModelListing(providerId: string, kinds: readonly string[]): boolean { + if (providerId.endsWith("-search")) return true; + if (kinds.length === 0) return false; + return kinds.every((kind) => TOOL_ONLY_SERVICE_KINDS.has(kind)); +} diff --git a/src/lib/providers/validation/cozeError.ts b/src/lib/providers/validation/cozeError.ts new file mode 100644 index 0000000000..adb3325df7 --- /dev/null +++ b/src/lib/providers/validation/cozeError.ts @@ -0,0 +1,56 @@ +// #5426 — Coze surfaces key-validation failures as a JSON envelope shaped like +// `{ "code": 4100, "msg": "...", "logId": "...", "from": "bot-api" }`. Left +// untranslated, that raw envelope (logId included) leaks verbatim into the +// connection-validation UI. This pure helper recognizes the Coze shape and +// composes a friendly, leak-free message so callers can surface it instead of +// the raw body. Kept in its own leaf module (no network deps) so it is unit- +// testable in isolation. + +function asRecord(value: unknown): Record | null { + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } + } + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * When `body` looks like a Coze error envelope (a string `msg`, `from === "bot-api"`, + * or a `logId`), return a friendly one-line message composed from `msg`/`code` + * (e.g. `Coze rejected the key: (code )`). Returns `null` for anything + * that is not a Coze envelope — including normal OpenAI-style errors, non-objects, + * empty objects, and non-JSON strings — so non-Coze callers fall through unchanged. + * Never echoes the raw `logId` or the whole body. + */ +export function extractCozeValidationError(body: unknown): string | null { + const record = asRecord(body); + if (!record) return null; + + const msg = typeof record.msg === "string" ? record.msg.trim() : ""; + const from = typeof record.from === "string" ? record.from : ""; + const logId = typeof record.logId === "string" ? record.logId.trim() : ""; + + const looksLikeCoze = msg !== "" || from === "bot-api" || logId !== ""; + if (!looksLikeCoze) return null; + + const code = record.code; + const codeStr = + typeof code === "number" + ? String(code) + : typeof code === "string" && code.trim() !== "" + ? code.trim() + : ""; + + const detail = msg || "the API key was rejected"; + return codeStr + ? `Coze rejected the key: ${detail} (code ${codeStr})` + : `Coze rejected the key: ${detail}`; +} diff --git a/src/lib/providers/validation/openaiFormat.ts b/src/lib/providers/validation/openaiFormat.ts index 0dba98a56e..c9cc575ef8 100644 --- a/src/lib/providers/validation/openaiFormat.ts +++ b/src/lib/providers/validation/openaiFormat.ts @@ -12,6 +12,7 @@ import { addModelsSuffix, normalizeBaseUrl, resolveChatUrl } from "./urlHelpers" import { applyCustomUserAgent, buildBearerHeaders } from "./headers"; import { toValidationErrorResult, validationRead, validationWrite } from "./transport"; import { validateDirectChatProvider } from "./directChatProbe"; +import { extractCozeValidationError } from "./cozeError"; export async function validateBedrockProvider({ apiKey, providerSpecificData = {} }: any) { if (!apiKey) { @@ -143,6 +144,20 @@ export async function validateOpenAILikeProvider({ return { valid: true, error: null }; } + // #5426: Coze answers the chat probe with a JSON envelope ({ code, msg, + // logId, from }) on a bad key. Translate it into a friendly message so the + // raw envelope (logId included) never leaks into the connection UI. Scoped + // to provider === "coze" so a non-Coze error body that happens to carry a + // `msg` field is never mislabeled, and other providers' response bodies are + // never consumed here — they fall through to the canned handling below. + if (provider === "coze") { + const chatErrorBody = await chatRes.text().catch(() => ""); + const cozeError = extractCozeValidationError(chatErrorBody); + if (cozeError) { + return { valid: false, error: cozeError }; + } + } + if (chatRes.status === 401 || chatRes.status === 403) { return { valid: false, error: "Invalid API key" }; } diff --git a/tests/unit/coze-validation-error-5426.test.ts b/tests/unit/coze-validation-error-5426.test.ts new file mode 100644 index 0000000000..8a2af32445 --- /dev/null +++ b/tests/unit/coze-validation-error-5426.test.ts @@ -0,0 +1,56 @@ +// #5426 — Coze key validation must surface a friendly message instead of leaking +// the raw upstream error envelope ({ code, msg, logId, from }) into the UI. +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +import { extractCozeValidationError } from "@/lib/providers/validation/cozeError"; + +describe("extractCozeValidationError (#5426)", () => { + it("builds a friendly message from a Coze envelope with msg + code", () => { + const body = { + code: 4100, + msg: "The token you entered is incorrect. Please check and try again.", + logId: "20240101000000ABCDEF", + from: "bot-api", + }; + const result = extractCozeValidationError(body); + assert.equal( + result, + "Coze rejected the key: The token you entered is incorrect. Please check and try again. (code 4100)" + ); + // Never echo the raw logId or the whole envelope. + assert.ok(result && !result.includes("20240101000000ABCDEF")); + assert.ok(result && !result.includes("logId")); + }); + + it('recognizes the from:"bot-api" variant', () => { + const body = { code: 700012006, msg: "rejected", from: "bot-api" }; + const result = extractCozeValidationError(body); + assert.equal(result, "Coze rejected the key: rejected (code 700012006)"); + }); + + it("recognizes a stringified JSON envelope", () => { + const body = JSON.stringify({ msg: "bad key", code: 4100 }); + assert.equal(extractCozeValidationError(body), "Coze rejected the key: bad key (code 4100)"); + }); + + it("returns null for a normal OpenAI error envelope", () => { + const body = { + error: { + message: "Incorrect API key provided", + type: "invalid_request_error", + code: "invalid_api_key", + }, + }; + assert.equal(extractCozeValidationError(body), null); + }); + + it("returns null for non-object / empty / non-JSON inputs", () => { + assert.equal(extractCozeValidationError(null), null); + assert.equal(extractCozeValidationError(undefined), null); + assert.equal(extractCozeValidationError(42), null); + assert.equal(extractCozeValidationError(""), null); + assert.equal(extractCozeValidationError("not json at all"), null); + assert.equal(extractCozeValidationError({}), null); + assert.equal(extractCozeValidationError([]), null); + }); +}); diff --git a/tests/unit/model-listing-capability-5420.test.ts b/tests/unit/model-listing-capability-5420.test.ts new file mode 100644 index 0000000000..7fe0694ac3 --- /dev/null +++ b/tests/unit/model-listing-capability-5420.test.ts @@ -0,0 +1,28 @@ +// #5420 — "Import Models" must be hidden for tool-only (search/fetch) providers, +// including ones whose id does NOT end in "-search" (e.g. firecrawl → webFetch), +// while staying visible for LLM and media providers that DO list models. +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +import { providerLacksModelListing } from "@/lib/providers/modelListingCapability"; + +describe("providerLacksModelListing (#5420)", () => { + it("hides model listing for -search suffixed providers regardless of kinds", () => { + assert.equal(providerLacksModelListing("brave-search", []), true); + assert.equal(providerLacksModelListing("brave-search", ["webSearch"]), true); + assert.equal(providerLacksModelListing("brave-search", ["llm"]), true); + }); + + it("hides model listing for tool-only providers without the -search suffix", () => { + assert.equal(providerLacksModelListing("firecrawl", ["webFetch"]), true); + assert.equal(providerLacksModelListing("x", ["webSearch"]), true); + assert.equal(providerLacksModelListing("y", ["webSearch", "webFetch"]), true); + }); + + it("keeps model listing for LLM and media providers", () => { + assert.equal(providerLacksModelListing("openai", []), false); + assert.equal(providerLacksModelListing("openai", ["llm"]), false); + assert.equal(providerLacksModelListing("falai", ["image"]), false); + assert.equal(providerLacksModelListing("x", ["webSearch", "llm"]), false); + assert.equal(providerLacksModelListing("z", ["embedding"]), false); + }); +});