diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 4ce9712934..a86bf5d90f 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -169,6 +169,7 @@ import { isTaskRoutingStrategy, reorderByTaskWeight, } from "./taskAwareRouting.ts"; +import { expandTargetsByFingerprints } from "./combo/fingerprintExpansion.ts"; export { RESET_WINDOW_NAMES }; export { QUOTA_SOFT_DEPRIORITIZE_FACTOR, setCandidateQuotaSoftPenalty }; @@ -368,8 +369,19 @@ export async function buildAutoCandidates( } } + // #5521: Expand fingerprint-based providers (mimocode, mcode, opencode) so each + // fingerprint gets its own combo slot instead of being bundled into one connection. + const fingerprintExpandedTargets = expandTargetsByFingerprints( + expandedTargets, + connectionById, + (t) => { + const parsed = parseModel(t.modelStr); + return t.provider || parsed.provider || parsed.providerAlias || "unknown"; + } + ); + const candidates = await Promise.all( - expandedTargets.map(async (target) => { + fingerprintExpandedTargets.map(async (target) => { const modelStr = target.modelStr; const parsed = parseModel(modelStr); const provider = target.provider || parsed.provider || parsed.providerAlias || "unknown"; diff --git a/open-sse/services/combo/fingerprintExpansion.ts b/open-sse/services/combo/fingerprintExpansion.ts new file mode 100644 index 0000000000..c96bb3ad05 --- /dev/null +++ b/open-sse/services/combo/fingerprintExpansion.ts @@ -0,0 +1,105 @@ +/** + * Fingerprint-based target expansion for combo routing. + * + * Some providers (MiMoCode, MiCode, OpenCode) store multiple device + * fingerprints inside a single connection's `providerSpecificData.fingerprints`. + * Without expansion the combo system treats the connection as one account, + * so only one fingerprint is used per request. This module splits such + * connections into one target per fingerprint so the combo round-robin + * distributes requests across all of them. + * + * Expansion runs AFTER connection-based expansion and BEFORE the + * `candidates` scoring pass in `combo.ts`. + */ + +import type { ResolvedComboTarget } from "./types.ts"; + +/** Providers whose `providerSpecificData.fingerprints` array should be expanded. */ +const FINGERPRINT_PROVIDERS: ReadonlySet = new Set(["mimocode", "mcode", "opencode"]); + +/** Check whether a provider uses fingerprint-based multi-account. */ +export function isFingerprintProvider(provider: string): boolean { + return FINGERPRINT_PROVIDERS.has(provider); +} + +/** Safely extract the fingerprints array from a connection record. */ +export function getConnectionFingerprints( + connection: Record | undefined | null +): string[] { + if (!connection || typeof connection !== "object") return []; + const psd = connection["providerSpecificData"]; + if (!psd || typeof psd !== "object") return []; + const fps = (psd as Record)["fingerprints"]; + if (!Array.isArray(fps)) return []; + return fps.filter((fp): fp is string => typeof fp === "string" && fp.trim().length > 0); +} + +/** True when a connection carries more than one fingerprint. */ +export function hasMultipleFingerprints( + connection: Record | undefined | null +): boolean { + return getConnectionFingerprints(connection).length > 1; +} + +/** + * Build an execution key that encodes a specific fingerprint. + * The first fingerprint keeps the original key so backward-compatible + * metrics / affinity lookups still work. + */ +export function buildFingerprintExecutionKey( + originalKey: string, + fingerprint: string, + isFirst: boolean +): string { + if (isFirst) return originalKey; + return `${originalKey}@fp:${fingerprint}`; +} + +/** + * Expand `expandedTargets` by splitting targets whose connection carries + * multiple fingerprints into one target per fingerprint. + * + * Targets that don't have a connectionId, don't belong to a fingerprint + * provider, or whose connection has ≤1 fingerprint are passed through + * unchanged. + * + * @param targets Targets already expanded by connection ID + * @param connectionById Map from connection ID → connection record + * @param getProvider Function to resolve a target's provider string + * @returns New array with fingerprint targets expanded + */ +export function expandTargetsByFingerprints( + targets: ResolvedComboTarget[], + connectionById: Map>, + getProvider: (target: ResolvedComboTarget) => string +): ResolvedComboTarget[] { + const result: ResolvedComboTarget[] = []; + + for (const target of targets) { + const provider = getProvider(target); + const { connectionId } = target; + + if (!connectionId || !isFingerprintProvider(provider)) { + result.push(target); + continue; + } + + const connection = connectionById.get(connectionId); + const fingerprints = getConnectionFingerprints(connection); + + if (fingerprints.length <= 1) { + result.push(target); + continue; + } + + for (let i = 0; i < fingerprints.length; i++) { + const isFirst = i === 0; + result.push({ + ...target, + executionKey: buildFingerprintExecutionKey(target.executionKey, fingerprints[i], isFirst), + }); + } + } + + return result; +} diff --git a/tests/e2e/fingerprint-expansion.test.ts b/tests/e2e/fingerprint-expansion.test.ts new file mode 100644 index 0000000000..8b46ad7487 --- /dev/null +++ b/tests/e2e/fingerprint-expansion.test.ts @@ -0,0 +1,335 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import net from "node:net"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { MockUpstreamServer, buildCompletion } from "./helpers/mockUpstreamServer.ts"; + +// #5521 — E2E test for fingerprint-based combo expansion. +// Seeds a mimocode connection with 3 fingerprints, creates a round-robin combo, +// and verifies that requests route through the combo successfully. + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-fingerprint-e2e-")); +const DASHBOARD_PORT = await getFreePort(); +const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); + +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "fingerprint-e2e-secret"; +process.env.REQUIRE_API_KEY = "false"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); + +function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to allocate a free port")); + return; + } + const { port } = address; + server.close((err) => { + if (err) reject(err); + else resolve(port); + }); + }); + }); +} + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function createServerProcess(dataDir: string, port: number) { + const stdoutLines: string[] = []; + const stderrLines: string[] = []; + let exitInfo: { code: number | null; signal: NodeJS.Signals | null } | null = null; + const child = spawn(process.execPath, ["scripts/dev/run-next-playwright.mjs", "dev"], { + cwd: REPO_ROOT, + env: { + ...process.env, + DATA_DIR: dataDir, + PORT: String(port), + DASHBOARD_PORT: String(port), + API_PORT: String(port), + HOST: "127.0.0.1", + REQUIRE_API_KEY: "false", + API_KEY_SECRET: process.env.API_KEY_SECRET || "fingerprint-e2e-secret", + DISABLE_SQLITE_AUTO_BACKUP: "true", + INITIAL_PASSWORD: "", + NEXT_TELEMETRY_DISABLED: "1", + OMNIROUTE_DISABLE_BACKGROUND_SERVICES: "true", + OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: "true", + OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: "true", + OMNIROUTE_HIDE_HEALTHCHECK_LOGS: "true", + OMNIROUTE_E2E_BOOTSTRAP_MODE: "open", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.once("exit", (code, signal) => { + exitInfo = { code, signal }; + }); + child.stdout.on("data", (chunk) => { + const lines = String(chunk).split(/\r?\n/).filter(Boolean); + stdoutLines.push(...lines); + if (stdoutLines.length > 200) stdoutLines.splice(0, stdoutLines.length - 200); + }); + child.stderr.on("data", (chunk) => { + const lines = String(chunk).split(/\r?\n/).filter(Boolean); + stderrLines.push(...lines); + if (stderrLines.length > 200) stderrLines.splice(0, stderrLines.length - 200); + }); + + return { + child, + stdoutLines, + stderrLines, + baseUrl: `http://127.0.0.1:${port}`, + get exitInfo() { + return exitInfo; + }, + }; +} + +async function waitForServer( + baseUrl: string, + logs: { + stdoutLines: string[]; + stderrLines: string[]; + exitInfo?: { code: number | null; signal: NodeJS.Signals | null } | null; + } +) { + const startedAt = Date.now(); + let lastError = ""; + while (Date.now() - startedAt < 120_000) { + if (logs.exitInfo) { + throw new Error( + [ + `OmniRoute exited before it became ready (code=${logs.exitInfo.code}, signal=${logs.exitInfo.signal})`, + "--- stdout ---", + ...logs.stdoutLines.slice(-40), + "--- stderr ---", + ...logs.stderrLines.slice(-40), + ].join("\n") + ); + } + try { + const response = await fetch(`${baseUrl}/api/monitoring/health`, { + signal: AbortSignal.timeout(5_000), + }); + if (response.ok) return; + lastError = `HTTP ${response.status}`; + } catch (error: unknown) { + lastError = error instanceof Error ? error.message : String(error); + } + await sleep(500); + } + throw new Error( + [ + `Timed out waiting for OmniRoute to start: ${lastError}`, + "--- stdout ---", + ...logs.stdoutLines.slice(-40), + "--- stderr ---", + ...logs.stderrLines.slice(-40), + ].join("\n") + ); +} + +async function stopProcess(child: ReturnType) { + if (child.killed) return; + child.kill("SIGTERM"); + const exited = await Promise.race([ + new Promise((resolve) => child.once("exit", () => resolve(true))), + sleep(5_000).then(() => false), + ]); + if (!exited && !child.killed) { + child.kill("SIGKILL"); + await new Promise((resolve) => child.once("exit", () => resolve())); + } +} + +async function postChat(baseUrl: string, model: string, content: string) { + const response = await fetch(`${baseUrl}/api/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + stream: false, + messages: [{ role: "user", content }], + }), + signal: AbortSignal.timeout(30_000), + }); + const text = await response.text(); + const json = text ? JSON.parse(text) : {}; + return { response, json }; +} + +// ── Setup ────────────────────────────────────────────────────────────────── + +const UPSTREAM_PORT = await getFreePort(); +const upstream = new MockUpstreamServer(); +const TOKEN = "sk-fp-e2e-test"; + +let app: + | { + child: ReturnType; + stdoutLines: string[]; + stderrLines: string[]; + baseUrl: string; + } + | undefined; + +test.before(async () => { + // Start mock upstream + const upstreamBaseUrl = await upstream.start(); + upstream.configureToken(TOKEN, { + defaultResponse: buildCompletion("fingerprint ok", { model: "fp-mimocode/mimo-auto" }), + }); + + // Seed mimocode provider node + const providerId = "openai-compatible-fp-mimocode"; + await providersDb.createProviderNode({ + id: providerId, + type: "openai-compatible", + name: "MiMoCode FP Test", + prefix: "fp-mimocode", + apiType: "chat", + baseUrl: upstreamBaseUrl, + }); + + // Seed connection with 3 fingerprints + await providersDb.createProviderConnection({ + provider: providerId, + authType: "apikey", + name: "fp-mimocode-multi-device", + apiKey: TOKEN, + isActive: true, + testStatus: "active", + providerSpecificData: { + baseUrl: upstreamBaseUrl, + apiType: "chat", + fingerprints: ["fp-device-aaa", "fp-device-bbb", "fp-device-ccc"], + accountProxies: [ + { fingerprint: "fp-device-aaa", proxy: null }, + { fingerprint: "fp-device-bbb", proxy: null }, + { fingerprint: "fp-device-ccc", proxy: null }, + ], + }, + }); + + // Create round-robin combo + await combosDb.createCombo({ + name: "fp-round-robin", + strategy: "round-robin", + config: { maxRetries: 0, retryDelayMs: 0 }, + models: ["fp-mimocode/mimo-auto"], + }); + + // Create priority combo (single target, for comparison) + await combosDb.createCombo({ + name: "fp-priority-single", + strategy: "priority", + config: { maxRetries: 0, retryDelayMs: 0 }, + models: ["fp-mimocode/mimo-auto"], + }); + + await settingsDb.updateSettings({ + resilienceSettings: { + requestQueue: { + autoEnableApiKeyProviders: true, + requestsPerMinute: 120, + minTimeBetweenRequestsMs: 0, + concurrentRequests: 4, + maxWaitMs: 2_000, + }, + connectionCooldown: { + oauth: { baseCooldownMs: 500, useUpstreamRetryHints: true, maxBackoffSteps: 3 }, + apikey: { baseCooldownMs: 200, useUpstreamRetryHints: false, maxBackoffSteps: 0 }, + }, + providerBreaker: { + oauth: { failureThreshold: 3, resetTimeoutMs: 2_000 }, + apikey: { failureThreshold: 2, resetTimeoutMs: 1_500 }, + }, + waitForCooldown: { enabled: false, maxRetries: 0, maxRetryWaitSec: 0 }, + }, + requestRetry: 0, + maxRetryIntervalSec: 0, + requireLogin: false, + setupComplete: true, + }); + + core.closeDbInstance(); + + app = createServerProcess(TEST_DATA_DIR, DASHBOARD_PORT); + await waitForServer(app.baseUrl, app); +}); + +test.after(async () => { + if (app) await stopProcess(app.child); + await upstream.stop(); + core.closeDbInstance(); + await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +// ── Tests ────────────────────────────────────────────────────────────────── + +test("round-robin combo with 3 fingerprints: all requests succeed", async () => { + assert.ok(app); + + // Send 3 requests — round-robin should distribute across expanded targets + for (let i = 0; i < 3; i++) { + const result = await postChat(app.baseUrl, "fp-round-robin", `request ${i + 1}`); + assert.equal( + result.response.status, + 200, + `request ${i + 1} failed: ${JSON.stringify(result.json)}` + ); + assert.equal(result.json.choices[0].message.content, "fingerprint ok"); + assert.equal(result.json.model, "fp-mimocode/mimo-auto"); + } + + // Mock server should have received all 3 hits + const state = upstream.getState(TOKEN); + assert.equal(state.hits, 3, `expected 3 hits on mock, got ${state.hits}`); +}); + +test("priority combo with fingerprint connection: request succeeds", async () => { + assert.ok(app); + upstream.resetState(TOKEN); + + const result = await postChat(app.baseUrl, "fp-priority-single", "priority test"); + assert.equal(result.response.status, 200, JSON.stringify(result.json)); + assert.equal(result.json.choices[0].message.content, "fingerprint ok"); + + const state = upstream.getState(TOKEN); + assert.equal(state.hits, 1); +}); + +test("round-robin combo handles 5 sequential requests", async () => { + assert.ok(app); + upstream.resetState(TOKEN); + + for (let i = 0; i < 5; i++) { + const result = await postChat(app.baseUrl, "fp-round-robin", `sequential ${i + 1}`); + assert.equal( + result.response.status, + 200, + `request ${i + 1} failed: ${JSON.stringify(result.json)}` + ); + } + + const state = upstream.getState(TOKEN); + assert.equal(state.hits, 5, `expected 5 hits, got ${state.hits}`); +}); diff --git a/tests/unit/combo-fingerprint-expansion.test.ts b/tests/unit/combo-fingerprint-expansion.test.ts new file mode 100644 index 0000000000..dda557f948 --- /dev/null +++ b/tests/unit/combo-fingerprint-expansion.test.ts @@ -0,0 +1,277 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// #5521 — A mimocode connection with multiple fingerprints in +// provider_specific_data.fingerprints was treated as a single combo target, +// so only one fingerprint (one IP) was used per request. The combo system +// must now expand each fingerprint into its own target so all of them +// participate in the round-robin. + +const { + isFingerprintProvider, + getConnectionFingerprints, + hasMultipleFingerprints, + buildFingerprintExecutionKey, + expandTargetsByFingerprints, +} = await import("../../open-sse/services/combo/fingerprintExpansion.ts"); + +// ── isFingerprintProvider ──────────────────────────────────────────────────── + +test("isFingerprintProvider: mimocode returns true", () => { + assert.equal(isFingerprintProvider("mimocode"), true); +}); + +test("isFingerprintProvider: mcode returns true", () => { + assert.equal(isFingerprintProvider("mcode"), true); +}); + +test("isFingerprintProvider: opencode returns true", () => { + assert.equal(isFingerprintProvider("opencode"), true); +}); + +test("isFingerprintProvider: openai returns false", () => { + assert.equal(isFingerprintProvider("openai"), false); +}); + +test("isFingerprintProvider: anthropic returns false", () => { + assert.equal(isFingerprintProvider("anthropic"), false); +}); + +test("isFingerprintProvider: empty string returns false", () => { + assert.equal(isFingerprintProvider(""), false); +}); + +// ── getConnectionFingerprints ──────────────────────────────────────────────── + +test("getConnectionFingerprints: extracts valid fingerprint strings", () => { + const conn = { + providerSpecificData: { + fingerprints: ["fp-aaa", "fp-bbb", "fp-ccc"], + }, + }; + assert.deepEqual(getConnectionFingerprints(conn), ["fp-aaa", "fp-bbb", "fp-ccc"]); +}); + +test("getConnectionFingerprints: filters out non-string entries", () => { + const conn = { + providerSpecificData: { + fingerprints: ["fp-aaa", null, 123, "fp-bbb", undefined], + }, + }; + assert.deepEqual(getConnectionFingerprints(conn), ["fp-aaa", "fp-bbb"]); +}); + +test("getConnectionFingerprints: filters out empty strings", () => { + const conn = { + providerSpecificData: { + fingerprints: ["fp-aaa", "", " ", "fp-bbb"], + }, + }; + assert.deepEqual(getConnectionFingerprints(conn), ["fp-aaa", "fp-bbb"]); +}); + +test("getConnectionFingerprints: returns empty array for null connection", () => { + assert.deepEqual(getConnectionFingerprints(null), []); +}); + +test("getConnectionFingerprints: returns empty array for undefined", () => { + assert.deepEqual(getConnectionFingerprints(undefined), []); +}); + +test("getConnectionFingerprints: returns empty array when no providerSpecificData", () => { + assert.deepEqual(getConnectionFingerprints({}), []); +}); + +test("getConnectionFingerprints: returns empty array when no fingerprints field", () => { + assert.deepEqual(getConnectionFingerprints({ providerSpecificData: {} }), []); +}); + +test("getConnectionFingerprints: returns empty array when fingerprints is not an array", () => { + assert.deepEqual( + getConnectionFingerprints({ providerSpecificData: { fingerprints: "not-array" } }), + [] + ); +}); + +// ── hasMultipleFingerprints ────────────────────────────────────────────────── + +test("hasMultipleFingerprints: true when 2+ fingerprints", () => { + const conn = { providerSpecificData: { fingerprints: ["fp-1", "fp-2"] } }; + assert.equal(hasMultipleFingerprints(conn), true); +}); + +test("hasMultipleFingerprints: false when exactly 1 fingerprint", () => { + const conn = { providerSpecificData: { fingerprints: ["fp-1"] } }; + assert.equal(hasMultipleFingerprints(conn), false); +}); + +test("hasMultipleFingerprints: false when 0 fingerprints", () => { + const conn = { providerSpecificData: { fingerprints: [] } }; + assert.equal(hasMultipleFingerprints(conn), false); +}); + +test("hasMultipleFingerprints: false for null connection", () => { + assert.equal(hasMultipleFingerprints(null), false); +}); + +// ── buildFingerprintExecutionKey ───────────────────────────────────────────── + +test("buildFingerprintExecutionKey: first fingerprint keeps original key", () => { + assert.equal(buildFingerprintExecutionKey("step-0", "fp-aaa", true), "step-0"); +}); + +test("buildFingerprintExecutionKey: non-first fingerprint appends fp: suffix", () => { + assert.equal(buildFingerprintExecutionKey("step-0", "fp-bbb", false), "step-0@fp:fp-bbb"); +}); + +test("buildFingerprintExecutionKey: long fingerprint is preserved verbatim", () => { + const longFp = "a".repeat(64); + assert.equal(buildFingerprintExecutionKey("key", longFp, false), `key@fp:${longFp}`); +}); + +// ── expandTargetsByFingerprints ────────────────────────────────────────────── + +function makeTarget(overrides: Record = {}) { + return { + kind: "model" as const, + stepId: "step-0", + executionKey: "step-0", + modelStr: "mimocode/mimo-auto", + provider: "mimocode", + providerId: null, + connectionId: "conn-1", + weight: 0, + label: null, + ...overrides, + }; +} + +function makeConnection(fps: string[]) { + return { + id: "conn-1", + provider: "mimocode", + providerSpecificData: { fingerprints: fps }, + }; +} + +test("expandTargetsByFingerprints: non-fingerprint provider passes through", () => { + const targets = [makeTarget({ provider: "openai", modelStr: "openai/gpt-4o" })]; + const connById = new Map>(); + const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); + assert.equal(result.length, 1); + assert.equal(result[0].executionKey, "step-0"); +}); + +test("expandTargetsByFingerprints: target with no connectionId passes through", () => { + const targets = [makeTarget({ connectionId: null })]; + const connById = new Map>(); + const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); + assert.equal(result.length, 1); + assert.equal(result[0].connectionId, null); +}); + +test("expandTargetsByFingerprints: single fingerprint passes through", () => { + const conn = makeConnection(["fp-aaa"]); + const targets = [makeTarget()]; + const connById = new Map([["conn-1", conn]]); + const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); + assert.equal(result.length, 1); + assert.equal(result[0].executionKey, "step-0"); +}); + +test("expandTargetsByFingerprints: 10 fingerprints expands to 10 targets", () => { + const fps = Array.from({ length: 10 }, (_, i) => `fp-${String(i).padStart(2, "0")}`); + const conn = makeConnection(fps); + const targets = [makeTarget()]; + const connById = new Map([["conn-1", conn]]); + const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); + assert.equal(result.length, 10); + assert.equal(result[0].executionKey, "step-0"); + for (let i = 1; i < 10; i++) { + assert.equal(result[i].executionKey, `step-0@fp:fp-${String(i).padStart(2, "0")}`); + } +}); + +test("expandTargetsByFingerprints: preserves all target properties across copies", () => { + const fps = ["fp-aaa", "fp-bbb", "fp-ccc"]; + const conn = makeConnection(fps); + const targets = [ + makeTarget({ connectionId: "conn-1", modelStr: "mimocode/mimo-auto", weight: 5 }), + ]; + const connById = new Map([["conn-1", conn]]); + const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); + assert.equal(result.length, 3); + for (const r of result) { + assert.equal(r.kind, "model"); + assert.equal(r.connectionId, "conn-1"); + assert.equal(r.modelStr, "mimocode/mimo-auto"); + assert.equal(r.provider, "mimocode"); + assert.equal(r.weight, 5); + } +}); + +test("expandTargetsByFingerprints: connection not found in map passes through", () => { + const targets = [makeTarget({ connectionId: "conn-missing" })]; + const connById = new Map>(); + const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); + assert.equal(result.length, 1); + assert.equal(result[0].connectionId, "conn-missing"); +}); + +test("expandTargetsByFingerprints: mixed providers expand only fingerprint ones", () => { + const fps = ["fp-1", "fp-2"]; + const conn = makeConnection(fps); + const targets = [ + makeTarget({ provider: "openai", modelStr: "openai/gpt-4o", connectionId: "conn-oai" }), + makeTarget({ connectionId: "conn-1" }), + ]; + const connById = new Map([ + ["conn-1", conn], + ["conn-oai", { id: "conn-oai", provider: "openai", providerSpecificData: {} }], + ]); + const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); + assert.equal(result.length, 3); + assert.equal(result[0].executionKey, "step-0"); + assert.equal(result[1].executionKey, "step-0"); + assert.equal(result[2].executionKey, "step-0@fp:fp-2"); +}); + +test("expandTargetsByFingerprints: empty input returns empty array", () => { + const connById = new Map>(); + const result = expandTargetsByFingerprints([], connById, (t) => t.provider); + assert.equal(result.length, 0); +}); + +test("expandTargetsByFingerprints: mcode provider expands correctly", () => { + const fps = ["mfp-1", "mfp-2", "mfp-3"]; + const conn = { + id: "conn-m", + provider: "mcode", + providerSpecificData: { fingerprints: fps }, + }; + const targets = [ + makeTarget({ provider: "mcode", modelStr: "mcode/auto", connectionId: "conn-m" }), + ]; + const connById = new Map([["conn-m", conn]]); + const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); + assert.equal(result.length, 3); + assert.equal(result[0].executionKey, "step-0"); + assert.equal(result[1].executionKey, "step-0@fp:mfp-2"); + assert.equal(result[2].executionKey, "step-0@fp:mfp-3"); +}); + +test("expandTargetsByFingerprints: multiple targets each expand independently", () => { + const conn1 = makeConnection(["fp-a1", "fp-a2"]); + const conn2 = makeConnection(["fp-b1", "fp-b2", "fp-b3"]); + const targets = [ + makeTarget({ stepId: "step-0", executionKey: "step-0", connectionId: "conn-1" }), + makeTarget({ stepId: "step-1", executionKey: "step-1", connectionId: "conn-1" }), + ]; + const connById = new Map([["conn-1", conn1]]); + const result = expandTargetsByFingerprints(targets, connById, (t) => t.provider); + assert.equal(result.length, 4); + assert.equal(result[0].executionKey, "step-0"); + assert.equal(result[1].executionKey, "step-0@fp:fp-a2"); + assert.equal(result[2].executionKey, "step-1"); + assert.equal(result[3].executionKey, "step-1@fp:fp-a2"); +});