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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 17 additions & 1 deletion src/app/api/providers/[id]/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Comment on lines +670 to +672

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

If the Cloudflare API returns an unexpected response where data.result is not an array (for example, an error payload or an empty object), (data.result || []) will evaluate to that non-array value. Calling .map() on it will then throw a TypeError, causing a hard 500 error instead of gracefully falling back to the local catalog. Using Array.isArray ensures the code is robust against unexpected API responses.

Suggested change
parseResponse: (data) =>
(data.result || [])
.map((model: any) => {
parseResponse: (data) =>
(Array.isArray(data?.result) ? 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",
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/cloudflare-models-uuid-4259.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading