From 0cbdc95023aaae504d446023bdcdf5f9f4021981 Mon Sep 17 00:00:00 2001 From: Dizzle <112548150+maxmad64bis@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:35:20 +0200 Subject: [PATCH] feat(sse): server-side template expansion for combo system prompts (#5501) (#9414) * feat(sse): server-side template expansion for combo system prompts (#5501) * fix(quality-gates): register combo-system-prompt-templates-5501 test in stryker tap.testFiles check:mutation-test-coverage --strict flagged tests/unit/combo-system-prompt-templates-5501.test.ts as covering src/shared/utils/circuitBreaker.ts without being listed in stryker.conf.json tap.testFiles, so its mutant kills wouldn't count. Co-authored-by: maxmad64bis --------- Co-authored-by: Max Co-authored-by: diegosouzapw Co-authored-by: maxmad64bis --- .../9414-combo-system-prompt-templates.md | 2 + docs/guides/FEATURES.md | 11 + open-sse/services/combo.ts | 28 ++ open-sse/services/combo/dispatchPrelude.ts | 34 ++- open-sse/services/comboAgentMiddleware.ts | 121 ++++++++ stryker.conf.json | 1 + tests/unit/combo-dispatch-prelude.test.ts | 33 +++ ...combo-system-prompt-templates-5501.test.ts | 271 ++++++++++++++++++ 8 files changed, 497 insertions(+), 4 deletions(-) create mode 100644 changelog.d/features/9414-combo-system-prompt-templates.md create mode 100644 tests/unit/combo-system-prompt-templates-5501.test.ts diff --git a/changelog.d/features/9414-combo-system-prompt-templates.md b/changelog.d/features/9414-combo-system-prompt-templates.md new file mode 100644 index 0000000000..05f86c0f6e --- /dev/null +++ b/changelog.d/features/9414-combo-system-prompt-templates.md @@ -0,0 +1,2 @@ +- **feat(sse):** combo `system_message` supports server-side `{{MODEL_ID}}`, `{{PROVIDER_ID}}`, `{{ACCOUNT}}` and `{{FINGERPRINT}}` template expansion from the actually-routed target ([#5501](https://github.com/diegosouzapw/OmniRoute/issues/5501)) +- **feat(sse):** template expansion covers the standard dispatch loop, round-robin and pinned context-cache sessions; fusion, chaos, pipeline and nested-execute strategies do not expand yet ([#5501](https://github.com/diegosouzapw/OmniRoute/issues/5501)) diff --git a/docs/guides/FEATURES.md b/docs/guides/FEATURES.md index 92f2a9f388..d416c2b486 100644 --- a/docs/guides/FEATURES.md +++ b/docs/guides/FEATURES.md @@ -69,6 +69,17 @@ Recent combo improvements: - **Repeated provider support** — reuse the same provider many times in one combo as long as the `(provider, model, connection)` tuple is unique - **Combo target health** — analytics and health surfaces now distinguish individual combo targets/steps instead of collapsing everything into model strings - **Composite tier ordering** — `defaultTier -> fallbackTier` now influences runtime execution/fallback order for top-level combo steps +- **System prompt templates** — combo `system_message` supports server-side + `{{MODEL_ID}}`, `{{PROVIDER_ID}}`, `{{ACCOUNT}}` and `{{FINGERPRINT}}` + placeholders, expanded from the actually-routed target right before dispatch. + Allowlisted and non-recursive; unknown placeholders stay literal; empty values + expand to empty; client system prompts are never rewritten. `{{FINGERPRINT}}` + resolves only for fingerprint-based free providers with a pinned or + auto-rotated fingerprint — it expands to empty elsewhere (e.g. + single-fingerprint connections, non-fp providers). Expansion covers the + standard dispatch loop, round-robin, and pinned context-cache sessions; + fusion, chaos, pipeline and nested-execute strategies do not expand + placeholders yet. ![Combos Dashboard](../screenshots/02-combos.png) diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 618be5d42a..82e4e36d74 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -36,6 +36,10 @@ import { import { buildNoUpstreamResponseDiagnostics, buildRecoveryHint } from "./combo/pinRecovery.ts"; import { buildTargetTimeoutRunner } from "./combo/targetTimeoutRunner.ts"; import { recordComboRequest, recordComboShadowRequest, getComboMetrics } from "./comboMetrics.ts"; +import { + expandComboSystemPromptIfPresent, + resolveTargetFingerprint, +} from "./comboAgentMiddleware.ts"; import { resolveComboConfig, getDefaultComboConfig, @@ -1197,6 +1201,18 @@ export async function handleComboChat({ } } } + // #5501: server-side template expansion for the combo system_message — + // resolved per-target, scoped to combo-injected content only (never + // client-owned system messages). Gate: a non-empty combo system_message. + attemptBody = expandComboSystemPromptIfPresent(attemptBody, combo, { + modelId: modelStr, + providerId: provider !== "unknown" ? provider : "", + account: + typeof target.label === "string" && target.label.trim().length > 0 + ? target.label.trim() + : "", + fingerprint: resolveTargetFingerprint(target) ?? "", + }); const result = await handleSingleModelWithTimeout(attemptBody, modelStr, { ...targetForAttempt, effectiveComboStrategy: strategy, @@ -2618,6 +2634,18 @@ async function handleRoundRobinCombo({ } } + // #5501: combo system_message template expansion per target (same gate + // as the main iteration loop — round-robin branches here, not executeTarget). + attemptBody = expandComboSystemPromptIfPresent(attemptBody, combo, { + modelId: modelStr, + providerId: provider !== "unknown" ? provider : "", + account: + typeof target.label === "string" && target.label.trim().length > 0 + ? target.label.trim() + : "", + fingerprint: resolveTargetFingerprint(target) ?? "", + }); + const result = await handleSingleModel(attemptBody, modelStr, { ...targetForAttempt, effectiveComboStrategy: "round-robin", diff --git a/open-sse/services/combo/dispatchPrelude.ts b/open-sse/services/combo/dispatchPrelude.ts index a3e51e2b69..7caf82fe76 100644 --- a/open-sse/services/combo/dispatchPrelude.ts +++ b/open-sse/services/combo/dispatchPrelude.ts @@ -23,6 +23,10 @@ import { clampComboDepth, MAX_GLOBAL_ATTEMPTS, resolveDelayMs } from "./comboPre import { resolveComboRuntimeUnits, resolveComboTargets } from "./comboStructure.ts"; import { isComboModelVisible } from "./comboVisibility.ts"; import { buildFusionHandleSingleModel, extractFusionPanelSpec } from "./fusionPanel.ts"; +import { + expandComboSystemPromptIfPresent, + resolveTargetFingerprint, +} from "../comboAgentMiddleware.ts"; import { clampStickyWeightedTargetLimit, getStickyRoundRobinStartIndex, @@ -262,12 +266,20 @@ export async function tryPinnedModelDispatch(args: { // when allCombos is authoritative (non-empty) so we can resolve combo-refs; // the auto-combo redirect path passes an empty list and keeps prior behavior. const haveFullCombos = Array.isArray(allCombos) ? allCombos.length > 0 : !!allCombos; - const pinInCombo = resolveComboTargets( + // Eagerly resolve the combo's targets once (used for the pin-validity check AND + // #5501 template expansion). A non-authoritative allCombos (empty/missing) + // resolves to the combo's direct targets only — same semantics as the original + // `!haveFullCombos ||` short-circuit, without feeding `[]` to the nested resolver. + // #5501 also needs these targets eagerly for the combo system_message expansion; + // the release refactor threads `hiddenModelsByProvider` through the resolver so + // hidden models stay filtered on both the pin-validity and expansion paths. + const comboTargets = resolveComboTargets( combo, - haveFullCombos ? allCombos : null, + haveFullCombos ? allCombos : undefined, clampComboDepth(config.maxComboDepth), hiddenModelsByProvider - ).some((target) => target.modelStr === pinnedModel); + ); + const pinInCombo = !haveFullCombos || comboTargets.some((t) => t.modelStr === pinnedModel); // Honor the pin only if it is still a combo target AND its provider is not // DURABLY down. Without the health gate a pin keeps routing a session to a // dead/credits-exhausted/throttled account forever (strategy bypassed, no @@ -282,7 +294,21 @@ export async function tryPinnedModelDispatch(args: { ); let pinnedResult: Response | null = null; try { - pinnedResult = await handleSingleModelWithTimeout(body, pinnedModel, { + // #5501: the combo system_message also expands on the pinned context path — + // a session pin bypasses the main loop, so without this the template would + // go literal from the second in-session request on. Target context comes + // from the pinned model's resolved combo target when available. + const pinnedTarget = comboTargets.find((t) => t.modelStr === pinnedModel); + const pinnedBody = expandComboSystemPromptIfPresent(body, combo, { + modelId: pinnedModel, + providerId: pinnedTarget && pinnedTarget.provider !== "unknown" ? pinnedTarget.provider : "", + account: + typeof pinnedTarget?.label === "string" && pinnedTarget.label.trim().length > 0 + ? pinnedTarget.label.trim() + : "", + fingerprint: pinnedTarget ? resolveTargetFingerprint(pinnedTarget) ?? "" : "", + }); + pinnedResult = await handleSingleModelWithTimeout(pinnedBody, pinnedModel, { modelPinned: true, } as SingleModelTarget); } catch (pinErr) { diff --git a/open-sse/services/comboAgentMiddleware.ts b/open-sse/services/comboAgentMiddleware.ts index f9b2419020..7d968e7835 100644 --- a/open-sse/services/comboAgentMiddleware.ts +++ b/open-sse/services/comboAgentMiddleware.ts @@ -19,6 +19,8 @@ * All features are opt-in per combo and backward compatible with existing setups. */ +import { isFingerprintProvider } from "./combo/fingerprintExpansion.ts"; + interface ComboConfig { system_message?: string | null; tool_filter_regex?: string | null; @@ -221,3 +223,122 @@ export function applyComboAgentMiddleware( pinnedModel, }; } + +// ── System Prompt Template Expansion (#5501) ───────────────────────────────── + +export interface ComboSystemPromptTemplateContext { + modelId: string; + providerId: string; + account: string; + fingerprint: string; +} + +/** + * Replace allowlisted `{{TOKEN}}` placeholders in a single left-to-right scan. + * No regex (ReDoS-averse, cf. #3870) and no recursion: an expanded value is + * appended to the output and never re-scanned. Unknown tokens ({{FOO}}) and + * dangling "{{" stay literal. + */ +function expandStringTemplates(value: string, values: Record): string { + let out = ""; + let rest = value; + while (rest.length > 0) { + const start = rest.indexOf("{{"); + if (start === -1) { + out += rest; + break; + } + const end = rest.indexOf("}}", start + 2); + if (end === -1) { + out += rest; + break; + } + const token = rest.slice(start, end + 2); + out += rest.slice(0, start); + out += token in values ? values[token] : token; + rest = rest.slice(end + 2); + } + return out; +} + +/** + * Expand allowlisted placeholders in the combo-injected system prompt (#5501). + * + * Strictly scoped to the content the combo override produced — never + * client-owned system content: + * - Responses API body (has `instructions`) → expand `body.instructions`. + * - messages body → expand `body.messages[0]` when it is the injected combo + * system message (the override filters all system messages and injects its + * own at index 0 with string content). + * - otherwise → body unchanged. + */ +export function expandComboSystemPromptTemplates( + body: Record, + ctx: ComboSystemPromptTemplateContext +): Record { + const values: Record = { + "{{MODEL_ID}}": ctx.modelId, + "{{PROVIDER_ID}}": ctx.providerId, + "{{ACCOUNT}}": ctx.account, + "{{FINGERPRINT}}": ctx.fingerprint, + }; + const result = { ...body }; + if (typeof result.instructions === "string") { + result.instructions = expandStringTemplates(result.instructions, values); + return result; + } + const messages = result.messages; + if (Array.isArray(messages)) { + const first = messages[0] as Record | undefined; + if ( + first && + (first.role === "system" || first.role === "developer") && + typeof first.content === "string" + ) { + const next = [...messages]; + next[0] = { ...first, content: expandStringTemplates(first.content, values) }; + result.messages = next; + } + } + return result; +} + +/** + * Gate + expand: expand the combo `system_message` template placeholders only + * when the combo actually defines a non-empty `system_message`. Client-owned + * content passes through untouched (single gate shared by every dispatch path). + */ +export function expandComboSystemPromptIfPresent( + body: Record, + combo: { system_message?: string | null }, + ctx: ComboSystemPromptTemplateContext +): Record { + if (typeof combo.system_message === "string" && combo.system_message.trim()) { + return expandComboSystemPromptTemplates(body, ctx); + } + return body; +} + +/** + * Resolve the device fingerprint for a combo target (#5501, #6087). + * Only fingerprint-based providers carry fingerprints (see isFingerprintProvider). + * Priority: explicit pin (`pinnedFingerprint`, combo builder) → the `@fp:` + * suffix in `executionKey` (auto-rotation). + * Returns null when none is knowable (the first fingerprint of an auto-rotated + * set keeps the bare execution key — documented limitation). + */ +export function resolveTargetFingerprint(target: { + provider: string; + pinnedFingerprint?: string; + executionKey?: string; +}): string | null { + if (!isFingerprintProvider(target.provider)) return null; + if (target.pinnedFingerprint) return target.pinnedFingerprint; + const key = target.executionKey; + if (key) { + const marker = "@fp:"; + const idx = key.lastIndexOf(marker); + if (idx !== -1) return key.slice(idx + marker.length); + } + return null; +} diff --git a/stryker.conf.json b/stryker.conf.json index a4c0534a6b..a7b59942a5 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -188,6 +188,7 @@ "tests/unit/combo-stream-readiness-fallback.test.ts", "tests/unit/combo-streaming-empty-content-failover.test.ts", "tests/unit/combo-strict-random-distribution-3959.test.ts", + "tests/unit/combo-system-prompt-templates-5501.test.ts", "tests/unit/combo-target-defensive-modelstr.test.ts", "tests/unit/combo-vision-aware-routing.test.ts", "tests/unit/combo/auto-quota-cutoff.test.ts", diff --git a/tests/unit/combo-dispatch-prelude.test.ts b/tests/unit/combo-dispatch-prelude.test.ts index b3d946976c..688abc8b26 100644 --- a/tests/unit/combo-dispatch-prelude.test.ts +++ b/tests/unit/combo-dispatch-prelude.test.ts @@ -457,6 +457,39 @@ test("tryPinnedModelDispatch: serves the pinned response when the pin is healthy ); }); +test("tryPinnedModelDispatch: expands the combo system_message template on the pinned path (#5501)", async () => { + const ctx = setup({ + name: "pinned-combo", + strategy: "priority", + models: [{ model: `${HEALTHY_PROVIDER}/live` }], + config: {}, + system_message: "Model: {{MODEL_ID}}", + }); + ctx.body = { + messages: [ + { role: "system", content: "Model: {{MODEL_ID}}" }, + { role: "user", content: "hi" }, + ], + }; + await seedHealthyPinProvider(); + const seen: string[] = []; + const res = await tryPinnedModelDispatch({ + body: ctx.body, + combo: ctx.combo, + pinnedModel: `${HEALTHY_PROVIDER}/live`, + allCombos: [], + config: ctx.config, + clientRequestedStream: false, + handleSingleModelWithTimeout: async (received: Record) => { + seen.push((received.messages as { content: string }[])[0].content); + return okResponse("pinned answer"); + }, + log: ctx.log, + }); + assert.ok(res, "the healthy pin must be served"); + assert.deepEqual(seen, [`Model: ${HEALTHY_PROVIDER}/live`]); +}); + test("tryPinnedModelDispatch: fails over when the pinned model returns a transient status", async () => { for (const status of [408, 429, 500, 502, 503, 504]) { const ctx = pinCtx(); diff --git a/tests/unit/combo-system-prompt-templates-5501.test.ts b/tests/unit/combo-system-prompt-templates-5501.test.ts new file mode 100644 index 0000000000..73c1aaa5cb --- /dev/null +++ b/tests/unit/combo-system-prompt-templates-5501.test.ts @@ -0,0 +1,271 @@ +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"; + +// Node's test runner stops registering tests past a top-level `await import`, so +// every module import — and the DATA_DIR pin that must precede combo.ts — happens +// here, before any `test()` call. Mirrors the combo-attempt-body-isolation harness. +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-combo-tpl-")); +const ORIGINAL_DATA_DIR = process.env.DATA_DIR; +process.env.DATA_DIR = TEST_DATA_DIR; + +const { + expandComboSystemPromptIfPresent, + expandComboSystemPromptTemplates, + resolveTargetFingerprint, +} = await import("../../open-sse/services/comboAgentMiddleware.ts"); +const { handleComboChat } = await import("../../open-sse/services/combo.ts"); +const core = await import("../../src/lib/db/core.ts"); +const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts"); +const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts"); +const { resetAll: resetAllSemaphores } = + await import("../../open-sse/services/rateLimitSemaphore.ts"); +const { _resetAllDecks } = await import("../../src/shared/utils/shuffleDeck.ts"); +const { clearSessions } = await import("../../open-sse/services/sessionManager.ts"); + +const CTX = { + modelId: "openrouter/owl-alpha", + providerId: "openrouter", + account: "my-key", + fingerprint: "fp-123", +}; + +function createLog() { + const entries: unknown[] = []; + const push = (level: string) => (tag: unknown, msg: unknown) => entries.push({ level, tag, msg }); + return { + info: push("info"), + warn: push("warn"), + error: push("error"), + debug: push("debug"), + entries, + }; +} + +const okResponse = () => + new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + +const MODELS = ["openai/gpt-4o-mini", "claude/sonnet", "gemini/flash"]; + +function comboOf(name: string, systemMessage?: string, strategy = "priority") { + return { + name, + strategy, + models: MODELS, + config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 }, + ...(systemMessage ? { system_message: systemMessage } : {}), + }; +} + +function bodyWithSystem(content: string) { + return { + model: "openai/gpt-4o-mini", + max_tokens: 100, + messages: [{ role: "system", content }, { role: "user", content: "hi" }], + }; +} + +test("messages format: expands all placeholders in messages[0] system content", () => { + const body = { + messages: [ + { role: "system", content: "M={{MODEL_ID}} P={{PROVIDER_ID}} A={{ACCOUNT}} F={{FINGERPRINT}}" }, + { role: "user", content: "hi" }, + ], + }; + const out = expandComboSystemPromptTemplates(body, CTX); + assert.equal(out.messages[0].content, "M=openrouter/owl-alpha P=openrouter A=my-key F=fp-123"); + assert.equal(out.messages[1].content, "hi"); +}); + +test("instructions (Responses API): expands instructions, leaves client messages untouched", () => { + const body = { + instructions: "Model: {{MODEL_ID}}", + input: "hi", + messages: [{ role: "system", content: "client {{MODEL_ID}}" }], + }; + const out = expandComboSystemPromptTemplates(body, CTX); + assert.equal(out.instructions, "Model: openrouter/owl-alpha"); + assert.equal(out.messages[0].content, "client {{MODEL_ID}}"); +}); + +test("unknown placeholder stays literal", () => { + const body = { messages: [{ role: "system", content: "{{FOO}} {{MODEL_ID}}" }] }; + const out = expandComboSystemPromptTemplates(body, CTX); + assert.equal(out.messages[0].content, "{{FOO}} openrouter/owl-alpha"); +}); + +test("no recursion: expanded value is never re-scanned", () => { + const body = { messages: [{ role: "system", content: "{{MODEL_ID}}" }] }; + const out = expandComboSystemPromptTemplates(body, { ...CTX, modelId: "{{PROVIDER_ID}}" }); + assert.equal(out.messages[0].content, "{{PROVIDER_ID}}"); +}); + +test("empty value expands to empty string", () => { + const body = { messages: [{ role: "system", content: "[{{FINGERPRINT}}]" }] }; + const out = expandComboSystemPromptTemplates(body, { ...CTX, fingerprint: "" }); + assert.equal(out.messages[0].content, "[]"); +}); + +test("no placeholders: body unchanged (deep equal)", () => { + const body = { + messages: [{ role: "system", content: "plain" }, { role: "user", content: "hi" }], + }; + const out = expandComboSystemPromptTemplates(body, CTX); + assert.deepEqual(out, body); +}); + +test("messages[0] non-system role: unchanged", () => { + const body = { messages: [{ role: "user", content: "{{MODEL_ID}}" }] }; + const out = expandComboSystemPromptTemplates(body, CTX); + assert.equal(out.messages[0].content, "{{MODEL_ID}}"); +}); + +test("expandComboSystemPromptIfPresent: absent system_message passes body through", () => { + const body = { messages: [{ role: "system", content: "keep {{MODEL_ID}}" }] }; + const out = expandComboSystemPromptIfPresent(body, { system_message: null }, CTX); + assert.equal(out, body); + const out2 = expandComboSystemPromptIfPresent(body, {}, CTX); + assert.equal(out2, body); +}); + +test("expandComboSystemPromptIfPresent: blank system_message passes body through", () => { + const body = { messages: [{ role: "system", content: "keep {{MODEL_ID}}" }] }; + const out = expandComboSystemPromptIfPresent(body, { system_message: " " }, CTX); + assert.equal(out, body); +}); + +test("expandComboSystemPromptIfPresent: non-empty system_message expands", () => { + const body = { messages: [{ role: "system", content: "M={{MODEL_ID}}" }] }; + const out = expandComboSystemPromptIfPresent(body, { system_message: "M={{MODEL_ID}}" }, CTX); + assert.equal(out.messages[0].content, "M=openrouter/owl-alpha"); +}); + +test("resolveTargetFingerprint: non-fp provider returns null", () => { + assert.equal(resolveTargetFingerprint({ provider: "openai", executionKey: "k@fp:abc" }), null); +}); + +test("resolveTargetFingerprint: pinned fingerprint wins", () => { + assert.equal( + resolveTargetFingerprint({ provider: "opencode", pinnedFingerprint: "pin1", executionKey: "k@fp:abc" }), + "pin1" + ); +}); + +test("resolveTargetFingerprint: parses @fp: suffix from executionKey", () => { + assert.equal(resolveTargetFingerprint({ provider: "mcode", executionKey: "k@fp:abc" }), "abc"); +}); + +test("resolveTargetFingerprint: null when no source", () => { + assert.equal(resolveTargetFingerprint({ provider: "opencode", executionKey: "k" }), null); + assert.equal(resolveTargetFingerprint({ provider: "mimocode" }), null); +}); + +// ── Integration: hook + gate through handleComboChat (#5501) ────────────────── + +test.beforeEach(() => { + resetAllComboMetrics(); + resetAllCircuitBreakers(); + resetAllSemaphores(); + _resetAllDecks(); + clearSessions(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + if (ORIGINAL_DATA_DIR === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = ORIGINAL_DATA_DIR; +}); + +test("combo system_message: {{MODEL_ID}} expands to the resolved target model", async () => { + const seen: string[] = []; + await handleComboChat({ + body: bodyWithSystem("placeholder"), + combo: comboOf("cow-tpl-expand", "Model: {{MODEL_ID}}"), + handleSingleModel: async (received: Record) => { + seen.push((received.messages as { content: string }[])[0].content); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + assert.deepEqual(seen, ["Model: openai/gpt-4o-mini"]); +}); + +test("gate: without combo system_message, client system content is NOT expanded", async () => { + const seen: string[] = []; + await handleComboChat({ + body: bodyWithSystem("keep {{MODEL_ID}} literal"), + combo: comboOf("cow-tpl-gate"), + handleSingleModel: async (received: Record) => { + seen.push((received.messages as { content: string }[])[0].content); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + assert.deepEqual(seen, ["keep {{MODEL_ID}} literal"]); +}); + +test("{{FINGERPRINT}} on a non-fp target expands to empty string, not literal 'null'", async () => { + const seen: string[] = []; + await handleComboChat({ + body: bodyWithSystem("placeholder"), + combo: comboOf("cow-tpl-fp", "F=[{{FINGERPRINT}}]"), + handleSingleModel: async (received: Record) => { + seen.push((received.messages as { content: string }[])[0].content); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + assert.deepEqual(seen, ["F=[]"]); +}); + +test("round-robin combo: {{MODEL_ID}} expands to the resolved target model", async () => { + const seen: string[] = []; + const routed: string[] = []; + await handleComboChat({ + body: bodyWithSystem("placeholder"), + combo: comboOf("cow-tpl-rr", "Model: {{MODEL_ID}}", "round-robin"), + handleSingleModel: async (received: Record, modelStr: string) => { + seen.push((received.messages as { content: string }[])[0].content); + routed.push(modelStr); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + assert.equal(seen.length, 1, "round-robin must dispatch exactly one target"); + assert.deepEqual(seen, [`Model: ${routed[0]}`]); +}); + +test("round-robin gate: without combo system_message, client system content stays literal", async () => { + const seen: string[] = []; + await handleComboChat({ + body: bodyWithSystem("keep {{MODEL_ID}} literal"), + combo: comboOf("cow-tpl-rr-gate", undefined, "round-robin"), + handleSingleModel: async (received: Record) => { + seen.push((received.messages as { content: string }[])[0].content); + return okResponse(); + }, + isModelAvailable: async () => true, + log: createLog(), + settings: null, + allCombos: null, + }); + assert.deepEqual(seen, ["keep {{MODEL_ID}} literal"]); +}); \ No newline at end of file