From ee5ee8b7850a759173d4d6ee58b7a50b00876f73 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 1 Jul 2026 04:09:50 -0300 Subject: [PATCH] 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. --- .../providers/[id]/providerPageHelpers.ts | 23 ++++-- src/app/api/providers/[id]/models/route.ts | 17 ++++- .../[id]/sync-models/degradedLocalCatalog.ts | 21 ++++++ .../api/providers/[id]/sync-models/route.ts | 3 +- src/shared/providers/webSessionCredentials.ts | 11 +++ tests/unit/provider-models-route.test.ts | 41 ++++++++++- ...s-degraded-local-catalog-5460-5465.test.ts | 42 +++++++++++ .../unit/t3chat-web-cookie-hint-5465.test.ts | 73 +++++++++++++++++++ 8 files changed, 216 insertions(+), 15 deletions(-) create mode 100644 src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts create mode 100644 tests/unit/sync-models-degraded-local-catalog-5460-5465.test.ts create mode 100644 tests/unit/t3chat-web-cookie-hint-5465.test.ts diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index 204eabb439..23ea2fd1f5 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -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, diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 76820980f4..c217c45636 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -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", }); } diff --git a/src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts b/src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts new file mode 100644 index 0000000000..beff0041e9 --- /dev/null +++ b/src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts @@ -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; +} diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index 43179b3ea6..b4c5646cd3 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -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; @@ -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({ diff --git a/src/shared/providers/webSessionCredentials.ts b/src/shared/providers/webSessionCredentials.ts index 2a9d0dac06..800f874d00 100644 --- a/src/shared/providers/webSessionCredentials.ts +++ b/src/shared/providers/webSessionCredentials.ts @@ -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", diff --git a/tests/unit/provider-models-route.test.ts b/tests/unit/provider-models-route.test.ts index 34e5c2b50e..0dd23c0f6e 100644 --- a/tests/unit/provider-models-route.test.ts +++ b/tests/unit/provider-models-route.test.ts @@ -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).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", diff --git a/tests/unit/sync-models-degraded-local-catalog-5460-5465.test.ts b/tests/unit/sync-models-degraded-local-catalog-5460-5465.test.ts new file mode 100644 index 0000000000..876384b191 --- /dev/null +++ b/tests/unit/sync-models-degraded-local-catalog-5460-5465.test.ts @@ -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); +}); diff --git a/tests/unit/t3chat-web-cookie-hint-5465.test.ts b/tests/unit/t3chat-web-cookie-hint-5465.test.ts new file mode 100644 index 0000000000..e938b87947 --- /dev/null +++ b/tests/unit/t3chat-web-cookie-hint-5465.test.ts @@ -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) { + const t = ((key: string, values?: Record) => { + 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")); +});