fix(providers): Cloudflare Workers AI discovery uses model names, not UUIDs (#4259) (#4282)

Cloudflare's /ai/models/search returns { id: "<uuid>", name: "@cf/..." } where
name is the callable slug and id is an internal UUID. The cloudflare-ai discovery
config passed the raw objects through (parseResponse: data.result), so buildResponse
used id (the UUID) as the model id — the dashboard/import listed UUIDs instead of
@cf/... model names. Map each result's name -> id (mirrors the gemini/huggingface/
clarifai parseResponse normalizers in the same map); falls through to the local
catalog on error so import never breaks.

TDD: tests/unit/cloudflare-models-uuid-4259.test.ts (RED on UUID ids -> GREEN on slugs).

Closes #4259
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-19 12:25:35 -03:00
committed by GitHub
parent 98b0d5e51e
commit 550440f65f
4 changed files with 135 additions and 2 deletions

View File

@@ -8,6 +8,10 @@
_In development — bullets added per PR; finalized at release._
### 🐛 Fixed
- **fix(providers): Cloudflare Workers AI model discovery shows model names, not UUIDs** — importing a Cloudflare Workers AI key listed models with internal UUID identifiers (e.g. `429b9e8b-d99e-…`) instead of their usable slugs (`@cf/meta/llama-3.1-8b-instruct`). Cloudflare's `/ai/models/search` returns `{ id: "<uuid>", name: "@cf/…" }`, and discovery was passing the raw objects through — so the UUID `id` became the callable model id. The `cloudflare-ai` discovery now maps each result's `name` → id, surfacing the real `@cf/…` model ids. ([#4259](https://github.com/diegosouzapw/OmniRoute/issues/4259) — thanks @FerLuisxd)
---
## [3.8.29] — 2026-06-19

View File

@@ -1,5 +1,6 @@
{
"_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.",
"_rebaseline_2026_06_19_4259_cloudflare_uuid_models": "Issue #4259 own growth: providers/[id]/models/route.ts 2538->2554 (+16 = the cloudflare-ai PROVIDER_MODELS_CONFIG `parseResponse` now maps each Cloudflare `/ai/models/search` result `name`->id + a 3-line comment, replacing the one-line `data.result || []` passthrough). Cloudflare Workers AI returns `{ id: \"<uuid>\", name: \"@cf/...\" }` — `name` is the callable slug, `id` is an internal UUID. The old passthrough fed the raw objects to buildResponse (id: m.id), so the dashboard/import surfaced UUIDs instead of `@cf/...` model ids. The map+filter is cohesive normalization at the config entry (mirrors the gemini/huggingface/clarifai parseResponse normalizers in the same map); not extractable. Structural shrink of this route tracked in #3789.",
"_rebaseline_2026_06_19_4249_vercel_gateway_live_models": "Issue #4249 own growth: providers/[id]/models/route.ts 2534->2538 (+4 = one NAMED_OPENAI_STYLE_PROVIDERS Set entry `vercel-ai-gateway` + a 3-line comment). Same fix shape as #4202 (zenmux) / #3976 (llm7/byteplus): vercel-ai-gateway carries a real baseUrl (https://ai-gateway.vercel.sh/v1/chat/completions, format openai) but was unclassified by every live-fetch branch, so import served the 5-entry hardcoded registry catalog instead of the upstream list — the import button looked broken (loaded nothing usable) while manual add worked. The `<baseUrl>/models` probe (after stripping /chat/completions) resolves to https://ai-gateway.vercel.sh/v1/models; falls back to the local catalog on upstream error so import never breaks. Pure additive Set membership; not extractable.",
"_rebaseline_2026_06_19_4227_cursor_cloud_ui": "PR #4250 (#4227 Cursor Cloud Agent) cross-layer UI wiring own growth: cloud-agents/page.tsx 913->922 (+9 = one CLOUD_AGENTS dropdown entry for the new `cursor-cloud` provider, mirroring the existing jules/devin/codex-cloud entries). The backend agent (cursor.ts) + registry/types/credentials-route landed in the PR, but the dashboard CLOUD_AGENTS list, health PROVIDER_NAMES, and lobeProviderIcons maps are hardcoded (client/server boundary — the registry can't be imported into the client page), so the agent was API-usable but not selectable in the UI and rendered with the Jules fallback name/icon. The +9 is the single presentational data entry at the existing hardcoded list; not extractable (it IS the list). The health-name + icon adds land in non-frozen files.",
"_rebaseline_2026_06_19_cost_telemetry_combo_prettier": "NOT this PR's logic change: combo.ts 2601->2605 (+4) is a pure Prettier reflow applied by the cost-telemetry-parity merge commit's lint-staged hook. The DEFAULT_WEIGHTS/ProviderCandidate/ScoringWeights import from ./autoCombo/scoring.ts is 102 chars (> printWidth 100) on release (it landed via a GitHub merge that never ran the local Prettier hook), so the hook wrapped it to the canonical 4-line form. Zero logic change (git diff -w empty); cost-telemetry-parity never edits combo.ts. Reverting is futile — Prettier re-wraps the 102-char import on any later commit. Bumped here so the merge surfacing the reflow carries its own baseline.",
@@ -135,7 +136,7 @@
"src/app/(dashboard)/dashboard/usage/components/EvalsTab.tsx": 2148,
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": 1069,
"src/app/api/oauth/[provider]/[action]/route.ts": 918,
"src/app/api/providers/[id]/models/route.ts": 2538,
"src/app/api/providers/[id]/models/route.ts": 2554,
"src/app/api/providers/[id]/test/route.ts": 842,
"src/app/api/usage/analytics/route.ts": 941,
"src/app/api/v1/models/catalog.ts": 1465,

View File

@@ -664,7 +664,23 @@ const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.result || [],
// #4259: Cloudflare's `/ai/models/search` returns `{ id: "<uuid>", name: "@cf/..." }`.
// `name` is the usable model slug; `id` is an internal UUID. Map `name`→id so the
// dashboard/import surfaces callable model ids (`@cf/...`) instead of UUIDs.
parseResponse: (data) =>
(data.result || [])
.map((model: any) => {
const slug = typeof model?.name === "string" ? model.name : "";
if (!slug) return null;
return {
id: slug,
name: slug,
...(typeof model?.description === "string" && model.description
? { description: model.description }
: {}),
};
})
.filter(Boolean),
},
synthetic: {
url: "https://api.synthetic.new/openai/v1/models",

View File

@@ -0,0 +1,112 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// #4259: Cloudflare Workers AI `/ai/models/search` returns objects shaped like
// `{ id: "<uuid>", name: "@cf/meta/llama-3.1-8b-instruct" }` — the human-usable
// model identifier is `name`, while `id` is an internal UUID. Discovery must use
// `name` as the callable model id; otherwise the dashboard/import shows UUIDs.
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-cf-models-4259-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const providerModelsRoute = await import("../../src/app/api/providers/[id]/models/route.ts");
const originalFetch = globalThis.fetch;
async function resetStorage() {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
async function seedConnection(provider: string, overrides: Record<string, any> = {}) {
return providersDb.createProviderConnection({
provider,
authType: overrides.authType || "apikey",
name: overrides.name || `${provider}-${Math.random().toString(16).slice(2, 8)}`,
apiKey: overrides.apiKey,
isActive: overrides.isActive ?? true,
testStatus: overrides.testStatus || "active",
providerSpecificData: overrides.providerSpecificData || {},
});
}
async function callRoute(connectionId: string, search = "") {
return providerModelsRoute.GET(
new Request(`http://localhost/api/providers/${connectionId}/models${search}`),
{ params: { id: connectionId } }
);
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("#4259 cloudflare-ai discovery uses the model name (slug) as id, not the UUID", async () => {
const connection = await seedConnection("cloudflare-ai", {
apiKey: "cf-token",
providerSpecificData: { accountId: "acc-123" },
});
const LLAMA_UUID = "429b9e8b-d99e-44de-91ad-706cf8183658";
const QWEN_UUID = "f8703a00-ed54-4c83-bcd9-706cf8183999";
let calledUrl = "";
globalThis.fetch = (async (url: any) => {
calledUrl = String(url);
return Response.json({
result: [
{
id: LLAMA_UUID,
name: "@cf/meta/llama-3.1-8b-instruct",
description: "Llama 3.1 8B Instruct",
task: { name: "Text Generation" },
},
{
id: QWEN_UUID,
name: "@cf/qwen/qwen1.5-0.5b-chat",
description: "Qwen 1.5 0.5B Chat",
task: { name: "Text Generation" },
},
],
});
}) as typeof fetch;
const response = await callRoute(connection.id, "?refresh=true");
assert.equal(response.status, 200);
// Sanity: hit the Cloudflare models search endpoint with the configured account.
assert.ok(
calledUrl.includes("/accounts/acc-123/ai/models/search"),
`unexpected discovery URL: ${calledUrl}`
);
const body = await response.json();
assert.equal(body.source, "api");
const ids: string[] = body.models.map((m: any) => m.id);
// The human-usable slug must be the id (RED before the fix — id was the UUID).
assert.ok(
ids.includes("@cf/meta/llama-3.1-8b-instruct"),
`expected slug id, got ${JSON.stringify(ids)}`
);
assert.ok(
ids.includes("@cf/qwen/qwen1.5-0.5b-chat"),
`expected slug id, got ${JSON.stringify(ids)}`
);
// The internal UUID must never be exposed as a callable model id.
assert.ok(!ids.includes(LLAMA_UUID), "UUID must not be used as a model id");
assert.ok(!ids.includes(QWEN_UUID), "UUID must not be used as a model id");
});