Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 15 additions & 2 deletions src/app/api/providers/[id]/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,14 +272,18 @@ 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({
provider,
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 } : {}),
});
};
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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,
});
}

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For providers that are intentionally local-catalog-only (such as embedding, rerank, or web-cookie providers with no discovery configuration), returning a warning like "API unavailable — using local catalog" is misleading because there is no remote API to begin with. Consider omitting the warning property entirely when intentional is true.

});
}
Expand Down
21 changes: 21 additions & 0 deletions src/app/api/providers/[id]/sync-models/degradedLocalCatalog.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 2 additions & 1 deletion src/app/api/providers/[id]/sync-models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -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({
Expand Down
11 changes: 11 additions & 0 deletions src/shared/providers/webSessionCredentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
41 changes: 37 additions & 4 deletions tests/unit/provider-models-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(", ")}`
);
}
});

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/sync-models-degraded-local-catalog-5460-5465.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 73 additions & 0 deletions tests/unit/t3chat-web-cookie-hint-5465.test.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
Loading