fix: match compatible-provider models owned by public prefix (#13831)

* fix: match compatible-provider models owned by public prefix

Resolves #13829

The provider-scoped /v1/providers/{provider}/models route filters
unified-catalog rows by internal provider ID. For compatible provider
nodes, the catalog emits the configured public prefix in owned_by, so
all valid models were dropped and the endpoint returned an empty list.

Resolve the compatible node's prefix and accept it alongside the
internal ID when filtering and when stripping the prefix from returned
model ids.

* chore(quality): satisfy the format and lint-suppression gates for #13829

Two gate-only touch-ups on top of the fix, no behaviour change:

- prettier --check rejected tests/unit/provider-models-v1-route.test.ts over a
  double blank line before a test block.
- typing the map callback removed the file's only `any`, which left the frozen
  entry in config/quality/eslint-suppressions.json unused; the lint gate fails
  on a stale suppression, so it is pruned.

Both were found by running the gates locally, because this fork PR's workflow
runs are still awaiting maintainer approval and only the semgrep check had run.

Co-authored-by: sahildaswani <sahildaswani@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: sahildaswani <sahildaswani@users.noreply.github.com>
This commit is contained in:
Sahil Daswani
2026-09-17 00:37:11 +08:00
committed by GitHub
parent bc36b1d1aa
commit ff493da805
3 changed files with 70 additions and 7 deletions

View File

@@ -4435,11 +4435,6 @@
"count": 2
}
},
"tests/unit/provider-models-v1-route.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1
}
},
"tests/unit/provider-node-icon-url.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 6

View File

@@ -1,4 +1,5 @@
import { getUnifiedModelsResponse } from "@/app/api/v1/models/catalog";
import { getProviderNodeById } from "@/lib/db/providers/nodes";
import { getServiceModels } from "@/lib/db/serviceModels";
import { isServiceBackendPluginId } from "@/lib/services/serviceBackends";
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
@@ -40,6 +41,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ prov
const providerEntry = getRegistryEntry(rawProvider);
let providerId = rawProvider;
let providerAlias = rawProvider;
let compatiblePrefix: string | null = null;
if (providerEntry) {
providerId = providerEntry.id;
@@ -65,6 +67,14 @@ export async function GET(request: Request, { params }: { params: Promise<{ prov
{ status: 400 }
);
}
const compatibleNode = (await getProviderNodeById(rawProvider)) as {
prefix?: unknown;
} | null;
compatiblePrefix =
typeof compatibleNode?.prefix === "string" && compatibleNode.prefix.trim().length > 0
? compatibleNode.prefix.trim()
: null;
}
}
@@ -86,10 +96,17 @@ export async function GET(request: Request, { params }: { params: Promise<{ prov
if (!id) return id;
if (id.startsWith(`${providerAlias}/`)) return id.slice(providerAlias.length + 1);
if (id.startsWith(`${providerId}/`)) return id.slice(providerId.length + 1);
if (compatiblePrefix && id.startsWith(`${compatiblePrefix}/`)) {
return id.slice(compatiblePrefix.length + 1);
}
return id;
};
const filtered = payload.data.filter((model) => model?.owned_by === providerId);
const acceptedOwners = new Set([providerId, providerAlias]);
if (compatiblePrefix) acceptedOwners.add(compatiblePrefix);
const filtered = payload.data.filter(
(model) => typeof model?.owned_by === "string" && acceptedOwners.has(model.owned_by)
);
const deduped = new Map<string, Record<string, any>>();
for (const model of filtered) {

View File

@@ -15,7 +15,11 @@ const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-v1-provid
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const providerNodesDb = await import("../../src/lib/db/providers/nodes.ts");
const serviceModelsDb = await import("../../src/lib/db/serviceModels.ts");
const catalog = await import("../../src/app/api/v1/models/catalog.ts");
const routeModule = await import("../../src/app/api/v1/providers/[provider]/models/route.ts");
function makeRequest(provider: string) {
@@ -30,6 +34,9 @@ async function callGET(provider: string) {
test.beforeEach(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
catalog.__resetCatalogBuilderRunsForTest();
});
test.after(() => {
@@ -105,13 +112,57 @@ test("GET /v1/providers/:provider/models returns synced embedded service models"
assert.equal(res.status, 200);
assert.equal(body.object, "list");
assert.deepEqual(
body.data.map((model: any) => model.id),
body.data.map((model: { id: string }) => model.id),
["cli/gpt-5"]
);
assert.equal(body.data[0].owned_by, "cliproxyapi");
assert.equal(body.data[0].parent, null);
});
test("#13829: compatible provider IDs return synced models owned by their public prefix", async () => {
const providerId = "openai-compatible-chat-a1b2c3d4-e5f6-7890-abcd-ef1234567890";
const prefix = "hz";
const modelId = "Qwen/Qwen3.6-35B-A3B-FP8";
await providerNodesDb.createProviderNode({
id: providerId,
type: "openai-compatible-chat",
name: "Hetzner",
prefix,
apiType: "chat",
baseUrl: "https://inference.example.com/v1",
});
const connection = await providersDb.createProviderConnection({
provider: providerId,
authType: "apikey",
name: "Hetzner",
apiKey: "test-key",
isActive: true,
testStatus: "active",
});
await modelsDb.replaceSyncedAvailableModelsForConnection(providerId, String(connection.id), [
{ id: modelId, name: modelId, source: "imported" },
]);
const catalogResponse = await catalog.getUnifiedModelsResponse(
new Request("http://localhost/api/v1/models")
);
const catalogBody = (await catalogResponse.json()) as { data: Array<Record<string, unknown>> };
const catalogModel = catalogBody.data.find((model) => model.id === `${prefix}/${modelId}`);
assert.equal(catalogModel?.owned_by, prefix);
const res = await callGET(providerId);
const body = await res.json();
assert.equal(res.status, 200);
assert.deepEqual(
body.data.map((model: { id: string }) => model.id),
[modelId]
);
assert.equal(body.data[0].owned_by, prefix);
assert.equal(body.data[0].parent, null);
});
test("GET /v1/providers/:provider/models rejects non-matching connection-like strings", async () => {
// Looks like a connection ID but with wrong prefix
const res = await callGET("custom-compatible-chat-a1b2c3d4-e5f6-7890-abcd-ef1234567890");