From 448741fe5f1cdd0ea83d2028993d3ef09a27b13c Mon Sep 17 00:00:00 2001 From: Rahul sharma Date: Mon, 22 Jun 2026 02:56:30 +0530 Subject: [PATCH] feat(combos): provider-wildcard expansion in combo steps (#2562) (#4545) Integrated into release/v3.8.33 --- config/quality/file-size-baseline.json | 2 +- open-sse/services/combo.ts | 46 ++- open-sse/services/combo/providerWildcard.ts | 253 ++++++++++++++ tests/unit/combo-provider-wildcard.test.ts | 348 ++++++++++++++++++++ 4 files changed, 644 insertions(+), 5 deletions(-) create mode 100644 open-sse/services/combo/providerWildcard.ts create mode 100644 tests/unit/combo-provider-wildcard.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 5b40e52716..460edee2bc 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -129,7 +129,7 @@ "open-sse/services/batchProcessor.ts": 828, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, - "open-sse/services/combo.ts": 2657, + "open-sse/services/combo.ts": 2695, "open-sse/services/rateLimitManager.ts": 1035, "open-sse/services/tokenRefresh.ts": 1997, "open-sse/services/usage.ts": 3450, diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index d3969d0a5b..d007dffdb7 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -90,6 +90,7 @@ import { import { resolveReasoningBufferedMaxTokens, toPositiveInteger } from "./reasoningTokenBuffer.ts"; import { RESET_WINDOW_NAMES } from "./combo/types.ts"; import type { + ComboLike, ComboRetryAfter, ComboErrorBody, SingleModelTarget, @@ -126,6 +127,10 @@ import { } from "./combo/comboPredicates.ts"; import { applyComboTargetExhaustion } from "./combo/targetExhaustion.ts"; import { dedupeTargetsByExecutionKey, isRecord } from "./combo/comboData.ts"; +import { + expandProviderWildcardsInCombo, + expandProviderWildcardsInCollection, +} from "./combo/providerWildcard.ts"; import { resolveShadowTargets, scheduleShadowRouting } from "./combo/shadowRouting.ts"; import { sortTargetsByCost, @@ -686,10 +691,30 @@ export async function handleComboChat({ const maxSetRetries = config.maxSetRetries ?? 0; const setRetryDelayMs = resolveDelayMs(config.setRetryDelayMs, 2000); + // #2562: Expand provider-wildcard steps (e.g. `fta/*`, `openai/gpt-4*`) into + // concrete model entries sourced from the live synced-models catalog + registry. + // Must run before resolveComboTargets so that wildcard-originated steps are + // treated identically to hand-authored entries by all downstream logic. + const expandedCombo = await expandProviderWildcardsInCombo(combo); + const expandedAllCombos = allCombos + ? Array.isArray(allCombos) + ? await expandProviderWildcardsInCollection(allCombos as ComboLike[]) + : { + ...allCombos, + combos: await expandProviderWildcardsInCollection( + ((allCombos as { combos?: ComboLike[] }).combos || []) as ComboLike[] + ), + } + : allCombos; + let orderedTargets = strategy === "weighted" - ? resolveWeightedTargets(combo, allCombos)?.orderedTargets || [] - : resolveComboTargets(combo, allCombos, clampComboDepth(config.maxComboDepth)); + ? resolveWeightedTargets(expandedCombo, expandedAllCombos)?.orderedTargets || [] + : resolveComboTargets( + expandedCombo, + expandedAllCombos, + clampComboDepth(config.maxComboDepth) + ); orderedTargets = await applyRequestTagRouting(orderedTargets, body, log); @@ -2130,9 +2155,22 @@ async function handleRoundRobinCombo({ ? resolveResilienceSettings(settings) : resolveResilienceSettings(null); + // #2562: Expand provider-wildcard steps before resolving targets. + const rrExpandedCombo = await expandProviderWildcardsInCombo(combo); + const rrExpandedAllCombos = allCombos + ? Array.isArray(allCombos) + ? await expandProviderWildcardsInCollection(allCombos as ComboLike[]) + : { + ...allCombos, + combos: await expandProviderWildcardsInCollection( + ((allCombos as { combos?: ComboLike[] }).combos || []) as ComboLike[] + ), + } + : allCombos; + const orderedTargets = resolveComboTargets( - combo, - allCombos, + rrExpandedCombo, + rrExpandedAllCombos, clampComboDepth(config.maxComboDepth) ); const tagFilteredTargets = await applyRequestTagRouting(orderedTargets, body, log); diff --git a/open-sse/services/combo/providerWildcard.ts b/open-sse/services/combo/providerWildcard.ts new file mode 100644 index 0000000000..fdc5cd60ab --- /dev/null +++ b/open-sse/services/combo/providerWildcard.ts @@ -0,0 +1,253 @@ +/** + * Provider-wildcard expansion for combo targets (#2562). + * + * Allows a combo step to be written as `provider/*` (or `provider/prefix*`) + * instead of enumerating every model explicitly. At request time the pattern + * is expanded into concrete `provider/modelId` entries sourced from: + * 1. The synced-available-models DB store (for API-key / OAuth providers whose + * model list changes at runtime, e.g. custom OpenAI-compatible providers). + * 2. The static providerRegistry (for built-in providers whose catalog is + * bundled with the release). + * + * This keeps combos always in sync with the live model catalog without any + * manual maintenance. + * + * ### Wildcard syntax + * + * `/*` – all models for the provider + * `/prefix*` – models whose id starts with `prefix` + * + * Examples: + * `fta/*` → all FreeTheAI synced models + * `openai/*` → all OpenAI registry models + * `opc/deepseek*` → FreeTheAI models whose id starts with "deepseek" + * + * ### Step preservation + * + * The original wildcard entry's `weight`, `label`, `connectionId`, and + * `allowedConnectionIds` are copied onto every expanded model step so that + * per-step routing policy is inherited. + */ + +import { wildcardMatch } from "../wildcardRouter.ts"; +import { getProviderModels } from "../../config/providerModels.ts"; +import { getSyncedAvailableModels } from "../../../src/lib/db/models.ts"; +import type { ComboLike } from "./types.ts"; + +/** Sentinel pattern used for "all models of a provider". */ +const PROVIDER_WILDCARD_SENTINEL = "*"; + +/** + * Return true if `entry` is a provider-wildcard step. + * Accepts both string notation (`"fta/*"`, `"opc/deep*"`) and the structured + * object form `{ kind: "provider-wildcard", providerId, modelPattern }`. + */ +export function isProviderWildcardEntry(entry: unknown): boolean { + if (typeof entry === "string") { + const trimmed = entry.trim(); + const slash = trimmed.indexOf("/"); + if (slash <= 0) return false; + const model = trimmed.slice(slash + 1); + return model.includes("*"); + } + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + const rec = entry as Record; + return rec.kind === "provider-wildcard" && typeof rec.providerId === "string"; + } + return false; +} + +type ProviderWildcardSpec = { + providerId: string; + modelPattern: string; + weight: number; + label: string | null; + connectionId: string | null; + allowedConnectionIds: string[] | null; +}; + +function parseWildcardEntry(entry: unknown): ProviderWildcardSpec | null { + if (typeof entry === "string") { + const trimmed = entry.trim(); + const slash = trimmed.indexOf("/"); + if (slash <= 0) return null; + const providerId = trimmed.slice(0, slash); + const modelPattern = trimmed.slice(slash + 1); + if (!modelPattern.includes("*")) return null; + return { + providerId, + modelPattern, + weight: 0, + label: null, + connectionId: null, + allowedConnectionIds: null, + }; + } + + if (entry && typeof entry === "object" && !Array.isArray(entry)) { + const rec = entry as Record; + if (rec.kind !== "provider-wildcard") return null; + const providerId = typeof rec.providerId === "string" ? rec.providerId.trim() : ""; + if (!providerId) return null; + const modelPattern = + typeof rec.modelPattern === "string" ? rec.modelPattern.trim() : PROVIDER_WILDCARD_SENTINEL; + const weight = typeof rec.weight === "number" && Number.isFinite(rec.weight) ? rec.weight : 0; + const label = typeof rec.label === "string" && rec.label.trim() ? rec.label.trim() : null; + const connectionId = + typeof rec.connectionId === "string" && rec.connectionId.trim() + ? rec.connectionId.trim() + : null; + const allowedConnectionIds = Array.isArray(rec.allowedConnectionIds) + ? (rec.allowedConnectionIds as unknown[]) + .map((c) => (typeof c === "string" ? c.trim() : "")) + .filter(Boolean) + : null; + return { + providerId, + modelPattern, + weight, + label, + connectionId, + allowedConnectionIds: allowedConnectionIds?.length ? allowedConnectionIds : null, + }; + } + + return null; +} + +/** + * Collect candidate model IDs for a provider from two sources: + * 1. Synced available models in the DB (runtime-dynamic; custom/OAuth providers) + * 2. Static provider registry (built-in providers bundled with the release) + * + * The union is deduped by model id. + */ +async function collectProviderModelIds(providerId: string): Promise { + const seen = new Set(); + const ids: string[] = []; + + // 1. Synced DB models (highest priority — reflects the live catalog) + try { + const synced = await getSyncedAvailableModels(providerId); + for (const m of synced) { + if (m.id && !seen.has(m.id)) { + seen.add(m.id); + ids.push(m.id); + } + } + } catch { + // Non-fatal — DB may be offline in tests or at early init. + } + + // 2. Static registry models (fallback / built-in providers) + const registryModels = getProviderModels(providerId); + for (const m of registryModels) { + if (m.id && !seen.has(m.id)) { + seen.add(m.id); + ids.push(m.id); + } + } + + return ids; +} + +/** + * Expand a single provider-wildcard spec into concrete model entry objects + * that `normalizeComboStep` can process as normal model steps. + * + * Returns `null` if no matching models are found (caller keeps the original + * entry so the combo does not silently lose a step). + */ +async function expandWildcardSpec( + spec: ProviderWildcardSpec, + comboName: string +): Promise { + const modelIds = await collectProviderModelIds(spec.providerId); + if (modelIds.length === 0) return null; + + const pattern = spec.modelPattern; + const matchingIds = + pattern === PROVIDER_WILDCARD_SENTINEL + ? modelIds + : modelIds.filter((id) => wildcardMatch(id, pattern)); + + if (matchingIds.length === 0) return null; + + return matchingIds.map((modelId) => { + const fullModelStr = `${spec.providerId}/${modelId}`; + const step: Record = { + kind: "model", + model: fullModelStr, + providerId: spec.providerId, + weight: spec.weight, + // Tag so downstream can identify the origin of this expanded step. + _expandedFromWildcard: `${spec.providerId}/${pattern}`, + _parentCombo: comboName, + }; + if (spec.label !== null) step.label = spec.label; + if (spec.connectionId !== null) step.connectionId = spec.connectionId; + if (spec.allowedConnectionIds !== null) step.allowedConnectionIds = spec.allowedConnectionIds; + return step; + }); +} + +/** + * Expand all provider-wildcard entries in a combo's `models` array in-place, + * returning a new ComboLike with concrete model entries in place of wildcards. + * + * Non-wildcard entries are passed through unchanged. + * + * @param combo The combo whose `models` may contain wildcard entries. + * @returns A shallow copy of `combo` with wildcards expanded, or the + * original if no wildcards were found. + */ +export async function expandProviderWildcardsInCombo(combo: ComboLike): Promise { + const models = combo.models; + if (!Array.isArray(models) || models.length === 0) return combo; + + let hasWildcard = false; + for (const entry of models) { + if (isProviderWildcardEntry(entry)) { + hasWildcard = true; + break; + } + } + if (!hasWildcard) return combo; + + const expanded: unknown[] = []; + for (const entry of models) { + if (!isProviderWildcardEntry(entry)) { + expanded.push(entry); + continue; + } + + const spec = parseWildcardEntry(entry); + if (!spec) { + // Malformed wildcard entry — keep as-is; normalizeComboStep will handle. + expanded.push(entry); + continue; + } + + const resolved = await expandWildcardSpec(spec, combo.name); + if (resolved && resolved.length > 0) { + expanded.push(...resolved); + } else { + // No models found: keep the original entry rather than silently dropping + // the step, so the operator sees the error from the upstream provider. + expanded.push(entry); + } + } + + return { ...combo, models: expanded }; +} + +/** + * Expand provider wildcards across a whole collection of combos. + * Used when pre-processing `allCombos` before nested-combo resolution so that + * wildcard entries in referenced sub-combos are also materialized. + */ +export async function expandProviderWildcardsInCollection( + combos: ComboLike[] +): Promise { + return Promise.all(combos.map((c) => expandProviderWildcardsInCombo(c))); +} diff --git a/tests/unit/combo-provider-wildcard.test.ts b/tests/unit/combo-provider-wildcard.test.ts new file mode 100644 index 0000000000..9c9e371b84 --- /dev/null +++ b/tests/unit/combo-provider-wildcard.test.ts @@ -0,0 +1,348 @@ +/** + * Unit tests for provider-wildcard combo expansion (#2562). + * + * Tests cover: + * - Detection of wildcard notation in combo models + * - Expansion against static providerRegistry models + * - Expansion against synced DB models (mocked) + * - Glob pattern filtering (`prefix*`) + * - Preservation of step metadata (weight, label, connectionId, allowedConnectionIds) + * - Graceful no-op when no models found (keeps original entry) + * - Non-wildcard entries pass through unchanged + * - Object-form `{ kind: "provider-wildcard", ... }` syntax + * - Collection-level expansion + */ + +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-provider-wildcard-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +// ── Imports ───────────────────────────────────────────────────────────────── + +const { + isProviderWildcardEntry, + expandProviderWildcardsInCombo, + expandProviderWildcardsInCollection, +} = await import("../../open-sse/services/combo/providerWildcard.ts"); + +const { replaceSyncedAvailableModelsForConnection, getSyncedAvailableModels } = + await import("../../src/lib/db/models.ts"); + +const core = await import("../../src/lib/db/core.ts"); +core.getDbInstance(); // initialise DB + run migrations + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function makeCombo(models: unknown[], name = "test-combo") { + return { name, models, id: "test-id" }; +} + +// Seed synced models for a provider into the DB. +async function seedSyncedModels(providerId: string, connectionId: string, modelIds: string[]) { + await replaceSyncedAvailableModelsForConnection( + providerId, + connectionId, + modelIds.map((id) => ({ id, name: id, source: "imported" as const })) + ); +} + +// ── isProviderWildcardEntry ─────────────────────────────────────────────────── + +test("isProviderWildcardEntry: detects string wildcard `provider/*`", () => { + assert.equal(isProviderWildcardEntry("fta/*"), true); + assert.equal(isProviderWildcardEntry("openai/*"), true); + assert.equal(isProviderWildcardEntry("opc/deepseek*"), true); + assert.equal(isProviderWildcardEntry("openai/gpt-4*"), true); +}); + +test("isProviderWildcardEntry: rejects plain model strings", () => { + assert.equal(isProviderWildcardEntry("fta/some-model"), false); + assert.equal(isProviderWildcardEntry("openai/gpt-4o"), false); + assert.equal(isProviderWildcardEntry("openai"), false); + assert.equal(isProviderWildcardEntry(""), false); + assert.equal(isProviderWildcardEntry(null), false); + assert.equal(isProviderWildcardEntry(42), false); +}); + +test("isProviderWildcardEntry: detects object form `{ kind: 'provider-wildcard' }`", () => { + assert.equal( + isProviderWildcardEntry({ kind: "provider-wildcard", providerId: "fta", modelPattern: "*" }), + true + ); +}); + +test("isProviderWildcardEntry: rejects object without kind=provider-wildcard", () => { + assert.equal(isProviderWildcardEntry({ kind: "model", model: "fta/some-model" }), false); + assert.equal(isProviderWildcardEntry({ kind: "provider-wildcard" }), false); // missing providerId +}); + +// ── expandProviderWildcardsInCombo — static registry ───────────────────────── + +test("expandProviderWildcardsInCombo: expands `openai/*` against static registry", async () => { + // `openai` is a built-in provider with models in providerRegistry + const combo = makeCombo(["openai/*"]); + const result = await expandProviderWildcardsInCombo(combo); + + // Should have expanded to at least 1 model + assert.ok(result.models.length > 0, "should have expanded to ≥1 model"); + + // Every expanded entry should be a model step object with `kind: "model"` + for (const entry of result.models) { + assert.equal(typeof entry, "object"); + assert.equal((entry as any).kind, "model"); + assert.ok( + typeof (entry as any).model === "string" && (entry as any).model.startsWith("openai/"), + `model should start with openai/, got: ${(entry as any).model}` + ); + } +}); + +test("expandProviderWildcardsInCombo: `_expandedFromWildcard` tag is set on expanded entries", async () => { + const combo = makeCombo(["openai/*"]); + const result = await expandProviderWildcardsInCombo(combo); + for (const entry of result.models) { + assert.equal((entry as any)._expandedFromWildcard, "openai/*"); + } +}); + +// ── expandProviderWildcardsInCombo — synced DB models ──────────────────────── + +test("expandProviderWildcardsInCombo: expands against synced DB models", async () => { + const providerId = "test-custom-provider-" + Date.now(); + await seedSyncedModels(providerId, "conn-1", ["model-alpha", "model-beta", "model-gamma"]); + + const combo = makeCombo([`${providerId}/*`]); + const result = await expandProviderWildcardsInCombo(combo); + + assert.equal(result.models.length, 3); + const modelStrs = result.models.map((e) => (e as any).model); + assert.ok(modelStrs.includes(`${providerId}/model-alpha`)); + assert.ok(modelStrs.includes(`${providerId}/model-beta`)); + assert.ok(modelStrs.includes(`${providerId}/model-gamma`)); +}); + +test("expandProviderWildcardsInCombo: glob prefix filter `provider/pre*`", async () => { + const providerId = "test-prefix-provider-" + Date.now(); + await seedSyncedModels(providerId, "conn-1", [ + "deepseek-v4-pro", + "deepseek-v4-flash", + "qwen3-free", + "minimax-m3", + ]); + + const combo = makeCombo([`${providerId}/deepseek*`]); + const result = await expandProviderWildcardsInCombo(combo); + + assert.equal(result.models.length, 2); + const ids = result.models.map((e) => (e as any).model); + assert.ok(ids.includes(`${providerId}/deepseek-v4-pro`)); + assert.ok(ids.includes(`${providerId}/deepseek-v4-flash`)); + assert.ok(!ids.includes(`${providerId}/qwen3-free`)); + assert.ok(!ids.includes(`${providerId}/minimax-m3`)); +}); + +// ── Step metadata preservation ──────────────────────────────────────────────── + +test("expandProviderWildcardsInCombo: weight is inherited on expanded entries", async () => { + const providerId = "test-weight-provider-" + Date.now(); + await seedSyncedModels(providerId, "conn-1", ["model-a", "model-b"]); + + const combo = makeCombo([ + { kind: "provider-wildcard", providerId, modelPattern: "*", weight: 50, label: "fast" }, + ]); + const result = await expandProviderWildcardsInCombo(combo); + + for (const entry of result.models) { + assert.equal((entry as any).weight, 50, "weight should be inherited"); + assert.equal((entry as any).label, "fast", "label should be inherited"); + } +}); + +test("expandProviderWildcardsInCombo: connectionId is inherited on expanded entries", async () => { + const providerId = "test-conn-provider-" + Date.now(); + await seedSyncedModels(providerId, "conn-42", ["model-x"]); + + const combo = makeCombo([ + { kind: "provider-wildcard", providerId, modelPattern: "*", connectionId: "conn-42" }, + ]); + const result = await expandProviderWildcardsInCombo(combo); + + assert.equal(result.models.length, 1); + assert.equal((result.models[0] as any).connectionId, "conn-42"); +}); + +test("expandProviderWildcardsInCombo: allowedConnectionIds is inherited", async () => { + const providerId = "test-acl-provider-" + Date.now(); + await seedSyncedModels(providerId, "conn-a", ["model-m"]); + + const combo = makeCombo([ + { + kind: "provider-wildcard", + providerId, + modelPattern: "*", + allowedConnectionIds: ["conn-a", "conn-b"], + }, + ]); + const result = await expandProviderWildcardsInCombo(combo); + + assert.equal(result.models.length, 1); + assert.deepEqual((result.models[0] as any).allowedConnectionIds, ["conn-a", "conn-b"]); +}); + +// ── Non-wildcard pass-through ───────────────────────────────────────────────── + +test("expandProviderWildcardsInCombo: non-wildcard string entries pass through unchanged", async () => { + const combo = makeCombo(["anthropic/claude-opus-4", "openai/gpt-4o"]); + const result = await expandProviderWildcardsInCombo(combo); + + assert.equal(result.models.length, 2); + assert.equal(result.models[0], "anthropic/claude-opus-4"); + assert.equal(result.models[1], "openai/gpt-4o"); +}); + +test("expandProviderWildcardsInCombo: mixed combo — wildcards expand, explicit entries preserved", async () => { + const providerId = "test-mixed-provider-" + Date.now(); + await seedSyncedModels(providerId, "conn-1", ["model-1", "model-2"]); + + const combo = makeCombo(["anthropic/claude-opus-4", `${providerId}/*`, "openai/gpt-4o"]); + const result = await expandProviderWildcardsInCombo(combo); + + // anthropic/claude-opus-4, model-1, model-2, openai/gpt-4o + assert.equal(result.models.length, 4); + assert.equal(result.models[0], "anthropic/claude-opus-4"); + assert.equal((result.models[1] as any).model, `${providerId}/model-1`); + assert.equal((result.models[2] as any).model, `${providerId}/model-2`); + assert.equal(result.models[3], "openai/gpt-4o"); +}); + +// ── No models found — graceful fallback ────────────────────────────────────── + +test("expandProviderWildcardsInCombo: keeps original entry when no models found for provider", async () => { + const combo = makeCombo(["nonexistent-provider-xyz/*"]); + const result = await expandProviderWildcardsInCombo(combo); + + // Should not silently drop the step + assert.equal(result.models.length, 1); + assert.equal(result.models[0], "nonexistent-provider-xyz/*"); +}); + +test("expandProviderWildcardsInCombo: keeps original entry when pattern matches nothing", async () => { + const providerId = "test-nomatch-provider-" + Date.now(); + await seedSyncedModels(providerId, "conn-1", ["some-model"]); + + const combo = makeCombo([`${providerId}/zzzz*`]); + const result = await expandProviderWildcardsInCombo(combo); + + // Pattern matches nothing — original entry preserved + assert.equal(result.models.length, 1); +}); + +// ── Collection expansion ────────────────────────────────────────────────────── + +test("expandProviderWildcardsInCollection: expands wildcards in every combo in the collection", async () => { + const p1 = "test-col-p1-" + Date.now(); + const p2 = "test-col-p2-" + Date.now(); + await seedSyncedModels(p1, "c1", ["m1", "m2"]); + await seedSyncedModels(p2, "c2", ["m3"]); + + const combos = [ + makeCombo([`${p1}/*`], "combo-a"), + makeCombo([`${p2}/*`, "openai/gpt-4o"], "combo-b"), + makeCombo(["anthropic/claude-opus-4"], "combo-c"), // no wildcard + ]; + + const results = await expandProviderWildcardsInCollection(combos); + + assert.equal(results[0].models.length, 2); // m1, m2 + assert.equal(results[1].models.length, 2); // m3, gpt-4o + assert.equal(results[2].models.length, 1); // unchanged + assert.equal(results[2].models[0], "anthropic/claude-opus-4"); +}); + +// ── Return identity when no wildcards ──────────────────────────────────────── + +test("expandProviderWildcardsInCombo: returns same object when no wildcards", async () => { + const combo = makeCombo(["openai/gpt-4o"]); + const result = await expandProviderWildcardsInCombo(combo); + // Same reference — no allocation when nothing to expand + assert.strictEqual(result, combo); +}); + +test("expandProviderWildcardsInCombo: returns same object for empty models array", async () => { + const combo = makeCombo([]); + const result = await expandProviderWildcardsInCombo(combo); + assert.strictEqual(result, combo); +}); + +// ── Multi-provider wildcard combo ───────────────────────────────────────────── +// Validates the primary use-case from issue #2562: +// a single combo that spans multiple providers, each expressed as a wildcard. +// e.g. freetheai/* + deepseek-web/* (or any other provider) +// The expanded model list is the ordered union of all providers' model catalogs. + +test("expandProviderWildcardsInCombo: two provider wildcards expand independently and maintain order", async () => { + const p1 = "test-multi-p1-" + Date.now(); + const p2 = "test-multi-p2-" + Date.now(); + await seedSyncedModels(p1, "conn-1", ["fast-model", "smart-model"]); + await seedSyncedModels(p2, "conn-2", ["web-search-model", "reasoning-model"]); + + // Simulate: freetheai/* + deepseek-web/* + const combo = makeCombo([`${p1}/*`, `${p2}/*`]); + const result = await expandProviderWildcardsInCombo(combo); + + // Should have all 4 models, p1 first then p2 (insertion order preserved) + assert.equal(result.models.length, 4); + const models = result.models.map((e) => (e as any).model); + assert.ok( + models.indexOf(`${p1}/fast-model`) < models.indexOf(`${p2}/web-search-model`), + "p1 models should come before p2 models" + ); + assert.ok(models.includes(`${p1}/fast-model`)); + assert.ok(models.includes(`${p1}/smart-model`)); + assert.ok(models.includes(`${p2}/web-search-model`)); + assert.ok(models.includes(`${p2}/reasoning-model`)); +}); + +test("expandProviderWildcardsInCombo: two providers with prefix filters", async () => { + const p1 = "test-prefix-p1-" + Date.now(); + const p2 = "test-prefix-p2-" + Date.now(); + await seedSyncedModels(p1, "conn-1", ["free-fast", "free-smart", "paid-pro"]); + await seedSyncedModels(p2, "conn-2", ["web-basic", "web-turbo", "local-model"]); + + // Only free models from p1, only web models from p2 + const combo = makeCombo([`${p1}/free*`, `${p2}/web*`]); + const result = await expandProviderWildcardsInCombo(combo); + + assert.equal(result.models.length, 4); // free-fast, free-smart, web-basic, web-turbo + const models = result.models.map((e) => (e as any).model); + assert.ok(models.includes(`${p1}/free-fast`)); + assert.ok(models.includes(`${p1}/free-smart`)); + assert.ok(!models.includes(`${p1}/paid-pro`)); // filtered out + assert.ok(models.includes(`${p2}/web-basic`)); + assert.ok(models.includes(`${p2}/web-turbo`)); + assert.ok(!models.includes(`${p2}/local-model`)); // filtered out +}); + +test("expandProviderWildcardsInCombo: three providers mixed with explicit entries", async () => { + const p1 = "test-three-p1-" + Date.now(); + const p2 = "test-three-p2-" + Date.now(); + await seedSyncedModels(p1, "conn-1", ["m-a", "m-b"]); + await seedSyncedModels(p2, "conn-2", ["m-c"]); + + // anchor explicit entry first, then two wildcards, then another explicit + const combo = makeCombo(["anthropic/claude-opus-4", `${p1}/*`, `${p2}/*`, "openai/gpt-4o"]); + const result = await expandProviderWildcardsInCombo(combo); + + // anthropic + 2 (p1) + 1 (p2) + openai = 5 + assert.equal(result.models.length, 5); + assert.equal(result.models[0], "anthropic/claude-opus-4"); + assert.equal((result.models[1] as any).model, `${p1}/m-a`); + assert.equal((result.models[2] as any).model, `${p1}/m-b`); + assert.equal((result.models[3] as any).model, `${p2}/m-c`); + assert.equal(result.models[4], "openai/gpt-4o"); +});