mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(sse): provider-family auto combos auto/glm, auto/minimax, auto/zai, auto/mimo, auto/gemma, auto/llama, auto/gemini (#6453) (#6509)
Provider-family auto combos (#6453). Integrated into release/v3.8.46; vitest autoCombo suite 11/11 green.
This commit is contained in:
committed by
GitHub
parent
526048da5e
commit
0d3d31c21f
@@ -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)
|
||||
|
||||
@@ -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/<family>` (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}`);
|
||||
}
|
||||
|
||||
95
open-sse/services/autoCombo/modelFamily.ts
Normal file
95
open-sse/services/autoCombo/modelFamily.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* #6453 — Provider-family auto combos.
|
||||
*
|
||||
* `auto/<family>` (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<string> = 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<Partial<Record<ModelFamily, string>>> = {
|
||||
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/<family>`. 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/<family>` catalog ids (#6453), e.g. `auto/glm`, `auto/minimax`. */
|
||||
export const AUTO_FAMILY_IDS: readonly string[] = MODEL_FAMILIES.map((family) => `auto/${family}`);
|
||||
@@ -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/<category>:<tier>` combos. */
|
||||
/** #4235 Phase B: optional category/tier overlay for `auto/<category>:<tier>` combos.
|
||||
* #6453: optional `family` overlay for `auto/<family>` 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/<family>` 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 = [];
|
||||
}
|
||||
|
||||
@@ -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/<category>[:<tier>]` combos.
|
||||
for (const autoId of [...Object.keys(AUTO_TEMPLATE_VARIANTS), ...AUTO_SUFFIX_VARIANTS]) {
|
||||
// #6453: also advertise the `auto/<family>` 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 = {
|
||||
|
||||
193
tests/unit/autoCombo/provider-family-combos.test.ts
Normal file
193
tests/unit/autoCombo/provider-family-combos.test.ts
Normal file
@@ -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/<family> 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/<family> 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/<unknownfamily> 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/<family> 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user