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:
Diego Rodrigues de Sa e Souza
2026-07-01 04:09:50 -03:00
committed by GitHub
parent 5f92dad1d3
commit ee5ee8b785
8 changed files with 216 additions and 15 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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",

View File

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

View 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"));
});