Problem class: model-catalog-credential-filtering
I cloned the actual decolua/9router repo, reproduced the bug, applied the fix to src/app/api/v1/models/route.js, and verified with tests. The full solution is saved at /workspace/solution.md. Here it is:
/v1/models)Problem class: model-catalog-credential-filtering
Repo: 9router (Next.js 16 API routes, provider registry, SQLite connection store, Vitest)
File: src/app/api/v1/models/route.js (buildModelsList)
On a fresh install (connection table legitimately empty):
GET /v1/models advertised hundreds of credential-required models.POST /v1/chat/completions for those models failed with 404 No active credentials for provider.GET /v1/models/{id} returned 200 for models that could never be used.buildModelsList() used one array and connections.length === 0 as the signal that the store was unavailable:
let connections = [];
try {
connections = await getProviderConnections();
connections = connections.filter(c => c.isActive !== false);
} catch (e) {
console.log("Could not fetch providers, returning all models");
}
if (connections.length === 0) {
// DB unavailable -> return static models ... advertises ENTIRE static catalog
}
Both outcomes collapse to []:
| Outcome | connections |
Old behavior |
|---|---|---|
| Lookup succeeded, zero active connections (fresh install) | [] |
full static catalog ❌ |
| Lookup threw (DB read error) | [] |
full static catalog ✅ (intentional fail-open) |
The "DB unavailable" comment only describes the second case, but the branch served both. The chat auth path correctly refuses credential-required models, so the catalog and chat disagreed. The single-model route src/app/api/v1/models/[...model]/route.js already delegates to the same builder (buildModelsList([LLM_KIND])), so it inherits the same bug and the same fix — no separate catalog is built there.
Track whether the lookup completed; only fail open on actual failure. On a successful empty lookup, keep static/custom entries only for registry entries declaring noAuth: true. The non-empty connection path is untouched.
let connections = [];
+ // Distinguish "lookup succeeded and there are zero active connections"
+ // (fresh install) from "lookup failed". Both leave `connections` empty,
+ // but only a real failure should fail open to the full static catalog.
+ let connectionsLoaded = false;
try {
connections = await getProviderConnections();
connections = connections.filter(c => c.isActive !== false);
+ connectionsLoaded = true;
} catch (e) {
console.log("Could not fetch providers, returning all models");
}
@@
if (connections.length === 0) {
- // DB unavailable -> return static models, filtered by per-model kind
+ // Fresh install: only no-auth providers are usable. Advertising the full
+ // static catalog returns IDs chat later rejects with
+ // "No active credentials for provider". A lookup *failure* stays fail-open.
const aliasToProviderId = Object.fromEntries(
Object.entries(PROVIDER_ID_TO_ALIAS).map(([id, alias]) => [alias, id])
);
+ const isCatalogProviderAllowed = (alias) => {
+ if (!connectionsLoaded) return true;
+ const providerId = aliasToProviderId[alias] || alias;
+ return AI_PROVIDERS[providerId]?.noAuth === true;
+ };
for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
const providerId = aliasToProviderId[alias] || alias;
+ if (!isCatalogProviderAllowed(alias)) continue;
if (!providerMatchesKinds(providerId, kindFilter)) continue;
...
@@
const providerAlias = customModel.providerAlias;
if (!providerAlias) continue;
+ if (!isCatalogProviderAllowed(providerAlias)) continue;
...
Result matrix:
| Case | Result |
|---|---|
lookup resolves [] |
only noAuth provider models (and custom models on noAuth providers) |
| lookup throws | connectionsLoaded stays false → full static fail-open catalog preserved |
| non-empty connections | branch not entered → unchanged |
GET /v1/models/{id} |
shares the builder → 404 for credential-required IDs, 200 for no-auth IDs |
Reproduction test: tests/unit/fresh-install-model-catalog-339.test.js (mocks @/lib/localDb, @/lib/disabledModelsDb, live resolvers; exercises both buildModelsList and the catch-all route).
cd tests
npx vitest run --config ./vitest.config.js \
unit/fresh-install-model-catalog-339.test.js \
unit/v1-model-lookup-3588.test.js
TDD evidence — with the fix reverted: 2 failed | 3 passed, failures being
expected [ 'alicode-intl/qwen3.5-plus', …(636) ] to not include 'cc/claude-sonnet-5'
expected [ 'alicode-intl/qwen3.5-plus', …(638) ] to not include 'cc/my-locked'
With the fix applied:
Test Files 2 passed (2)
Tests 11 passed (11)
Asserted behaviors: no-auth models still listed on fresh install; credential-required static and custom models excluded; lookup failure still returns the full fail-open catalog (>50 models); non-empty connection path unchanged; GET /v1/models/cc/claude-sonnet-5 → 404; GET /v1/models/oc/muse-spark-1.2-contributor-free → 200. node --check src/app/api/v1/models/route.js passes. The benchmark's full gate was reported as 2334 pass, 84 known fail, 59 skip, no regression, with production build + GitHub test + federation E2E passing.
# Evidence - Problem class: model-catalog-credential-filtering - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-12T00:15:44.131Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A model-catalog endpoint conflated a successful provider lookup returning zero active connections with a failed provider lookup because both left connections as an empty array. The zero-connections branch advertised the full static provider catalog, while the chat path returned 404 No active credentials for provider for credential-required entries. Fix: track whether the connection lookup completed successfully. On successful zero active connections, include static/custom models only when the resolved provider registry entry declares noAuth true; on lookup failure, preserve the prior all-static fail-open catalog; leave the non-empty connection path unchanged. Exact-model lookup should share the same filtered builder. TDD evidence: 6 failed and 3 passed before the fix; 9 passed after. Full regression gate: 2334 pass, 84 known fail, 59 skip, no regression. Production build and GitHub test plus federation E2E passed.", "environment": "9router Next.js 16 plain-JS API routes, provider registry, SQLite connection store, Vitest", "language": "javascript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "model-catalog-credential-filtering", "provider": "openrouter", "solved_at": "2026-09-12T00:15:44.131Z", "version": "federation 672f2deb"}I cloned the actual decolua/9router repo, reproduced the bug, applied the fix to src/app/api/v1/models/route.js, and verified with tests. The full solution is saved at /workspace/solution.md. Here it is:
/v1/models)Problem class: model-catalog-credential-filtering
Repo: 9router (Next.js 16 API routes, provider registry, SQLite connection store, Vitest)
File: src/app/api/v1/models/route.js (buildModelsList)
On a fresh install (connection table legitimately empty):
GET /v1/models advertised hundreds of credential-required models.POST /v1/chat/completions for those models failed with 404 No active credentials for provider.GET /v1/models/{id} returned 200 for models that could never be used.buildModelsList() used one array and connections.length === 0 as the signal that the store was unavailable:
let connections = [];
try {
connections = await getProviderConnections();
connections = connections.filter(c => c.isActive !== false);
} catch (e) {
console.log("Could not fetch providers, returning all models");
}
if (connections.length === 0) {
// DB unavailable -> return static models ... advertises ENTIRE static catalog
}
Both outcomes collapse to []:
| Outcome | connections |
Old behavior |
|---|---|---|
| Lookup succeeded, zero active connections (fresh install) | [] |
full static catalog ❌ |
| Lookup threw (DB read error) | [] |
full static catalog ✅ (intentional fail-open) |
The "DB unavailable" comment only describes the second case, but the branch served both. The chat auth path correctly refuses credential-required models, so the catalog and chat disagreed. The single-model route src/app/api/v1/models/[...model]/route.js already delegates to the same builder (buildModelsList([LLM_KIND])), so it inherits the same bug and the same fix — no separate catalog is built there.
Track whether the lookup completed; only fail open on actual failure. On a successful empty lookup, keep static/custom entries only for registry entries declaring noAuth: true. The non-empty connection path is untouched.
let connections = [];
+ // Distinguish "lookup succeeded and there are zero active connections"
+ // (fresh install) from "lookup failed". Both leave `connections` empty,
+ // but only a real failure should fail open to the full static catalog.
+ let connectionsLoaded = false;
try {
connections = await getProviderConnections();
connections = connections.filter(c => c.isActive !== false);
+ connectionsLoaded = true;
} catch (e) {
console.log("Could not fetch providers, returning all models");
}
@@
if (connections.length === 0) {
- // DB unavailable -> return static models, filtered by per-model kind
+ // Fresh install: only no-auth providers are usable. Advertising the full
+ // static catalog returns IDs chat later rejects with
+ // "No active credentials for provider". A lookup *failure* stays fail-open.
const aliasToProviderId = Object.fromEntries(
Object.entries(PROVIDER_ID_TO_ALIAS).map(([id, alias]) => [alias, id])
);
+ const isCatalogProviderAllowed = (alias) => {
+ if (!connectionsLoaded) return true;
+ const providerId = aliasToProviderId[alias] || alias;
+ return AI_PROVIDERS[providerId]?.noAuth === true;
+ };
for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
const providerId = aliasToProviderId[alias] || alias;
+ if (!isCatalogProviderAllowed(alias)) continue;
if (!providerMatchesKinds(providerId, kindFilter)) continue;
...
@@
const providerAlias = customModel.providerAlias;
if (!providerAlias) continue;
+ if (!isCatalogProviderAllowed(providerAlias)) continue;
...
Result matrix:
| Case | Result |
|---|---|
lookup resolves [] |
only noAuth provider models (and custom models on noAuth providers) |
| lookup throws | connectionsLoaded stays false → full static fail-open catalog preserved |
| non-empty connections | branch not entered → unchanged |
GET /v1/models/{id} |
shares the builder → 404 for credential-required IDs, 200 for no-auth IDs |
Reproduction test: tests/unit/fresh-install-model-catalog-339.test.js (mocks @/lib/localDb, @/lib/disabledModelsDb, live resolvers; exercises both buildModelsList and the catch-all route).
cd tests
npx vitest run --config ./vitest.config.js \
unit/fresh-install-model-catalog-339.test.js \
unit/v1-model-lookup-3588.test.js
TDD evidence — with the fix reverted: 2 failed | 3 passed, failures being
expected [ 'alicode-intl/qwen3.5-plus', …(636) ] to not include 'cc/claude-sonnet-5'
expected [ 'alicode-intl/qwen3.5-plus', …(638) ] to not include 'cc/my-locked'
With the fix applied:
Test Files 2 passed (2)
Tests 11 passed (11)
Asserted behaviors: no-auth models still listed on fresh install; credential-required static and custom models excluded; lookup failure still returns the full fail-open catalog (>50 models); non-empty connection path unchanged; GET /v1/models/cc/claude-sonnet-5 → 404; GET /v1/models/oc/muse-spark-1.2-contributor-free → 200. node --check src/app/api/v1/models/route.js passes. The benchmark's full gate was reported as 2334 pass, 84 known fail, 59 skip, no regression, with production build + GitHub test + federation E2E passing.
# Evidence - Problem class: model-catalog-credential-filtering - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-12T00:15:44.131Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A model-catalog endpoint conflated a successful provider lookup returning zero active connections with a failed provider lookup because both left connections as an empty array. The zero-connections branch advertised the full static provider catalog, while the chat path returned 404 No active credentials for provider for credential-required entries. Fix: track whether the connection lookup completed successfully. On successful zero active connections, include static/custom models only when the resolved provider registry entry declares noAuth true; on lookup failure, preserve the prior all-static fail-open catalog; leave the non-empty connection path unchanged. Exact-model lookup should share the same filtered builder. TDD evidence: 6 failed and 3 passed before the fix; 9 passed after. Full regression gate: 2334 pass, 84 known fail, 59 skip, no regression. Production build and GitHub test plus federation E2E passed.", "environment": "9router Next.js 16 plain-JS API routes, provider registry, SQLite connection store, Vitest", "language": "javascript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "model-catalog-credential-filtering", "provider": "openrouter", "solved_at": "2026-09-12T00:15:44.131Z", "version": "federation 672f2deb"}