diff --git a/CHANGELOG.md b/CHANGELOG.md index 887c91f7f6..356f2c9148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### ✨ New Features +- **feat(sse):** **provider-family auto combos** — `auto/glm`, `auto/minimax`, `auto/mimo`, `auto/zai`, `auto/gemma`, `auto/llama`, `auto/gemini` ([#6453](https://github.com/diegosouzapw/OmniRoute/issues/6453)) — new routable ids that materialize an on-demand virtual combo spanning whatever installed backends currently expose that model family, degrading gracefully as backends rotate. A new pure `open-sse/services/autoCombo/modelFamily.ts` (`detectModelFamily`) classifies by model-id prefix for six families; `zai` is instead resolved by provider id (z.ai's hosted API serves the same `glm-*` model ids as every other GLM backend, so `auto/zai` means "route to my z.ai backend specifically" vs `auto/glm`'s "any connected GLM backend"). Reuses the existing `createVirtualAutoCombo` on-demand materialization path (no DB writes) and the `/v1/models` catalog advertising loop. Regression guard: `tests/unit/autoCombo/provider-family-combos.test.ts` (11). - **feat(proxy):** native **proxy-pool round-robin / egress IP rotation** ([#6365](https://github.com/diegosouzapw/OmniRoute/issues/6365)) — a scope (global / provider / account) can now hold **multiple** proxies as a pool with a rotation strategy, so outbound requests cycle their egress IP instead of pinning one proxy per scope. Migration `117_proxy_pool_rotation.sql` lifts the `UNIQUE(scope, scope_id)` constraint (rebuild via the canonical rename/copy/drop; existing single assignments become 1-element pools) and adds a `proxy_scope_rotation` companion table holding the per-scope strategy + a persisted monotonic round-robin cursor. Strategies: `round-robin` (default, monotonic cursor — never `Math.random`), `random`, and `sticky-per-N-min`. Resolution (`resolveProxyForScopeFromRegistry` / `resolveProxyForConnectionFromRegistry`) now fetches the alive, position-ordered candidate set (unchanged `PROXY_ALIVE_PREDICATE`) and applies the strategy; an empty / all-dead pool still returns `null` — the #6246 fail-closed guard is **untouched** (never falls through to direct egress). Backend + DB only; dashboard pool-builder UI is a follow-up. Regression guard: `tests/unit/proxy-pool-rotation-6365.test.ts` (8, incl. fail-closed + backward-compat). - **feat(providers):** end-to-end **tool/function calling on the native Gemini `/v1beta` endpoint** ([#6222](https://github.com/diegosouzapw/OmniRoute/issues/6222)) — both directions of the Gemini↔OpenAI conversion now preserve tool data (previously silently dropped). Request side: `convertGeminiToInternal` (extracted to its own testable module) maps `tools[].functionDeclarations` → OpenAI `tools`, prior `functionCall` parts → assistant `tool_calls`, and `functionResponse` parts → `tool`-role messages. Response side: `convertOpenAIResponseToGemini` emits `parts[].functionCall {name,args}` from `message.tool_calls`, and the streaming `openAIChunkToGeminiChunk` accumulates fragmented `tool_calls` deltas by index into complete `functionCall` parts. The non-Gemini client paths (Claude, OpenAI-Responses) already preserved tool calls — this closes the gap specific to the native Gemini surface. Regression guard: `tests/unit/v1beta-gemini-tool-calling-6222.test.ts` (6, incl. a streaming SSE round-trip). - **feat(providers):** copilot-m365-web **enterprise / work tier** support ([#6334](https://github.com/diegosouzapw/OmniRoute/issues/6334)) — mirrors the EDU-tier pattern (#6210): `M365ConnectionParams` gains an `agent` field, a new opt-in `M365_ENTERPRISE_OVERRIDES` preset (`agent=work`, `scenario=officeweb`, `licenseType=Premium`) applies via `providerSpecificData.tier="enterprise"` (alias `"work"`), and `agent` is also overridable directly via `providerSpecificData.agent`. `buildWsUrl` was hardcoding `agent="web"` (the one enterprise-distinguishing param with no override path), so a Premium work account handshook then returned an empty stream. The individual and EDU paths are untouched. Kilo's dup flag vs #6210 (EDU tier) was a false positive — different tier. Regression guard: `tests/unit/copilot-m365-enterprise-6334.test.ts` (7). End-to-end confirmation on a real Premium work account is a live-VPS validation follow-up (Hard Rule #18). (thanks @Forcerecon) diff --git a/open-sse/services/autoCombo/builtinCatalog.ts b/open-sse/services/autoCombo/builtinCatalog.ts index 134b48205c..cc80184aa5 100644 --- a/open-sse/services/autoCombo/builtinCatalog.ts +++ b/open-sse/services/autoCombo/builtinCatalog.ts @@ -1,6 +1,9 @@ import type { AutoVariant } from "./autoPrefix"; import { VALID_VARIANTS } from "./autoPrefix"; import { parseAutoSuffix } from "./suffixComposition"; +import { isValidModelFamily, AUTO_FAMILY_IDS } from "./modelFamily"; + +export { AUTO_FAMILY_IDS }; /** * Built-in `auto/*` catalog → AutoVariant resolution. @@ -78,7 +81,11 @@ export function resolveAutoVariant(modelStr: string, suffix: string): ResolvedAu * decide whether an `auto/` model is a valid built-in before materializing it. */ export function isRecognizedBuiltinAuto(modelStr: string, suffix: string): boolean { - return resolveAutoVariant(modelStr, suffix).recognized || parseAutoSuffix(suffix).valid; + return ( + resolveAutoVariant(modelStr, suffix).recognized || + parseAutoSuffix(suffix).valid || + isValidModelFamily(suffix) + ); } export async function createBuiltinAutoCombo(modelStr: string, suffix: string) { @@ -105,5 +112,15 @@ export async function createBuiltinAutoCombo(modelStr: string, suffix: string) { return virtualCombo; } + // #6453: `auto/` (e.g. auto/glm, auto/minimax, auto/zai, auto/mimo, + // auto/gemma, auto/llama, auto/gemini) — spans whatever installed backends + // currently expose that model family, degrading gracefully as backends rotate. + if (isValidModelFamily(suffix)) { + const virtualCombo = await createVirtualAutoCombo(undefined, { family: suffix }); + virtualCombo.name = modelStr; + virtualCombo.id = modelStr; + return virtualCombo; + } + throw new Error(`Unknown built-in auto combo: ${modelStr}`); } diff --git a/open-sse/services/autoCombo/modelFamily.ts b/open-sse/services/autoCombo/modelFamily.ts new file mode 100644 index 0000000000..ab3b950cea --- /dev/null +++ b/open-sse/services/autoCombo/modelFamily.ts @@ -0,0 +1,95 @@ +/** + * #6453 — Provider-family auto combos. + * + * `auto/` (e.g. `auto/glm`, `auto/minimax`) is a NEW axis alongside the + * existing task-mode (`auto/best-coding`) and category:tier (`auto/coding:fast`) + * combos: instead of grouping by routing intent, it groups by underlying MODEL + * FAMILY and materializes an on-demand virtual combo spanning whatever installed + * backends currently expose that family, degrading gracefully as backends + * rotate — same on-demand mechanism (`createVirtualAutoCombo`), a different + * candidate filter. + * + * Kept as a pure, dependency-free module so `detectModelFamily` is unit-testable + * in isolation without touching the DB/registry-backed virtual factory. + */ + +export type ModelFamily = "glm" | "minimax" | "mimo" | "zai" | "gemma" | "llama" | "gemini"; + +export const MODEL_FAMILIES: readonly ModelFamily[] = [ + "glm", + "minimax", + "mimo", + "zai", + "gemma", + "llama", + "gemini", +]; + +const MODEL_FAMILY_SET: ReadonlySet = new Set(MODEL_FAMILIES); + +/** Model-id prefix → family. Matched against the bare model id (provider prefix, if + * any, stripped) so both `glm-5.2` and `zai/glm-5.2` resolve the same way. */ +const FAMILY_ID_PATTERNS: ReadonlyArray<{ family: ModelFamily; pattern: RegExp }> = [ + { family: "glm", pattern: /^glm-/i }, + { family: "minimax", pattern: /^minimax-/i }, + { family: "mimo", pattern: /^mimo-/i }, + { family: "gemma", pattern: /^gemma-/i }, + { family: "llama", pattern: /^llama-/i }, + { family: "gemini", pattern: /^gemini-/i }, +]; + +/** + * `zai` is deliberately NOT a model-id prefix rule: Zhipu's z.ai hosted API serves + * the same `glm-*` model ids as every other GLM backend (`glm` provider, custom + * OpenAI/Anthropic-compatible connections, etc — see + * `open-sse/config/providers/registry/zai/index.ts`). Aliasing `auto/zai` to a + * model-name prefix would make it identical to `auto/glm`, so instead it is + * resolved by PROVIDER id: "route to my z.ai backend specifically", distinct from + * `auto/glm` ("route to any connected provider currently serving a GLM model, + * z.ai included"). Documented here rather than deferred because the distinction + * is meaningful and the rule is a one-line lookup. + */ +export const FAMILY_PROVIDER_OVERRIDE: Readonly>> = { + zai: "zai", +}; + +export function isValidModelFamily(value: string | null | undefined): value is ModelFamily { + return typeof value === "string" && MODEL_FAMILY_SET.has(value); +} + +/** + * Detect the model family from a bare or provider-prefixed model id. + * Returns `null` when the id doesn't match any known family prefix — including + * `zai`, which is never detected from a model id (see `FAMILY_PROVIDER_OVERRIDE`). + */ +export function detectModelFamily(modelId: string | null | undefined): ModelFamily | null { + if (typeof modelId !== "string" || modelId.trim().length === 0) return null; + const bare = modelId.includes("/") ? modelId.slice(modelId.lastIndexOf("/") + 1) : modelId; + for (const { family, pattern } of FAMILY_ID_PATTERNS) { + if (pattern.test(bare)) return family; + } + return null; +} + +interface FamilyPoolCandidate { + provider: string; + model: string; +} + +/** + * Build the candidate filter for `auto/`. Provider-override families + * (currently only `zai`) filter by connection provider id; every other family + * filters by `detectModelFamily(model) === family`. + */ +export function buildFamilyCandidateFilter( + family: ModelFamily +): (candidate: FamilyPoolCandidate) => boolean { + const providerOverride = FAMILY_PROVIDER_OVERRIDE[family]; + if (providerOverride) { + return (candidate) => candidate.provider === providerOverride; + } + return (candidate) => detectModelFamily(candidate.model) === family; +} + +/** Advertised `auto/` catalog ids (#6453), e.g. `auto/glm`, `auto/minimax`. */ +export const AUTO_FAMILY_IDS: readonly string[] = MODEL_FAMILIES.map((family) => `auto/${family}`); diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 4a959120cd..39e7083d73 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -17,12 +17,16 @@ import { type AutoCategory, type AutoTier, } from "./suffixComposition"; +import { buildFamilyCandidateFilter, type ModelFamily } from "./modelFamily"; import { getHiddenModelsByProvider } from "@/models"; -/** #4235 Phase B: optional category/tier overlay for `auto/:` combos. */ +/** #4235 Phase B: optional category/tier overlay for `auto/:` combos. + * #6453: optional `family` overlay for `auto/` combos (e.g. `auto/glm`) — + * mutually exclusive with category/tier, applied instead of them when present. */ export interface AutoComboSpec { category?: AutoCategory; tier?: AutoTier; + family?: ModelFamily; } /** Minimal connection shape needed for virtual auto-combo factory */ @@ -306,26 +310,40 @@ export async function createVirtualAutoCombo( // connected. Operators who want the old "never break routing, lose the bias" // behavior can opt back in via the env var below. let effectivePool = candidatePool; - const candidateFilter = spec ? buildAutoCandidateFilter(spec.category, spec.tier) : null; + // #6453: `auto/` narrows by model family instead of category/tier. The + // two overlays are mutually exclusive on the spec (family takes precedence when + // both are somehow present, which callers never do in practice). + const candidateFilter = spec?.family + ? buildFamilyCandidateFilter(spec.family) + : spec + ? buildAutoCandidateFilter(spec.category, spec.tier) + : null; if (candidateFilter) { const narrowed = candidatePool.filter((c) => candidateFilter({ provider: c.provider, model: c.model }) ); + const label = spec?.family + ? `auto/${spec.family}` + : `auto/${spec?.category ?? ""}${spec?.tier ? `:${spec.tier}` : ""}`; if (narrowed.length > 0) { effectivePool = narrowed; } else if ( - process.env.OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL === "true" || - process.env.OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL === "1" + !spec?.family && + (process.env.OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL === "true" || + process.env.OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL === "1") ) { - // Opt-in legacy behavior: warn loudly, then keep the full pool. + // Opt-in legacy behavior (category/tier only): warn loudly, then keep the full pool. log.warn( "AUTO", - `auto/${spec?.category ?? ""}${spec?.tier ? `:${spec.tier}` : ""} matched no connected models; falling back to the full pool (OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=true)` + `${label} matched no connected models; falling back to the full pool (OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=true)` ); } else { + // Family combos always degrade to an empty pool when unavailable — a family + // is a hard identity constraint, not a soft optimization bias, so there is + // no sensible "fall back to the full pool" behavior for it. log.warn( "AUTO", - `auto/${spec?.category ?? ""}${spec?.tier ? `:${spec.tier}` : ""} matched no connected models; returning an empty pool. Set OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=true to restore the legacy "use full pool" behavior.` + `${label} matched no connected models; returning an empty pool.${spec?.family ? "" : ' Set OMNIROUTE_AUTO_FREE_FALLBACK_TO_FULL_POOL=true to restore the legacy "use full pool" behavior.'}` ); effectivePool = []; } diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 2a7e37d3b1..c45109def8 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -24,6 +24,7 @@ import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo"; import { AUTO_TEMPLATE_VARIANTS, AUTO_SUFFIX_VARIANTS, + AUTO_FAMILY_IDS, createBuiltinAutoCombo, } from "@omniroute/open-sse/services/autoCombo/builtinCatalog"; import { getAllSyncedAvailableModels, type SyncedAvailableModel } from "@/lib/db/models"; @@ -585,7 +586,12 @@ async function buildUnifiedModelsResponseCore( // combo cannot be materialized (e.g. no eligible connections yet) the minimal // #4164 entry is emitted instead, so the id is never dropped. // #4235 Phase B: also advertise the curated `auto/[:]` combos. - for (const autoId of [...Object.keys(AUTO_TEMPLATE_VARIANTS), ...AUTO_SUFFIX_VARIANTS]) { + // #6453: also advertise the `auto/` combos (auto/glm, auto/minimax, ...). + for (const autoId of [ + ...Object.keys(AUTO_TEMPLATE_VARIANTS), + ...AUTO_SUFFIX_VARIANTS, + ...AUTO_FAMILY_IDS, + ]) { if (blockedProviders.has("auto") || listedIds.has(autoId)) continue; // #5192 listedIds.add(autoId); const baseAutoEntry = { diff --git a/tests/unit/autoCombo/provider-family-combos.test.ts b/tests/unit/autoCombo/provider-family-combos.test.ts new file mode 100644 index 0000000000..54a6861227 --- /dev/null +++ b/tests/unit/autoCombo/provider-family-combos.test.ts @@ -0,0 +1,193 @@ +/** + * #6453 — Provider-family auto combos (`auto/glm`, `auto/minimax`, `auto/zai`, + * `auto/mimo`, `auto/gemma`, `auto/llama`, `auto/gemini`). + * + * See: `open-sse/services/autoCombo/modelFamily.ts` (pure family detection) and + * `open-sse/services/autoCombo/builtinCatalog.ts` (recognition + materialization). + * + * NOTE: tests/unit/autoCombo/ is a vitest-only scope (see vitest.mcp.config.ts); + * the node:test runner does not walk this dir. + */ +import { describe, it, beforeEach, afterAll, vi } from "vitest"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + detectModelFamily, + isValidModelFamily, + AUTO_FAMILY_IDS, +} from "../../../open-sse/services/autoCombo/modelFamily"; + +// First-touch DB migrations run once per worker and can exceed vitest's 5s +// default in a cold thread; the DB-backed materialization tests below need it. +vi.setConfig({ testTimeout: 20_000 }); + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-family-combo-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +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 builtinCatalog = await import("../../../open-sse/services/autoCombo/builtinCatalog.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +beforeEach(async () => { + await resetStorage(); +}); + +afterAll(async () => { + await resetStorage(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) { + delete process.env.DATA_DIR; + } else { + process.env.DATA_DIR = ORIGINAL_DATA_DIR; + } +}); + +describe("detectModelFamily (pure)", () => { + it("recognizes glm model ids", () => { + assert.equal(detectModelFamily("glm-5.2"), "glm"); + assert.equal(detectModelFamily("zai/glm-5.2"), "glm"); + }); + + it("recognizes minimax, mimo, gemma, llama, gemini model ids", () => { + assert.equal(detectModelFamily("minimax-m3"), "minimax"); + assert.equal(detectModelFamily("mimo-v2.5"), "mimo"); + assert.equal(detectModelFamily("gemma-3-27b"), "gemma"); + assert.equal(detectModelFamily("llama-3.3-70b"), "llama"); + assert.equal(detectModelFamily("gemini-3-pro"), "gemini"); + }); + + it("returns null for unrelated model ids", () => { + assert.equal(detectModelFamily("gpt-4o"), null); + assert.equal(detectModelFamily(""), null); + assert.equal(detectModelFamily(null), null); + }); + + it("never detects zai from a model id (provider-override family)", () => { + // zai's own models are named glm-*, not zai-*; auto/zai is resolved by + // provider id, not by a model-name prefix (see modelFamily.ts comment). + assert.equal(detectModelFamily("zai-glm-5.2"), null); + }); + + it("isValidModelFamily accepts exactly the 7 advertised families", () => { + for (const family of ["glm", "minimax", "mimo", "zai", "gemma", "llama", "gemini"]) { + assert.equal(isValidModelFamily(family), true); + } + assert.equal(isValidModelFamily("gpt"), false); + assert.equal(isValidModelFamily(undefined), false); + }); + + it("advertises exactly one auto/ catalog id per family", () => { + assert.deepEqual( + [...AUTO_FAMILY_IDS].sort(), + ["auto/gemini", "auto/gemma", "auto/glm", "auto/llama", "auto/mimo", "auto/minimax", "auto/zai"] + ); + }); +}); + +describe("auto/ materialization (#6453)", () => { + it("resolves auto/glm to a virtual combo spanning every connected GLM backend", async () => { + await providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + name: "GLM direct", + apiKey: "sk-test-glm", + defaultModel: "glm-5.2", + }); + await providersDb.createProviderConnection({ + provider: "zai", + authType: "apikey", + name: "z.ai", + apiKey: "sk-test-zai", + defaultModel: "glm-5.2", + }); + await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "OpenAI", + apiKey: "sk-test-openai", + defaultModel: "gpt-4o-mini", + }); + + const combo = await builtinCatalog.createBuiltinAutoCombo("auto/glm", "glm"); + + assert.equal(combo.id, "auto/glm"); + assert.equal(combo.strategy, "auto"); + const providerIds = combo.models.map((m) => m.providerId).sort(); + assert.deepEqual(providerIds, ["glm", "zai"]); + assert.ok(combo.models.every((m) => m.model.endsWith("glm-5.2"))); + }); + + it("resolves auto/zai to ONLY the zai-provider connection (provider-override family)", async () => { + await providersDb.createProviderConnection({ + provider: "glm", + authType: "apikey", + name: "GLM direct", + apiKey: "sk-test-glm", + defaultModel: "glm-5.2", + }); + await providersDb.createProviderConnection({ + provider: "zai", + authType: "apikey", + name: "z.ai", + apiKey: "sk-test-zai", + defaultModel: "glm-5.2", + }); + + const combo = await builtinCatalog.createBuiltinAutoCombo("auto/zai", "zai"); + + assert.equal(combo.id, "auto/zai"); + const providerIds = combo.models.map((m) => m.providerId); + assert.deepEqual(providerIds, ["zai"]); + }); + + it("degrades gracefully to the family subset, excluding connected-but-unrelated providers", async () => { + // Free/noAuth backends may still expose a family model (e.g. opencode serves + // minimax under its own catalog) — the family combo is expected to include + // those, but MUST exclude a connected provider whose model is a different + // family entirely. This is the "subset available" degrade path (#6453). + await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "OpenAI", + apiKey: "sk-test-openai", + defaultModel: "gpt-4o-mini", + }); + + const combo = await builtinCatalog.createBuiltinAutoCombo("auto/minimax", "minimax"); + + assert.equal(combo.id, "auto/minimax"); + assert.ok( + combo.models.every((m) => m.providerId !== "openai"), + "auto/minimax must not include the connected openai/gpt-4o-mini candidate" + ); + assert.ok( + combo.models.every((m) => detectModelFamily(m.model) === "minimax"), + "every candidate in auto/minimax must actually be a minimax model" + ); + }); + + + it("rejects auto/ with the same clean error as any unknown combo", async () => { + await assert.rejects( + () => builtinCatalog.createBuiltinAutoCombo("auto/unknownfam", "unknownfam"), + /Unknown built-in auto combo/ + ); + }); + + it("isRecognizedBuiltinAuto recognizes every auto/ id", () => { + for (const family of ["glm", "minimax", "mimo", "zai", "gemma", "llama", "gemini"]) { + assert.equal(builtinCatalog.isRecognizedBuiltinAuto(`auto/${family}`, family), true); + } + assert.equal(builtinCatalog.isRecognizedBuiltinAuto("auto/unknownfam", "unknownfam"), false); + }); +});