fix(providers): gate import for tool-only providers + sanitize Coze validation error (#5420 #5426) (#5522)

#5420: the 'Import Models' button now hides for tool-only providers
(web search / web fetch) via a capability check over resolved serviceKinds,
not just the -search suffix — firecrawl/jina-reader (webFetch) no longer
show an Import button that 400s. No LLM/media provider is affected.

#5426: Coze key validation no longer leaks the raw upstream envelope
({code,msg,logId,from}) into the UI; the Coze error becomes a friendly
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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-29 23:10:43 -03:00
committed by GitHub
parent f4b1aee7b9
commit 17b36694c9
7 changed files with 195 additions and 1 deletions

View File

@@ -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: <msg> (code <n>)` 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 typod 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))

View File

@@ -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 {

View File

@@ -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<string>(["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));
}

View File

@@ -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<string, unknown> | null {
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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: <msg> (code <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}`;
}

View File

@@ -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" };
}

View File

@@ -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);
});
});

View File

@@ -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);
});
});