fix(combo): expand fingerprint-based providers into per-fingerprint combo targets (#6082)

Expand fingerprint-based providers into per-fingerprint combo targets. Integrated into release/v3.8.44.
This commit is contained in:
PizzaV
2026-07-04 00:39:04 +02:00
committed by GitHub
parent 32e4c906ef
commit 421a1ec5bc
4 changed files with 730 additions and 1 deletions

View File

@@ -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";

View File

@@ -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<string> = 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<string, unknown> | undefined | null
): string[] {
if (!connection || typeof connection !== "object") return [];
const psd = connection["providerSpecificData"];
if (!psd || typeof psd !== "object") return [];
const fps = (psd as Record<string, unknown>)["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<string, unknown> | 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<string, Record<string, unknown>>,
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;
}

View File

@@ -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<number> {
return new Promise<number>((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<typeof spawn>) {
if (child.killed) return;
child.kill("SIGTERM");
const exited = await Promise.race([
new Promise<boolean>((resolve) => child.once("exit", () => resolve(true))),
sleep(5_000).then(() => false),
]);
if (!exited && !child.killed) {
child.kill("SIGKILL");
await new Promise<void>((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<typeof spawn>;
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}`);
});

View File

@@ -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<string, unknown> = {}) {
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<string, Record<string, unknown>>();
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<string, Record<string, unknown>>();
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<string, Record<string, unknown>>();
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<string, Record<string, unknown>>();
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");
});