mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 08:12:20 +03:00
fix(providers): import intentional local-catalog-only providers instead of 502 (#5460, #5465) (#5787)
The model-sync route returned a hard 502 ('Remote model discovery failed;
local catalog fallback not synced') for every provider whose local catalog is
its ONLY discovery source (Reka #5460, t3.chat #5465, embedding/rerank like
voyage-ai/jina-ai, Qwen-OAuth, and web-cookie providers).
The /models route now flags catalogs that are the provider's intended source
(no remote /models endpoint) with intentional:true; model-sync imports those
instead of 502-ing, while a genuinely degraded remote fallback still surfaces.
New dependency-free leaf degradedLocalCatalog.ts.
Also fixes t3.chat's confusing add-credential hint: it no longer renders the
circular 'Required cookie: convex-session-id + Cookie header...' copy and wires
the step-by-step DevTools hint (t3ChatWebCookieHint) already translated in every
locale.
Regression guards: tests/unit/sync-models-degraded-local-catalog-5460-5465.test.ts,
tests/unit/t3chat-web-cookie-hint-5465.test.ts, + intentional-flag assertions in
tests/unit/provider-models-route.test.ts.
This commit is contained in:
committed by
GitHub
parent
5f92dad1d3
commit
ee5ee8b785
@@ -37,14 +37,7 @@ export type LocalProviderMetadata = {
|
||||
|
||||
export type CommandCodeAuthFlowState = {
|
||||
phase:
|
||||
| "idle"
|
||||
| "starting"
|
||||
| "polling"
|
||||
| "received"
|
||||
| "applying"
|
||||
| "applied"
|
||||
| "expired"
|
||||
| "error";
|
||||
"idle" | "starting" | "polling" | "received" | "applying" | "applied" | "expired" | "error";
|
||||
state: string;
|
||||
authUrl: string;
|
||||
callbackUrl: string;
|
||||
@@ -402,6 +395,20 @@ export function getWebSessionCredentialHint(
|
||||
);
|
||||
}
|
||||
|
||||
// #5465 — a provider-specific hint (e.g. t3.chat's step-by-step DevTools copy)
|
||||
// replaces the generic one-line cookie/token template when that template is
|
||||
// unclear for the provider (t3.chat needs a localStorage value AND the Cookie
|
||||
// header, so "Required cookie: convex-session-id + Cookie header…" reads
|
||||
// circular). The override key ships translated in every locale.
|
||||
if (requirement.hintKey) {
|
||||
return providerText(
|
||||
t,
|
||||
requirement.hintKey,
|
||||
"Open the provider's web session in DevTools, copy the required credential(s), and paste them in the fields below.",
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
return requirement.kind === "token"
|
||||
? providerText(
|
||||
t,
|
||||
|
||||
@@ -272,7 +272,7 @@ export async function GET(
|
||||
...(warning ? { warning } : {}),
|
||||
});
|
||||
|
||||
const buildLocalCatalogResponse = (warning?: string) => {
|
||||
const buildLocalCatalogResponse = (warning?: string, intentional = false) => {
|
||||
const localModels = toLocalCatalogModels();
|
||||
if (localModels.length === 0) return null;
|
||||
return buildResponse({
|
||||
@@ -280,6 +280,10 @@ export async function GET(
|
||||
connectionId,
|
||||
models: localModels,
|
||||
source: "local_catalog",
|
||||
// #5460/#5465 — flag catalogs that are the provider's ONLY discovery
|
||||
// source (no remote /models endpoint). model-sync imports these instead
|
||||
// of treating them as a degraded remote-fetch failure (502).
|
||||
...(intentional ? { intentional: true } : {}),
|
||||
...(warning ? { warning } : {}),
|
||||
});
|
||||
};
|
||||
@@ -399,7 +403,9 @@ export async function GET(
|
||||
};
|
||||
|
||||
if (provider === "reka") {
|
||||
const localCatalog = buildLocalCatalogResponse();
|
||||
// reka has no remote model-discovery endpoint — the local catalog is the
|
||||
// intended source, not a degraded fallback (#5460).
|
||||
const localCatalog = buildLocalCatalogResponse(undefined, true);
|
||||
if (localCatalog) return localCatalog;
|
||||
}
|
||||
|
||||
@@ -1632,6 +1638,9 @@ export async function GET(
|
||||
owned_by: "qwen",
|
||||
})),
|
||||
source: "local_catalog",
|
||||
// #5460/#5465 — Qwen OAuth has no OAuth-compatible remote /models list;
|
||||
// the static catalog is intentional, so model-sync should import it.
|
||||
intentional: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1655,6 +1664,10 @@ export async function GET(
|
||||
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
|
||||
})),
|
||||
source: "local_catalog",
|
||||
// #5460/#5465 — providers with no discovery config (embedding/rerank/
|
||||
// web-cookie providers like voyage-ai, jina-ai, t3-web) are
|
||||
// intentionally local-catalog-only; model-sync imports rather than 502s.
|
||||
intentional: true,
|
||||
warning: "API unavailable — using local catalog",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* #5460 (Reka) + #5465 (t3.chat) — Distinguish a genuinely degraded
|
||||
* `local_catalog` models response (remote model discovery failed → the sync
|
||||
* route surfaces a 502) from a provider whose local catalog is its INTENDED and
|
||||
* only discovery source (reka, qwen-oauth, embedding/rerank providers like
|
||||
* voyage-ai/jina-ai, web-cookie providers like t3-web).
|
||||
*
|
||||
* The models route tags the latter with `intentional: true`. Before this guard,
|
||||
* model-sync 502'd on ANY `local_catalog` source, so the Import/Sync button
|
||||
* failed every single time for those providers even with a valid key/cookie.
|
||||
*
|
||||
* Dependency-free leaf so it can be unit-tested without booting the DB/route.
|
||||
*/
|
||||
export function isDegradedLocalCatalog(modelsData: {
|
||||
source?: unknown;
|
||||
intentional?: unknown;
|
||||
}): boolean {
|
||||
const source =
|
||||
typeof modelsData?.source === "string" ? modelsData.source.trim().toLowerCase() : "";
|
||||
return source === "local_catalog" && modelsData?.intentional !== true;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { autoSyncCodexProfilesFromLiveCatalog } from "@/lib/cli-helper/codexProfileAutoSync";
|
||||
import { autoSyncClaudeProfilesFromLiveCatalog } from "@/lib/cli-helper/claudeProfileAutoSync";
|
||||
import { GET as getProviderModels } from "../models/route";
|
||||
import { isDegradedLocalCatalog } from "./degradedLocalCatalog";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
@@ -444,7 +445,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
const modelSource = toNonEmptyString(modelsData.source)?.toLowerCase() || "unknown";
|
||||
const modelWarning = toNonEmptyString(modelsData.warning);
|
||||
if (modelSource === "local_catalog") {
|
||||
if (isDegradedLocalCatalog(modelsData)) {
|
||||
const responseError =
|
||||
modelWarning || "Remote model discovery failed; local catalog fallback not synced";
|
||||
await saveCallLog({
|
||||
|
||||
@@ -7,6 +7,13 @@ export type WebSessionCredentialRequirement =
|
||||
placeholder: string;
|
||||
acceptsFullCookieHeader: boolean;
|
||||
storageKeys: readonly string[];
|
||||
/**
|
||||
* #5465 — Optional i18n key for a provider-specific credential hint that
|
||||
* REPLACES the generic "Required cookie: {credential}…" copy. Use when the
|
||||
* generic template is confusing (e.g. t3.chat needs a localStorage value
|
||||
* AND the Cookie header, so the one-line cookie hint reads circular).
|
||||
*/
|
||||
hintKey?: string;
|
||||
}
|
||||
| {
|
||||
kind: "none";
|
||||
@@ -107,6 +114,10 @@ export const WEB_SESSION_CREDENTIAL_REQUIREMENTS = {
|
||||
placeholder: "convex-session-id=abc123...; Cookie: ...",
|
||||
acceptsFullCookieHeader: true,
|
||||
storageKeys: ["cookie", "convex-session-id", "convexSessionId"],
|
||||
// #5465 — the generic cookie hint reads circular for t3.chat (needs a
|
||||
// localStorage value AND the Cookie header); use the step-by-step DevTools
|
||||
// copy that already ships translated in every locale.
|
||||
hintKey: "t3ChatWebCookieHint",
|
||||
},
|
||||
"adapta-web": {
|
||||
kind: "cookie",
|
||||
|
||||
@@ -76,7 +76,10 @@ test("provider models route returns a static local catalog for non-LLM search/ag
|
||||
const body = await response.json();
|
||||
assert.equal(body.source, "local_catalog", `${provider} should serve a local catalog`);
|
||||
const ids = (body.models || []).map((m) => m.id);
|
||||
assert.ok(ids.includes(expectId), `${provider} should list "${expectId}"; got: ${ids.join(", ")}`);
|
||||
assert.ok(
|
||||
ids.includes(expectId),
|
||||
`${provider} should list "${expectId}"; got: ${ids.join(", ")}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -118,9 +121,8 @@ test("provider models route falls back to the local AI/ML API catalog when the l
|
||||
});
|
||||
|
||||
test("cablyai is flagged deprecated (domain NXDOMAIN) and no longer 500s on model import (#5568)", async () => {
|
||||
const { APIKEY_PROVIDERS_GATEWAYS } = await import(
|
||||
"../../src/shared/constants/providers/apikey/gateways.ts"
|
||||
);
|
||||
const { APIKEY_PROVIDERS_GATEWAYS } =
|
||||
await import("../../src/shared/constants/providers/apikey/gateways.ts");
|
||||
const cablyai = (APIKEY_PROVIDERS_GATEWAYS as Record<string, any>).cablyai;
|
||||
assert.equal(cablyai?.deprecated, true, "cablyai must be marked deprecated (domain is NXDOMAIN)");
|
||||
assert.ok(
|
||||
@@ -502,6 +504,37 @@ test("provider models route returns the local catalog for embedding and rerank p
|
||||
assert.ok(jinaBody.models.some((model) => model.id === "jina-reranker-m0"));
|
||||
});
|
||||
|
||||
test("provider models route flags intentional local-catalog-only providers so model-sync imports them (#5460/#5465)", async () => {
|
||||
// reka + voyage-ai never do a remote /models fetch — their local catalog is
|
||||
// the intended source, so the response must carry `intentional: true` for the
|
||||
// sync route to import instead of 502-ing ("local catalog fallback not synced").
|
||||
const reka = await seedConnection("reka", { apiKey: "reka-key" });
|
||||
const voyage = await seedConnection("voyage-ai", { apiKey: "voyage-key" });
|
||||
|
||||
const [rekaBody, voyageBody] = await Promise.all([
|
||||
callRoute(reka.id).then((r) => r.json() as any),
|
||||
callRoute(voyage.id).then((r) => r.json() as any),
|
||||
]);
|
||||
|
||||
assert.equal(rekaBody.source, "local_catalog");
|
||||
assert.equal(rekaBody.intentional, true, "reka local catalog must be flagged intentional");
|
||||
assert.equal(voyageBody.source, "local_catalog");
|
||||
assert.equal(voyageBody.intentional, true, "voyage-ai local catalog must be flagged intentional");
|
||||
});
|
||||
|
||||
test("provider models route does NOT flag a degraded remote-fetch fallback as intentional (#5460/#5465)", async () => {
|
||||
// aimlapi normally discovers remotely; when the live fetch fails it falls back
|
||||
// to the local catalog — that IS degraded and must NOT be flagged intentional,
|
||||
// so model-sync still surfaces the failure (502) for it.
|
||||
const connection = await seedConnection("aimlapi", { apiKey: "aiml-key" });
|
||||
globalThis.fetch = async () => new Response("upstream down", { status: 500 });
|
||||
|
||||
const body = (await (await callRoute(connection.id)).json()) as any;
|
||||
|
||||
assert.equal(body.source, "local_catalog");
|
||||
assert.notEqual(body.intentional, true, "degraded fallback must not be flagged intentional");
|
||||
});
|
||||
|
||||
test("provider models route returns the local catalog for Runway video models", async () => {
|
||||
const connection = await seedConnection("runwayml", {
|
||||
apiKey: "runway-key",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #5460 (Reka) + #5465 (t3.chat): the model-sync route used to 502 on ANY
|
||||
// `local_catalog` source, so providers whose local catalog is their ONLY
|
||||
// discovery source (reka, qwen-oauth, embedding/rerank + web-cookie providers)
|
||||
// failed Import/Sync every time. The route now imports those (flagged
|
||||
// `intentional: true` by the models route) and only 502s a genuinely degraded
|
||||
// remote-fetch fallback.
|
||||
const { isDegradedLocalCatalog } =
|
||||
await import("../../src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts");
|
||||
|
||||
test("isDegradedLocalCatalog: intentional local-only catalog is NOT a degraded failure (#5460/#5465)", () => {
|
||||
// reka / voyage-ai / t3-web etc. — the models route tags these intentional.
|
||||
assert.equal(
|
||||
isDegradedLocalCatalog({ source: "local_catalog", intentional: true }),
|
||||
false,
|
||||
"intentional local-catalog-only providers must import, not 502"
|
||||
);
|
||||
// Case-insensitive on source.
|
||||
assert.equal(isDegradedLocalCatalog({ source: "LOCAL_CATALOG", intentional: true }), false);
|
||||
});
|
||||
|
||||
test("isDegradedLocalCatalog: a degraded remote-fetch fallback IS a failure (502)", () => {
|
||||
// Provider that normally discovers remotely but the fetch failed → no flag.
|
||||
assert.equal(
|
||||
isDegradedLocalCatalog({
|
||||
source: "local_catalog",
|
||||
warning: "API unavailable — using local catalog",
|
||||
}),
|
||||
true,
|
||||
"unflagged local_catalog is a degraded fallback and must 502"
|
||||
);
|
||||
assert.equal(isDegradedLocalCatalog({ source: "local_catalog", intentional: false }), true);
|
||||
});
|
||||
|
||||
test("isDegradedLocalCatalog: non-local sources are never degraded-local failures", () => {
|
||||
assert.equal(isDegradedLocalCatalog({ source: "api" }), false);
|
||||
assert.equal(isDegradedLocalCatalog({ source: "cache", intentional: false }), false);
|
||||
assert.equal(isDegradedLocalCatalog({}), false);
|
||||
assert.equal(isDegradedLocalCatalog({ source: "" }), false);
|
||||
});
|
||||
73
tests/unit/t3chat-web-cookie-hint-5465.test.ts
Normal file
73
tests/unit/t3chat-web-cookie-hint-5465.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #5465 — t3.chat's add-credential form showed the generic, circular cookie
|
||||
// hint ("Required cookie: convex-session-id + Cookie header. Paste the Cookie
|
||||
// header value…"). t3.chat needs a localStorage value AND the Cookie header, so
|
||||
// that copy is confusing. A step-by-step DevTools hint (t3ChatWebCookieHint)
|
||||
// already shipped translated in every locale but was never wired to the UI.
|
||||
const { getWebSessionCredentialHint } =
|
||||
await import("../../src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts");
|
||||
const { WEB_SESSION_CREDENTIAL_REQUIREMENTS } =
|
||||
await import("../../src/shared/providers/webSessionCredentials.ts");
|
||||
|
||||
const T3_HINT =
|
||||
"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.";
|
||||
|
||||
/** Minimal translator stub mimicking next-intl's `t` + `t.has`. */
|
||||
function makeTranslator(messages: Record<string, string>) {
|
||||
const t = ((key: string, values?: Record<string, unknown>) => {
|
||||
const raw = messages[key];
|
||||
if (raw === undefined) return key;
|
||||
return values
|
||||
? Object.entries(values).reduce(
|
||||
(acc, [name, value]) => acc.replaceAll(`{${name}}`, String(value)),
|
||||
raw
|
||||
)
|
||||
: raw;
|
||||
}) as any;
|
||||
t.has = (key: string) => key in messages;
|
||||
return t;
|
||||
}
|
||||
|
||||
test("t3.chat add-credential hint uses the step-by-step DevTools copy, not the circular generic one (#5465)", () => {
|
||||
const t = makeTranslator({
|
||||
t3ChatWebCookieHint: T3_HINT,
|
||||
webCookieCredentialHint:
|
||||
"Required cookie: {credential}. Paste the Cookie header value from your own signed-in {provider} web session. Do not include the Cookie: prefix.",
|
||||
});
|
||||
|
||||
const hint = getWebSessionCredentialHint(
|
||||
t,
|
||||
WEB_SESSION_CREDENTIAL_REQUIREMENTS["t3-web"],
|
||||
"t3.chat",
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(hint, T3_HINT);
|
||||
assert.ok(hint && hint.includes("Local Storage"), "must explain the localStorage step");
|
||||
assert.ok(
|
||||
hint && !hint.includes("Required cookie: convex-session-id + Cookie header"),
|
||||
"must not fall back to the circular generic cookie hint"
|
||||
);
|
||||
});
|
||||
|
||||
test("t3-web requirement now carries the hintKey override (#5465)", () => {
|
||||
const req = WEB_SESSION_CREDENTIAL_REQUIREMENTS["t3-web"] as { hintKey?: string };
|
||||
assert.equal(req.hintKey, "t3ChatWebCookieHint");
|
||||
});
|
||||
|
||||
test("cookie providers without a hintKey still use the generic hint (#5465 regression guard)", () => {
|
||||
const t = makeTranslator({
|
||||
webCookieCredentialHint:
|
||||
"Required cookie: {credential}. Paste the Cookie header value from your own signed-in {provider} web session. Do not include the Cookie: prefix.",
|
||||
});
|
||||
// adapta-web is a cookie provider with no hintKey.
|
||||
const hint = getWebSessionCredentialHint(
|
||||
t,
|
||||
WEB_SESSION_CREDENTIAL_REQUIREMENTS["adapta-web"],
|
||||
"adapta",
|
||||
false
|
||||
);
|
||||
assert.ok(hint && hint.startsWith("Required cookie: __client"));
|
||||
});
|
||||
Reference in New Issue
Block a user