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
@@ -0,0 +1 @@
- **fix(providers):** no-auth providers that expose no remote model endpoint can finally sync their catalog — model-sync treats a `local_catalog` response without the `intentional` marker as a failed remote fetch and returns 502 before importing, so it never pinned a stale catalog over a real outage. But the no-auth response path never set that marker, and a no-auth provider whose registry entry declares no `modelsUrl` has the local catalog as its _only_ possible discovery source. Those providers could therefore never persist a single model while `/models` kept answering `200 OK` — a silent, permanent dead zone (observed on `chipotle`, `cloudflare-playground`, `duckduckgo-web`, `felo-web` and `theoldllm`: 58 models across 5 connections). The catalog is now marked intentional exactly when no `modelsUrl` exists; providers that declare one still report `upstream`, and a genuinely failed live fetch is still rejected as degraded.
9 changes: 9 additions & 0 deletions src/app/api/providers/[id]/models/modelRouteProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,5 +108,14 @@ export async function buildNoAuthModelsResponse(
connectionId,
models: visible,
source: "local_catalog",
// A no-auth provider with no `modelsUrl` has no remote model
// endpoint at all, so this catalog is its INTENDED and only discovery
// source (same as reka/lmarena), not a degraded remote fetch. Without the
// tag, model-sync's isDegradedDiscovery guard 502s before importing and the
// provider can never persist a model while /models keeps returning 200.
// Providers that DO declare `modelsUrl` return above with source:"upstream",
// and a failed live fetch still falls through here untagged only when the
// URL exists — that case stays a genuine degradation.
...(modelsUrl ? {} : { intentional: true }),
});
}
55 changes: 55 additions & 0 deletions tests/unit/noauth-catalog-only-providers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";

import { buildNoAuthModelsResponse } from "../../src/app/api/providers/[id]/models/modelRouteProjection";
import { isDegradedDiscovery } from "../../src/app/api/providers/[id]/sync-models/degradedLocalCatalog";
import { NOAUTH_PROVIDERS } from "../../src/shared/constants/providers/noauth";
import { getModelsByProviderId } from "../../src/shared/constants/models";

/**
* A no-auth provider whose registry entry exposes no remote models endpoint has
* the local catalog as its INTENDED and only discovery source. Model-sync's
* degraded-discovery guard rejects an untagged `local_catalog` response as a
* failed remote fetch, so without the intentional marker such a provider can
* never persist a model while `/models` keeps answering 200 OK.
*
* The provider list is DERIVED, not hardcoded: providers come and go between
* releases (chipotle/felo-web/theoldllm existed in v3.8.50 and were dropped in
* v3.8.51), and a stale literal list would rot into a false failure while
* quietly failing to cover any newly added provider.
*/
function catalogOnlyNoAuthProviders(): string[] {
const ids: string[] = [];
for (const id of Object.keys(NOAUTH_PROVIDERS)) {
// Only providers that actually ship a catalog can be asserted on.
if (!(getModelsByProviderId(id) || []).length) continue;
ids.push(id);
}
return ids;
}

test("catalog-only no-auth providers reach the model-sync import stage", async () => {
const providers = catalogOnlyNoAuthProviders();
assert.ok(providers.length > 0, "expected at least one catalog-bearing no-auth provider");

const blocked: string[] = [];
for (const provider of providers) {
const res = await buildNoAuthModelsResponse(provider, `conn-${provider}`, false, true);
const body = (await res.json()) as {
source?: unknown;
intentional?: unknown;
warning?: unknown;
models?: unknown[];
};
// Providers with a live remote endpoint report `upstream`; only the
// catalog-only path is under test here.
if (body.source !== "local_catalog") continue;
if (isDegradedDiscovery(body)) blocked.push(provider);
}

assert.deepEqual(
blocked,
[],
`these providers would 502 before importing a single model: ${blocked.join(", ")}`
);
});
72 changes: 72 additions & 0 deletions tests/unit/noauth-local-catalog-intentional.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";

import { buildNoAuthModelsResponse } from "../../src/app/api/providers/[id]/models/modelRouteProjection";
import { isDegradedDiscovery } from "../../src/app/api/providers/[id]/sync-models/degradedLocalCatalog";

/**
* A no-auth provider whose registry entry exposes NO `modelsUrl` has the local
* catalog as its INTENDED and only discovery source — exactly like `reka` and
* `lmarena`, which the models route already tags `intentional: true`.
*
* `buildNoAuthModelsResponse` returned the catalog untagged, so model-sync's
* `isDegradedDiscovery` guard read a perfectly healthy response as a failed
* remote fetch and returned 502 BEFORE importing anything. That produced a
* permanent, silent auto-sync dead zone: the provider could never persist a
* model while its /models endpoint kept answering HTTP 200.
*
* Observed live on OmniRoute v3.8.50: every catalog-only no-auth provider
* (cloudflare-playground, duckduckgo-web and peers) was blocked this way.
*/

// `duckduckgo-web` has models in the registry catalog and no modelsUrl.
const CATALOG_ONLY_PROVIDER = "duckduckgo-web";

test("catalog-only no-auth provider is tagged as an intentional catalog", async () => {
const res = await buildNoAuthModelsResponse(CATALOG_ONLY_PROVIDER, "conn-1", false, true);
const body = (await res.json()) as Record<string, unknown>;

assert.equal(body.source, "local_catalog", "catalog path is the expected source");
assert.ok(
Array.isArray(body.models) && (body.models as unknown[]).length > 0,
"the registry catalog must actually yield models for this assertion to mean anything"
);
assert.equal(
body.intentional,
true,
"the catalog is this provider's only discovery source, so it must be tagged intentional"
);
});

test("model-sync imports the catalog instead of returning 502", async () => {
const res = await buildNoAuthModelsResponse(CATALOG_ONLY_PROVIDER, "conn-1", false, true);
const body = (await res.json()) as Record<string, unknown>;

assert.equal(
isDegradedDiscovery(body as { source?: unknown; intentional?: unknown; warning?: unknown }),
false,
"the guard must let this through — otherwise auto-sync can never persist a model"
);
});

test("a genuinely degraded remote fetch is still rejected", () => {
// The guard must keep 502'ing real failures so a stale catalog is never
// silently pinned over a broken credential/endpoint.
assert.equal(
isDegradedDiscovery({
source: "local_catalog",
warning: "API unavailable — using local catalog",
}),
true
);
assert.equal(
isDegradedDiscovery({ source: "cache", warning: "API unavailable — using cached catalog" }),
true
);
});

test("the live-discovery path is untouched", () => {
// aihorde / uncloseai DO expose modelsUrl and returned source:"upstream" live;
// the tag must not leak onto that path.
assert.equal(isDegradedDiscovery({ source: "upstream" }), false);
});