mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
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:
@@ -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";
|
||||
|
||||
105
open-sse/services/combo/fingerprintExpansion.ts
Normal file
105
open-sse/services/combo/fingerprintExpansion.ts
Normal 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;
|
||||
}
|
||||
335
tests/e2e/fingerprint-expansion.test.ts
Normal file
335
tests/e2e/fingerprint-expansion.test.ts
Normal 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}`);
|
||||
});
|
||||
277
tests/unit/combo-fingerprint-expansion.test.ts
Normal file
277
tests/unit/combo-fingerprint-expansion.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user