mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 23:02:10 +03:00
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:
committed by
GitHub
parent
f4b1aee7b9
commit
17b36694c9
@@ -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 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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
26
src/lib/providers/modelListingCapability.ts
Normal file
26
src/lib/providers/modelListingCapability.ts
Normal 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));
|
||||
}
|
||||
56
src/lib/providers/validation/cozeError.ts
Normal file
56
src/lib/providers/validation/cozeError.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
56
tests/unit/coze-validation-error-5426.test.ts
Normal file
56
tests/unit/coze-validation-error-5426.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
28
tests/unit/model-listing-capability-5420.test.ts
Normal file
28
tests/unit/model-listing-capability-5420.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user