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
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,13 @@ ALLOW_API_KEY_REVEAL=false
# Default: false
# OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS=false

# Legacy resolveBareModelToConnectionDefault behaviour (#4825): replace ANY bare
# model name (not only "auto"/empty) with the selected connection's defaultModel.
# Off by default: a concrete bare name such as claude-haiku-4-5 is kept.
# Used by: open-sse/services/model.ts.
# Default: false
# BARE_MODEL_DEFAULT_OVERRIDE_LEGACY=false

# Per-model concurrency cap for round-robin combos (#9100).
# Used by: open-sse/services/comboConfig.ts — the round-robin combo semaphore
# was hard-capped at 3 concurrent requests per model with no override, which
Expand Down
1 change: 1 addition & 0 deletions changelog.d/fixes/bare-model-default-only-placeholder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(routing):** a connection's `defaultModel` (`resolveBareModelToConnectionDefault`, added in #4825) now only replaces a placeholder bare model (`auto` or empty); a concrete bare name such as `claude-haiku-4-5` is no longer silently swapped for another provider's default. `BARE_MODEL_DEFAULT_OVERRIDE_LEGACY=true` restores the old behaviour.
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
| `OMNIROUTE_PAYLOAD_RULES_PATH` | `./config/payloadRules.json` | `open-sse/services/payloadRules.ts` | Path to payload manipulation rules JSON file (per-model/protocol upstream tweaks). |
| `OMNIROUTE_PAYLOAD_RULES_RELOAD_MS` | `5000` | `open-sse/services/payloadRules.ts` | Reload interval (ms) for hot-reloading the payload rules file. Minimum `1000`. |
| `OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS` | `false` | `open-sse/services/model.ts` | Opt-in: route bare `claude-*` model IDs from Claude Code clients through the Claude Code OAuth account instead of requiring a provider prefix. Explicit provider prefixes still win. Also configurable via a dashboard toggle on the Claude provider page. |
| `BARE_MODEL_DEFAULT_OVERRIDE_LEGACY` | `false` | `open-sse/services/model.ts` | Legacy `resolveBareModelToConnectionDefault` behaviour (#4825): replace any bare model name with the selected connection's `defaultModel`. Off by default, only `auto` or an empty model is replaced; logs one warning per pair when on. |
| `COMBO_CONCURRENCY_PER_MODEL` | `3` | `open-sse/services/comboConfig.ts` | Per-model concurrency cap for round-robin combos (#9100). The round-robin combo semaphore was hard-capped at 3 concurrent requests per model with no override, serializing higher-concurrency traffic behind that cap. Validated to `>= 1`, clamped to `<= 32`. |
| `DISABLE_CONTEXT_WINDOW_CHECKS` | `false` | `open-sse/handlers/chatCore.ts` | Dangerous opt-in that skips OmniRoute's local context-window / max-input-token check for direct single-model requests. Upstream providers still enforce their actual limits; prompt compression and the model's own output-token cap remain active. Effective precedence is Feature Flags DB override > environment variable > default; no restart is required. |
| `OMNIROUTE_SELF_HOSTED_PROVIDERS` | _(unset)_ | `open-sse/services/selfHostedEntry.ts` | Inline YAML `providers:` document (RIC-738, D4). When set (with or without a `strategy:` block), `/v1/chat/completions` diverts to the self-hosted unified OpenAI-compatible entry instead of the cloud pipeline. Unset (the default): the route falls straight through to the existing cloud pipeline. See `docs/routing/SELF_HOSTED_OPENAI_ENTRY.md`. |
Expand Down
34 changes: 33 additions & 1 deletion open-sse/services/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,15 @@ interface ProviderConnectionLike {
* placeholder. A "/"-qualified model name is an explicit provider/model choice
* and is always returned untouched.
*
* Only a placeholder is replaced: the resolved model must be empty, null or
* "auto". A concrete bare model name (e.g. a catalog short name such as
* "claude-haiku-4-5") is an explicit choice too; replacing it with the
* connection's `defaultModel` silently served a different model (a Gemini
* default answering a Claude request) with no error anywhere.
*
* Set `BARE_MODEL_DEFAULT_OVERRIDE_LEGACY=true` to restore the previous
* behaviour (any bare name is replaced); it logs one warning per pair.
*
* Pure function — `requestedModelStr` is the raw client-facing model string
* (used only to decide whether the name is "bare"); `resolvedModel` is the
* already-resolved model that would otherwise be sent upstream.
Expand All @@ -152,12 +161,35 @@ export function resolveBareModelToConnectionDefault(
if (typeof requestedModelStr !== "string" || requestedModelStr.includes("/")) {
return fallback;
}
if (typeof connectionDefaultModel === "string" && connectionDefaultModel.length > 0) {
if (typeof connectionDefaultModel !== "string" || connectionDefaultModel.length === 0) {
return fallback;
}
if (fallback === null || fallback === "" || fallback === "auto") {
return connectionDefaultModel;
}
if (fallback === connectionDefaultModel) return fallback;
if (isBareModelDefaultOverrideLegacy()) {
warnLegacyBareModelOverride(fallback, connectionDefaultModel);
return connectionDefaultModel;
}
return fallback;
}

function isBareModelDefaultOverrideLegacy() {
return isTruthyEnv(process.env.BARE_MODEL_DEFAULT_OVERRIDE_LEGACY);
}

const legacyBareModelOverrideWarned = new Set<string>();

function warnLegacyBareModelOverride(model: string, connectionDefaultModel: string) {
const key = `${model}|${connectionDefaultModel}`;
if (legacyBareModelOverrideWarned.has(key)) return;
legacyBareModelOverrideWarned.add(key);
log.warn(
`BARE_MODEL_DEFAULT_OVERRIDE_LEGACY: '${model}' replaced by the connection defaultModel '${connectionDefaultModel}'`
);
}

function isCrossProxyModelCompatEnabled() {
const raw = process.env.MODEL_ALIAS_COMPAT_ENABLED;
return raw !== "false" && raw !== "0";
Expand Down
27 changes: 23 additions & 4 deletions tests/unit/hidden-model-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,14 +170,33 @@ test("a model hidden for another provider does not block a visible OpenAI model"
assert.deepEqual(dispatchedModels, ["gpt-4o-mini"]);
});

test("a connection default cannot replace a visible request with a hidden model", async () => {
test("a connection default does not replace a visible concrete bare model", async () => {
await providers.updateProviderConnection(connectionId, { defaultModel: "gpt-4o-mini" });
assert.equal((await chat("gpt-4o")).status, 200);
assert.deepEqual(dispatchedModels, ["gpt-4o-mini"]);
assert.deepEqual(dispatchedModels, ["gpt-4o"]);
dispatchedModels.length = 0;
models.mergeModelCompatOverride("openai", "gpt-4o-mini", { isHidden: true });
assert.equal((await chat("gpt-4o")).status, 404);
assert.deepEqual(dispatchedModels, []);
assert.equal((await chat("gpt-4o")).status, 200);
assert.deepEqual(dispatchedModels, ["gpt-4o"]);
});

test("a connection default cannot replace a visible request with a hidden model", async () => {
// The default only replaces a concrete bare model under the legacy override,
// so the hidden-model gate on that swap is exercised with the override on.
const before = process.env.BARE_MODEL_DEFAULT_OVERRIDE_LEGACY;
process.env.BARE_MODEL_DEFAULT_OVERRIDE_LEGACY = "true";
try {
await providers.updateProviderConnection(connectionId, { defaultModel: "gpt-4o-mini" });
assert.equal((await chat("gpt-4o")).status, 200);
assert.deepEqual(dispatchedModels, ["gpt-4o-mini"]);
dispatchedModels.length = 0;
models.mergeModelCompatOverride("openai", "gpt-4o-mini", { isHidden: true });
assert.equal((await chat("gpt-4o")).status, 404);
assert.deepEqual(dispatchedModels, []);
} finally {
if (before === undefined) delete process.env.BARE_MODEL_DEFAULT_OVERRIDE_LEGACY;
else process.env.BARE_MODEL_DEFAULT_OVERRIDE_LEGACY = before;
}
});

test("visibility does not override API-key denial or grant access to visible models", async () => {
Expand Down
58 changes: 53 additions & 5 deletions tests/unit/resolve-bare-model-to-connection-default-474.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@ test("bare model name resolves to the connection defaultModel", () => {
});

test('"/"-qualified model name is left untouched even when a defaultModel exists', () => {
const effective = resolveBareModelToConnectionDefault(
"openai/gpt-4o",
"gpt-4o",
"gpt-4o-mini"
);
const effective = resolveBareModelToConnectionDefault("openai/gpt-4o", "gpt-4o", "gpt-4o-mini");
assert.equal(effective, "gpt-4o");
});

Expand All @@ -41,3 +37,55 @@ test("null/undefined requested model strings are treated as non-bare (no overrid
test("null resolved model with no defaultModel returns null", () => {
assert.equal(resolveBareModelToConnectionDefault("auto", null, null), null);
});

// The connection defaultModel only replaces a placeholder ("auto" or empty).
// A concrete bare model name (e.g. a catalog short name like
// "claude-haiku-4-5") is an explicit model choice: swapping it for the
// connection's defaultModel silently served a different model (a Gemini
// default answering a Claude request) with no error anywhere.

test("concrete bare model name is NOT replaced by the connection defaultModel", () => {
assert.equal(
resolveBareModelToConnectionDefault("claude-haiku-4-5", "claude-haiku-4-5", "gemini-2.5-flash"),
"claude-haiku-4-5"
);
assert.equal(resolveBareModelToConnectionDefault("gpt-4o", "gpt-4o", "gpt-4o-mini"), "gpt-4o");
});

test("bare alias that resolved to a concrete model keeps the resolved model", () => {
// The alias "haiku" resolved to a concrete id; the defaultModel must not win.
assert.equal(
resolveBareModelToConnectionDefault("haiku", "claude-haiku-4-5-20251001", "gemini-2.5-flash"),
"claude-haiku-4-5-20251001"
);
});

test("empty or null resolved model still falls back to the connection defaultModel", () => {
assert.equal(resolveBareModelToConnectionDefault("auto", "", "gpt-4o-mini"), "gpt-4o-mini");
assert.equal(resolveBareModelToConnectionDefault("auto", null, "gpt-4o-mini"), "gpt-4o-mini");
});

test("defaultModel equal to the resolved model is returned as-is", () => {
assert.equal(
resolveBareModelToConnectionDefault("gpt-4o-mini", "gpt-4o-mini", "gpt-4o-mini"),
"gpt-4o-mini"
);
});

test("BARE_MODEL_DEFAULT_OVERRIDE_LEGACY=true restores the old replacement", () => {
const before = process.env.BARE_MODEL_DEFAULT_OVERRIDE_LEGACY;
process.env.BARE_MODEL_DEFAULT_OVERRIDE_LEGACY = "true";
try {
assert.equal(
resolveBareModelToConnectionDefault(
"claude-haiku-4-5",
"claude-haiku-4-5",
"gemini-2.5-flash"
),
"gemini-2.5-flash"
);
} finally {
if (before === undefined) delete process.env.BARE_MODEL_DEFAULT_OVERRIDE_LEGACY;
else process.env.BARE_MODEL_DEFAULT_OVERRIDE_LEGACY = before;
}
});
25 changes: 22 additions & 3 deletions tests/unit/suffix-effort-propagation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ for (const choice of [{ reasoning: false }, { thinking: { type: "disabled" } },
});
}

test("connection default replacement does not inherit a bare alias suffix", async () => {
async function sendBareAliasWithConnectionDefault(): Promise<Record<string, unknown> | undefined> {
await harness.resetStorage();
const connection = await seedConnection("agnes");
const { updateProviderConnection } = await import("../../src/lib/db/providers.ts");
Expand Down Expand Up @@ -143,8 +143,27 @@ test("connection default replacement does not inherit a bare alias suffix", asyn
})
);
assert.equal(response.status, 200, await response.text());
assert.equal(sent?.model, "agnes-default-replacement");
assert.equal(sent?.reasoning_effort, undefined);
return sent;
}

test("a bare alias keeps its concrete target and suffix effort over a connection default", async () => {
const sent = await sendBareAliasWithConnectionDefault();
assert.equal(sent?.model, MODEL);
assert.equal(sent?.reasoning_effort, "max");
});

test("connection default replacement does not inherit a bare alias suffix", async () => {
// The default only replaces a concrete bare model under the legacy override.
const before = process.env.BARE_MODEL_DEFAULT_OVERRIDE_LEGACY;
process.env.BARE_MODEL_DEFAULT_OVERRIDE_LEGACY = "true";
try {
const sent = await sendBareAliasWithConnectionDefault();
assert.equal(sent?.model, "agnes-default-replacement");
assert.equal(sent?.reasoning_effort, undefined);
} finally {
if (before === undefined) delete process.env.BARE_MODEL_DEFAULT_OVERRIDE_LEGACY;
else process.env.BARE_MODEL_DEFAULT_OVERRIDE_LEGACY = before;
}
});

test("a different combo provider does not inherit the original suffix", async () => {
Expand Down
Loading