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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ _Development cycle in progress — entries are added as work merges into `releas

### 🔧 Bug Fixes

- **fix(catalog):** imported/custom models on no-auth providers (e.g. The Old LLM) now appear in `GET /api/v1/models` and the Playground model dropdown. The custom-model eligibility gate required a DB connection row, which no-auth providers never have, so every imported model for them was silently dropped (built-in models were unaffected). The gate now applies the same no-auth bypass used elsewhere. ([#3200](https://github.com/diegosouzapw/OmniRoute/issues/3200) — thanks @tjengbudi, @a2belugin)
- **fix(claude):** Claude Code → `claude-opus-4-8` tool calls no longer break with `tool call could not be parsed (retry also failed)` — OmniRoute no longer force-injects `interleaved-thinking` / `advanced-tool-use` / `effort` beta flags the client never negotiated. When the client sends its own `anthropic-beta` header, those betas are only emitted if the client requested them; opaque clients (OAuth identity cloak) keep the full set unchanged. ([#3415](https://github.com/diegosouzapw/OmniRoute/issues/3415) — thanks @Forcerecon)
- **fix(translator):** Vertex AI tool calls no longer fail with `400 Unknown name "id" at contents[].parts[].function_call` — the OpenAI-style `id` field is now stripped from `functionCall`/`functionResponse` parts when the routed provider is `vertex`/`vertex-partner`. The public Gemini API still receives `id` (required for Gemini 3+ signature matching). ([#3440](https://github.com/diegosouzapw/OmniRoute/issues/3440) — thanks @nullbytef0x)

Expand Down
11 changes: 11 additions & 0 deletions src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1150,7 +1150,18 @@ export async function getUnifiedModelsResponse(
if (!modelId) continue;
if (model.isHidden === true) continue;
if (getModelIsHidden(canonicalProviderId, modelId)) continue;
// noAuth providers (e.g. theoldllm) never create DB connection rows, so the
// eligibility gate would drop every imported/custom model for them (#3200).
// Mirror providerSupportsModel's noAuth bypass (#2798) — keep the gate for
// auth providers (preserving parentProviderType for compatible UUID nodes).
const isNoAuthProvider = Object.values(NOAUTH_PROVIDERS).some(
(p) =>
p.id === canonicalProviderId ||
p.id === providerId ||
("alias" in p && p.alias === alias)
);
Comment on lines +1157 to +1162

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

Since all providers in NOAUTH_PROVIDERS are guaranteed to have an alias property, we can simplify the check by removing the "alias" in p guard. Additionally, since isNoAuthProvider is constant for all models of a given provider, it would be more efficient to hoist this computation out of the inner for (const model of providerCustomModels) loop to avoid redundant array allocations and iterations.

          const isNoAuthProvider = Object.values(NOAUTH_PROVIDERS).some(
            (p) => p.id === canonicalProviderId || p.id === providerId || p.alias === alias
          );

if (
!isNoAuthProvider &&
!hasEligibleConnectionForModel(
getConnectionsForProvider(alias, canonicalProviderId, providerId, parentProviderType),
modelId
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/noauth-imported-models-3200.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Regression test for #3200 — imported/custom models on noAuth providers were missing
// from GET /api/v1/models (and therefore from the Playground model dropdown), while
// BUILT-IN and CUSTOM models on regular auth providers showed up fine.
//
// Root cause: the custom-models loop in catalog.ts gated every model through
// hasEligibleConnectionForModel(getConnectionsForProvider(...)). noAuth providers
// (e.g. theoldllm / alias "tllm") have NO DB connection rows, so getConnectionsForProvider
// returns [] and hasEligibleConnectionForModel([]) === false → the model was dropped.
// Built-in models survived because they go through providerSupportsModel(), which has a
// noAuth bypass (#2798). This test asserts an IMPORTED model on a noAuth provider appears.

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";

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-noauth-imported-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "catalog-test-secret";

const core = await import("../../src/lib/db/core.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");

async function resetStorage() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}

test.beforeEach(async () => {
await resetStorage();
});

test.after(async () => {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});

test("#3200 imported model on a noAuth provider (theoldllm) appears in /api/v1/models", async () => {
// theoldllm is a noAuth provider (alias "tllm") — it never creates a DB connection row.
// Import a model that is NOT a built-in theoldllm model, so its presence is solely due
// to the custom/imported path (the path the bug breaks).
await modelsDb.addCustomModel("theoldllm", "my-imported-model-3200", "My Imported Model", "imported");

const response = await v1ModelsCatalog.getUnifiedModelsResponse(
new Request("http://localhost/api/v1/models")
);
const body = (await response.json()) as { data: Array<{ id: string }> };
const ids = new Set(body.data.map((m) => m.id));

assert.equal(response.status, 200);
assert.ok(
ids.has("tllm/my-imported-model-3200"),
"imported model on noAuth provider must appear under its alias prefix"
);
});

test("#3200 custom/imported models on auth providers still appear (no regression)", async () => {

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

The test title states that custom/imported models on auth providers 'still appear', but the test actually asserts that they do not appear (leak) when there are no active connections. Let's update the title to accurately reflect the test's assertion.

test("#3200 custom/imported models on auth providers without connections do not leak (no regression)", async () => {

// kiro is an auth provider; with a manual custom model added, the alias-prefixed id
// must still be present (the active-connection eligibility path is unchanged).
// No connection seeded here — kiro custom models require an eligible connection, so
// this guards that the fix does NOT make auth-provider custom models appear without one.
await modelsDb.addCustomModel("kiro", "custom-kiro-3200", "Custom Kiro");

const response = await v1ModelsCatalog.getUnifiedModelsResponse(
new Request("http://localhost/api/v1/models")
);
const body = (await response.json()) as { data: Array<{ id: string }> };
const ids = new Set(body.data.map((m) => m.id));

assert.equal(response.status, 200);
// Auth provider with NO active connection → custom model must NOT leak in.
assert.equal(
ids.has("kiro/custom-kiro-3200"),
false,
"auth-provider custom model must stay gated behind an eligible connection"
);
});