Compare commits

...

4 Commits

Author SHA1 Message Date
Bob.Hou
8ff7f7daf0 fix(sse): relocate directive-only messages off messages[0] (#10457)
The upstream Messages API rejects directive-style messages (empty content
array with a message-level output_config) when they sit at messages[0] —
the initial system prompt position — while accepting the form at any other
position. Measured in production: 122x 400 on the offical-claude combo in
one hour.

The mid-conversation-system passthrough (official provider + 1M-context
beta models) keeps system-role messages inside messages[], so a directive
that arrived first went upstream unchanged. relocateDirectiveOnlyMessages()
moves the whole leading run of empty system messages: directive-only ones
past the first real turn, plain empties dropped. extractSystemRoleMessages()
now folds a directive's output_config into the top-level parameter instead
of silently discarding it.

Signed-off-by: Minxi Hou <houminxi@gmail.com>
2026-08-15 13:51:40 -03:00
Hernan Javier Ardila Sanchez
e168b2347e fix(combo): restrict auto combo pools to user-visible models (#10456)
Auto combos (virtual auto/* pools via virtualFactory and pure-auto named
combos via expandAutoComboCandidatePool) expanded their candidate pool from
the provider's STATIC registry catalog, which can include models the operator
never synced or approved (e.g. openrouter/auto). The visibility filter
(getHiddenModelsByProvider) only caught models explicitly flagged isHidden,
so catalog-only models passed through and got routed upstream.

Build the credentialed pool from the models the user actually has available
(synced + custom non-hidden), falling back to the static catalog only when
the operator has no synced/custom models for that provider. Applies to every
provider uniformly (openai, kilocode, openrouter, ...), with per-connection
scoping for synced models. Provider wildcards (providerWildcard.ts) already
used the active synced catalog as the authoritative source.

Regression coverage: tests/unit/combo-auto-pool-visible-only.test.ts

Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
2026-08-15 13:51:34 -03:00
backryun
4adf50dbcb fix(ci): clean up Windows packaged smoke process trees (#10453) 2026-08-15 13:51:11 -03:00
backryun
370c1b9ae7 test(build): resolve standalone fixture paths from file URLs (#10451) 2026-08-15 13:50:46 -03:00
10 changed files with 790 additions and 16 deletions

View File

@@ -7,8 +7,8 @@ import { resolveChatCoreRequestSetup } from "./chatCore/requestSetup.ts";
import { normalizeOpenAICompatibleTools } from "./chatCore/openAICompatibleTools.ts";
import { buildFailureUsageRecord } from "./chatCore/failureUsage.ts";
import { estimateFinalInputTokens } from "./chatCore/contextEstimation.ts";
import { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts";
export { extractSystemRoleMessages } from "./chatCore/claudeSystemRole.ts";
import { extractSystemRoleMessages, relocateDirectiveOnlyMessages } from "./chatCore/claudeSystemRole.ts";
export { extractSystemRoleMessages, relocateDirectiveOnlyMessages } from "./chatCore/claudeSystemRole.ts";
import { checkIdempotencyCache } from "./chatCore/idempotency.ts";
import { checkSemanticCache } from "./chatCore/semanticCache.ts";
import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts";
@@ -2132,6 +2132,12 @@ export async function handleChatCore({
!shouldUseMidConversationSystem(translatedBody, effectiveModel)
) {
extractSystemRoleMessages(translatedBody);
} else {
// The mid-conversation-system path keeps system-role messages inside
// messages[], but a directive-only message (content: [] +
// output_config) at messages[0] is rejected by Anthropic. Move it past
// the first real turn; Anthropic accepts the form at any other position.
relocateDirectiveOnlyMessages(translatedBody);
}
if (Array.isArray(translatedBody.messages)) {
translatedBody.messages = splitMisplacedToolResults(

View File

@@ -135,6 +135,21 @@ export function extractSystemRoleMessages(payload: Record<string, unknown>): voi
}
}
}
// Directive payload (message-level output_config, as emitted by Claude
// Code clients): the message itself is lifted away, so fold its output
// configuration into the top-level parameter instead of silently dropping
// it — whatever shape the content had. An explicit top-level output_config
// wins, and among several directive messages the first one wins.
if (payload.output_config == null) {
const directive = sm as Record<string, unknown>;
if (
directive.output_config != null &&
typeof directive.output_config === "object" &&
!Array.isArray(directive.output_config)
) {
payload.output_config = directive.output_config;
}
}
}
if (extraBlocks.length > 0) {
const existingSystem = payload.system;
@@ -148,3 +163,85 @@ export function extractSystemRoleMessages(payload: Record<string, unknown>): voi
}
payload.messages = messages.filter((m) => !isSystemRole(m.role));
}
/**
* Moves a directive-only system message (empty content array + message-level
* `output_config`, the shape Claude Code clients emit) off `messages[0]`.
*
* Anthropic treats `messages[0]` as the initial system prompt position and
* rejects the directive-only form there ("use the top-level 'system' parameter
* for the initial system prompt"), while accepting it at any other position.
* The mid-conversation-system passthrough (provider `claude` + 1M-context beta
* models) deliberately keeps system-role messages inside `messages[]`, so a
* directive that arrived first would go upstream unchanged and 400. Relocate it
* past the first real turn instead; when the conversation has no real turn at
* all, fold the `output_config` into the top-level parameter (which wins when
* already present) and drop the now-empty message.
*/
export function relocateDirectiveOnlyMessages(payload: Record<string, unknown>): void {
if (!Array.isArray(payload.messages) || payload.messages.length === 0) return;
const messages = payload.messages as Array<Record<string, unknown>>;
const isSystemRole = (role: unknown): boolean =>
typeof role === "string" &&
(role.toLowerCase() === "system" || role.toLowerCase() === "developer");
const isEmptySystem = (m: Record<string, unknown>): boolean =>
m != null &&
typeof m === "object" &&
isSystemRole(m.role) &&
Array.isArray(m.content) &&
m.content.length === 0;
const isDirectiveOnly = (m: Record<string, unknown>): boolean =>
isEmptySystem(m) &&
m.output_config != null &&
typeof m.output_config === "object" &&
!Array.isArray(m.output_config);
if (!isEmptySystem(messages[0])) {
return;
}
// Collect the whole leading run of empty system messages so consecutive
// directives are all relocated in one pass (handling only messages[0] would
// leave the second directive at the rejected position).
let runEnd = 0;
while (runEnd < messages.length && isEmptySystem(messages[runEnd])) {
runEnd++;
}
const lead = messages.slice(0, runEnd);
const directives = lead.filter(isDirectiveOnly);
// First real (user/assistant) turn after the run. System messages with text
// content are not safe insertion anchors — keep walking past them, and past
// any non-object entries a malformed body may carry.
let insertAfter = -1;
for (let i = runEnd; i < messages.length; i++) {
const candidate = messages[i];
if (
candidate != null &&
typeof candidate === "object" &&
!isSystemRole(candidate.role)
) {
insertAfter = i;
break;
}
}
if (insertAfter === -1) {
// No real turn to relocate after: fold the first directive's
// output_config into the top-level parameter (an explicit top-level value
// wins) and drop the whole run.
if (payload.output_config == null && directives.length > 0) {
payload.output_config = directives[0].output_config;
}
payload.messages = messages.slice(runEnd);
return;
}
// Move the directives (in order) past the first real turn; plain empty
// system messages carry nothing and are dropped.
payload.messages = [
...messages.slice(runEnd, insertAfter + 1),
...directives,
...messages.slice(insertAfter + 1),
];
}

View File

@@ -23,6 +23,7 @@ import {
import type { AutoVariant } from "./autoPrefix";
import { buildFamilyCandidateFilter, type ModelFamily } from "./modelFamily";
import { getHiddenModelsByProvider } from "@/models";
import { getSyncedAvailableModelsByConnection, getCustomModels } from "@/lib/db/models";
import { filterPaidOnlyCandidates } from "./paidModelFilter";
import { isModelExcludedByConnection } from "@/domain/connectionModelRules";
import { filterExcludedCandidates } from "./candidateOverrides";
@@ -481,15 +482,41 @@ export async function prepareVirtualAutoComboInputs(
const defaultModelIds = providerConnections
.map((conn) => (typeof conn.defaultModel === "string" ? conn.defaultModel.trim() : ""))
.filter(Boolean);
const modelIds = Array.from(new Set([...registryModelIds, ...defaultModelIds]));
const hiddenModels = hiddenModelsMap.get(providerId);
// #auto-pool-visible-only: build the credentialed pool from the models the user
// actually has available (synced + custom non-hidden) when any exist, falling
// back to the static catalog only when the user has none. This keeps catalog-only
// models (e.g. openrouter/auto) out of every auto/* pool when the operator only
// synced a subset (e.g. OpenRouter with importFreeModelsOnly).
const [syncedByConnection, customModels] = await Promise.all([
getSyncedAvailableModelsByConnection(providerId),
getCustomModels(providerId),
]);
const userVisibleIds = new Set<string>();
for (const models of Object.values(syncedByConnection)) {
for (const m of models) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);
}
for (const m of customModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);
const hasUserModels = userVisibleIds.size > 0;
const modelIds = hasUserModels
? Array.from(userVisibleIds)
: Array.from(new Set([...registryModelIds, ...defaultModelIds]));
for (const modelId of modelIds) {
if (hiddenModels?.has(modelId)) continue;
const allowedConnectionIds = providerConnections
.filter((conn) => {
if (isModelExcludedByConnection(modelId, conn.providerSpecificData)) return false;
if (hasUserModels) {
// User-synced models are scoped to the connections that carry them;
// custom models are provider-wide like registry models.
const connSynced = syncedByConnection[conn.id] ?? [];
const isSyncedForConn = connSynced.some((m) => m.id === modelId);
const isCustomForProvider = customModels.some((m) => m.id === modelId);
return isSyncedForConn || isCustomForProvider || conn.defaultModel?.trim() === modelId;
}
// Registry models are provider-wide. A non-registry default (for a custom
// or passthrough model) is scoped only to connections that selected it.
return registryModelIdSet.has(modelId) || conn.defaultModel?.trim() === modelId;

View File

@@ -39,6 +39,11 @@ import {
} from "../autoCombo/scoring.ts";
import type { RoutingHint } from "../manifestAdapter";
import { getCachedProviderConnections } from "../../../src/lib/db/readCache";
import {
getSyncedAvailableModels,
getCustomModels,
getHiddenModelsByProvider,
} from "../../../src/lib/db/models";
import { getProviderModels } from "../../config/providerModels.ts";
import {
getConnectionRoutingTags,
@@ -458,10 +463,27 @@ export async function expandAutoComboCandidatePool(
// expansion doesn't turn into O(n^2) per provider. See #OOM incident
// (zero-config auto combo expanding to 1000s of provider/model targets).
const seenModelStrs = new Set(eligibleTargets.map((t) => t.modelStr));
const hiddenModelsMap = getHiddenModelsByProvider();
for (const providerId of providerIds) {
const providerModels = getProviderModels(providerId);
for (const model of providerModels) {
const modelStr = `${providerId}/${model.id}`;
// #auto-pool-visible-only: when the operator has synced/custom models for
// this provider, expand ONLY those (minus hidden); fall back to the static
// catalog only when the user has none. This keeps catalog-only models
// (e.g. openrouter/auto) out of pure-auto pools when the operator only
// synced a subset (e.g. OpenRouter with importFreeModelsOnly).
const [syncedModels, customModels] = await Promise.all([
getSyncedAvailableModels(providerId),
getCustomModels(providerId),
]);
const hiddenModels = hiddenModelsMap.get(providerId);
const userVisibleIds = new Set<string>();
for (const m of syncedModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);
for (const m of customModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);
const hasUserModels = userVisibleIds.size > 0;
const expandIds = hasUserModels
? Array.from(userVisibleIds)
: getProviderModels(providerId).map((m) => m.id);
for (const modelId of expandIds) {
const modelStr = `${providerId}/${modelId}`;
if (!seenModelStrs.has(modelStr)) {
seenModelStrs.add(modelStr);
eligibleTargets.push({

View File

@@ -255,20 +255,33 @@ async function signalProcessTree(child, signal) {
}
}
async function stopApp(child) {
export async function stopApp(
child,
{
currentPlatform = platform(),
signalProcessTreeFn = signalProcessTree,
waitForProcessTreeExitFn = waitForProcessTreeExit,
} = {}
) {
if (!child.pid) return;
await signalProcessTree(child, "SIGTERM");
await waitForProcessTreeExit(child, 5_000);
// On Windows, terminating only the direct Electron process can orphan the
// packaged server when the parent exits before the follow-up liveness check.
// Kill the process tree in one operation while the root PID is still valid.
if (currentPlatform === "win32") {
await signalProcessTreeFn(child, "SIGKILL");
await waitForProcessTreeExitFn(child, 2_000);
return;
}
const isStillRunning =
platform() === "win32"
? child.exitCode === null && child.signalCode === null
: isProcessGroupAlive(child.pid);
await signalProcessTreeFn(child, "SIGTERM");
await waitForProcessTreeExitFn(child, 5_000);
const isStillRunning = isProcessGroupAlive(child.pid);
if (isStillRunning) {
await signalProcessTree(child, "SIGKILL");
await waitForProcessTreeExit(child, 2_000);
await signalProcessTreeFn(child, "SIGKILL");
await waitForProcessTreeExitFn(child, 2_000);
}
}

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
assembleStandalone,
patchTurbopackChunks,
@@ -197,7 +198,7 @@ test("the TPROXY addon source is skipped gracefully when it was not built (non-L
// the requirement from the source itself: EVERY relative import in
// standalone-server-ws.mjs must be shipped into the bundle by the extra-module sync.
test("every relative import of standalone-server-ws.mjs is shipped into the bundle", async () => {
const repoRoot = path.resolve(new URL(".", import.meta.url).pathname, "../../..");
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const serverWsSrc = fs.readFileSync(
path.join(repoRoot, "scripts/dev/standalone-server-ws.mjs"),
"utf8"

View File

@@ -0,0 +1,120 @@
// @ts-nocheck
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-directive-midconv-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
const originalFetch = globalThis.fetch;
function noopLog() {
return {
debug() {},
info() {},
warn() {},
error() {},
};
}
async function flushAsyncSideEffects() {
for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve));
}
test.afterEach(async () => {
globalThis.fetch = originalFetch;
await flushAsyncSideEffects();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});
test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("claude mid-conversation-system passthrough relocates a directive-only messages[0]", async () => {
let captured = null;
globalThis.fetch = async (url, init = {}) => {
captured = {
url: String(url),
method: init.method ?? "GET",
headers: new Headers(init.headers),
body: JSON.parse(String(init.body || "{}")),
};
return new Response(
JSON.stringify({
id: "msg_test",
type: "message",
role: "assistant",
model: "claude-opus-5",
content: [{ type: "text", text: "OK" }],
usage: { input_tokens: 4, output_tokens: 1 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
};
const body = {
model: "claude-opus-5",
max_tokens: 64,
system: [{ type: "text", text: "You are Claude." }],
tools: [{ name: "Bash", description: "Run a command", input_schema: { type: "object" } }],
messages: [
{ role: "system", content: [], output_config: { effort: "medium" } },
{ role: "user", content: "hello" },
],
stream: false,
};
const result = await handleChatCore({
body: structuredClone(body),
modelInfo: { provider: "claude", model: "claude-opus-5", extendedContext: false },
credentials: { apiKey: "test-claude-key", providerSpecificData: {} },
log: noopLog(),
clientRawRequest: {
endpoint: "/v1/messages",
body: structuredClone(body),
headers: new Headers({
accept: "application/json",
"content-type": "application/json",
"user-agent": "claude-code/2.1.154",
}),
},
userAgent: "claude-code/2.1.154",
});
assert.ok(captured, "fetch was not called");
assert.ok(captured.url.startsWith("https://api.anthropic.com/v1/messages"));
assert.equal(captured.method, "POST");
assert.ok(captured.headers.get("x-api-key"), "x-api-key header missing");
assert.ok(captured.headers.get("anthropic-version"), "anthropic-version header missing");
assert.equal(result.success, true);
// The directive-only message must not sit at messages[0] when it reaches upstream.
const upstreamMessages = captured.body.messages;
assert.equal(upstreamMessages[0].role, "user");
assert.equal(upstreamMessages[1].role, "system");
assert.deepEqual(upstreamMessages[1].output_config, { effort: "medium" });
// The relocation must not disturb anything else the client sent.
assert.deepEqual(upstreamMessages[1].content, []);
assert.equal(upstreamMessages[0].content, "hello");
// The claude identity layer prepends its own blocks; assert the client's
// block survived rather than an exact count.
assert.ok(
captured.body.system.some(
(block) => block.type === "text" && block.text === "You are Claude."
)
);
assert.equal(captured.body.tools.length, 1);
// The directive stays message-level; the top level (if set) is the base
// executor's own default injection, not the hoisted directive value.
assert.notDeepEqual(captured.body.output_config, { effort: "medium" });
});

View File

@@ -0,0 +1,291 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
extractSystemRoleMessages,
relocateDirectiveOnlyMessages,
} from "../../open-sse/handlers/chatCore.ts";
// Claude Code 2.1.154+ clients send directives as system-role messages with an
// empty content array and a message-level output_config. Anthropic rejects the
// directive-only form when it lands at messages[0] (the initial system prompt
// position) while accepting it at any other position. Upstream error text:
// messages.0: use the top-level 'system' parameter for the initial system
// prompt; the directive-only form (content: [] with output_config) is
// accepted at any position
// Measured in production: 122x 400 in one hour on the offical-claude combo.
test("relocateDirectiveOnlyMessages moves a directive-only messages[0] past the first real turn", () => {
const payload = {
messages: [
{ role: "system", content: [], output_config: { effort: "high" } },
{ role: "user", content: "hello" },
{ role: "assistant", content: "hi" },
],
};
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages.length, 3);
assert.equal(payload.messages[0].role, "user");
assert.equal(payload.messages[1].role, "system");
assert.deepEqual(payload.messages[1].output_config, { effort: "high" });
assert.equal(payload.messages[2].role, "assistant");
});
test("relocateDirectiveOnlyMessages skips consecutive system messages to find the real turn", () => {
const payload = {
messages: [
{ role: "system", content: [], output_config: { effort: "high" } },
{ role: "system", content: "mid-conversation context" },
{ role: "user", content: "hello" },
],
};
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages.length, 3);
assert.equal(payload.messages[0].role, "system");
assert.equal(payload.messages[0].content, "mid-conversation context");
assert.equal(payload.messages[1].role, "user");
assert.equal(payload.messages[2].role, "system");
assert.deepEqual(payload.messages[2].output_config, { effort: "high" });
});
test("relocateDirectiveOnlyMessages drops an empty system message without output_config at messages[0]", () => {
const payload = {
messages: [
{ role: "system", content: [] },
{ role: "user", content: "hello" },
],
};
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages.length, 1);
assert.equal(payload.messages[0].role, "user");
});
test("relocateDirectiveOnlyMessages folds output_config to top level when no real turn exists", () => {
const payload = {
messages: [{ role: "system", content: [], output_config: { effort: "xhigh" } }],
};
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages.length, 0);
assert.deepEqual(payload.output_config, { effort: "xhigh" });
});
test("relocateDirectiveOnlyMessages keeps an existing top-level output_config untouched", () => {
const payload = {
output_config: { effort: "low" },
messages: [{ role: "system", content: [], output_config: { effort: "xhigh" } }],
};
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages.length, 0);
assert.deepEqual(payload.output_config, { effort: "low" });
});
test("relocateDirectiveOnlyMessages is a no-op for a normal user first message", () => {
const payload = {
messages: [
{ role: "user", content: "hello" },
{ role: "system", content: [], output_config: { effort: "high" } },
],
};
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages.length, 2);
assert.equal(payload.messages[0].role, "user");
assert.equal(payload.messages[1].role, "system");
assert.equal(payload.messages[1].content.length, 0);
assert.deepEqual(payload.messages[1].output_config, { effort: "high" });
});
test("relocateDirectiveOnlyMessages relocates a directive after an empty system message", () => {
const payload = {
messages: [
{ role: "system", content: [] },
{ role: "system", content: [], output_config: { effort: "high" } },
{ role: "user", content: "hello" },
],
};
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages.length, 2);
assert.equal(payload.messages[0].role, "user");
assert.equal(payload.messages[1].role, "system");
assert.deepEqual(payload.messages[1].output_config, { effort: "high" });
});
test("relocateDirectiveOnlyMessages relocates consecutive directives in order", () => {
const payload = {
messages: [
{ role: "system", content: [], output_config: { effort: "high" } },
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "user", content: "hello" },
],
};
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages.length, 3);
assert.equal(payload.messages[0].role, "user");
assert.equal(payload.messages[1].role, "system");
assert.deepEqual(payload.messages[1].output_config, { effort: "high" });
assert.equal(payload.messages[2].role, "system");
assert.deepEqual(payload.messages[2].output_config, { effort: "low" });
});
test("relocateDirectiveOnlyMessages walks past a text system message to find the anchor", () => {
const payload = {
messages: [
{ role: "system", content: [], output_config: { effort: "high" } },
{ role: "system", content: "real system prompt" },
{ role: "user", content: "hello" },
],
};
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages.length, 3);
assert.equal(payload.messages[0].content, "real system prompt");
assert.equal(payload.messages[1].role, "user");
assert.equal(payload.messages[2].role, "system");
assert.deepEqual(payload.messages[2].output_config, { effort: "high" });
});
test("relocateDirectiveOnlyMessages is a no-op for a system message with text content", () => {
const payload = {
messages: [
{ role: "system", content: "real system prompt" },
{ role: "user", content: "hello" },
],
};
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages.length, 2);
assert.equal(payload.messages[0].content, "real system prompt");
});
test("relocateDirectiveOnlyMessages handles a non-array messages field", () => {
const payload = { messages: "not-an-array" };
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages, "not-an-array");
});
test("relocateDirectiveOnlyMessages handles an empty messages array", () => {
const payload = { messages: [] };
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages.length, 0);
});
test("relocateDirectiveOnlyMessages handles developer-role directives too", () => {
const payload = {
messages: [
{ role: "developer", content: [], output_config: { format: { type: "json_schema" } } },
{ role: "user", content: "hello" },
],
};
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages.length, 2);
assert.equal(payload.messages[0].role, "user");
assert.equal(payload.messages[1].role, "developer");
assert.deepEqual(payload.messages[1].output_config, {
format: { type: "json_schema" },
});
});
test("extractSystemRoleMessages preserves the output_config of directive-only messages", () => {
const payload = {
messages: [
{ role: "system", content: "Memory context: foo" },
{ role: "system", content: [], output_config: { effort: "high" } },
{ role: "user", content: "hello" },
],
};
extractSystemRoleMessages(payload);
assert.equal(payload.messages.length, 1);
assert.equal(payload.messages[0].role, "user");
assert.deepEqual(payload.system, [{ type: "text", text: "Memory context: foo" }]);
assert.deepEqual(payload.output_config, { effort: "high" });
});
test("extractSystemRoleMessages keeps an existing top-level output_config", () => {
const payload = {
output_config: { effort: "low" },
messages: [
{ role: "system", content: [], output_config: { effort: "high" } },
{ role: "user", content: "hello" },
],
};
extractSystemRoleMessages(payload);
assert.equal(payload.messages.length, 1);
assert.deepEqual(payload.output_config, { effort: "low" });
});
test("extractSystemRoleMessages folds output_config even when the message also has text", () => {
const payload = {
messages: [
{
role: "system",
content: [{ type: "text", text: "Text + directive" }],
output_config: { effort: "high" },
},
{ role: "user", content: "hello" },
],
};
extractSystemRoleMessages(payload);
assert.equal(payload.messages.length, 1);
assert.deepEqual(payload.system, [{ type: "text", text: "Text + directive" }]);
assert.deepEqual(payload.output_config, { effort: "high" });
});
test("extractSystemRoleMessages keeps the first directive output_config among several", () => {
const payload = {
messages: [
{ role: "system", content: [], output_config: { effort: "high" } },
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "user", content: "hello" },
],
};
extractSystemRoleMessages(payload);
assert.equal(payload.messages.length, 1);
assert.deepEqual(payload.output_config, { effort: "high" });
});
test("extractSystemRoleMessages folds output_config for string-content messages too", () => {
const payload = {
messages: [
{ role: "system", content: "String content", output_config: { effort: "high" } },
{ role: "user", content: "hello" },
],
};
extractSystemRoleMessages(payload);
assert.equal(payload.messages.length, 1);
assert.deepEqual(payload.system, [{ type: "text", text: "String content" }]);
assert.deepEqual(payload.output_config, { effort: "high" });
});
test("relocateDirectiveOnlyMessages does not throw on a null first message", () => {
const payload = {
messages: [null, { role: "user", content: "hello" }],
};
assert.doesNotThrow(() => relocateDirectiveOnlyMessages(payload));
assert.equal(payload.messages.length, 2);
});
test("relocateDirectiveOnlyMessages does not throw on a null anchor candidate", () => {
const payload = {
messages: [
{ role: "system", content: [], output_config: { effort: "high" } },
null,
{ role: "user", content: "hello" },
],
};
assert.doesNotThrow(() => relocateDirectiveOnlyMessages(payload));
// The null entry stays where it was; the directive lands after the real turn.
assert.equal(payload.messages.length, 3);
assert.equal(payload.messages[0], null);
assert.equal(payload.messages[1].role, "user");
assert.equal(payload.messages[2].role, "system");
assert.deepEqual(payload.messages[2].output_config, { effort: "high" });
});
test("relocateDirectiveOnlyMessages drops plain empties but keeps text system messages with no real turn", () => {
const payload = {
messages: [
{ role: "system", content: [] },
{ role: "system", content: "keep me" },
],
};
relocateDirectiveOnlyMessages(payload);
assert.equal(payload.messages.length, 1);
assert.equal(payload.messages[0].content, "keep me");
assert.equal(payload.output_config, undefined);
});

View File

@@ -0,0 +1,173 @@
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";
// Regression coverage for the "auto combos must only pick user-visible models"
// fix (2026-08-15): a provider whose connection only has synced/free models
// (e.g. OpenRouter with importFreeModelsOnly) must NOT surface catalog-only
// models like `openrouter/auto` in any auto candidate pool. The pool must be
// built from what the user actually has visible (synced + custom non-hidden),
// falling back to the static catalog only when the user has no synced/custom
// models for that provider at all.
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-auto-visible-"));
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const virtualFactory = await import("../../open-sse/services/autoCombo/virtualFactory.ts");
const combo = await import("../../open-sse/services/combo.ts");
function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(() => resetStorage());
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;
});
async function createOpenRouterConnectionWithFreeSync() {
const conn = await providersDb.createProviderConnection({
provider: "openrouter",
authType: "apikey",
name: "OpenRouter",
apiKey: "sk-test-openrouter",
providerSpecificData: { importFreeModelsOnly: true },
});
const connectionId = (conn as { id?: string }).id;
assert.ok(connectionId, "created openrouter connection must expose an id");
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connectionId, [
{
id: "liquid/lfm-2.5-2.6b:free",
name: "LiquidAI: LFM2.5-2.6B (free)",
source: "imported" as const,
},
{
id: "nvidia/nemotron-3.5-lightning:free",
name: "NVIDIA: Nemotron 3.5 Lightning (free)",
source: "imported" as const,
},
]);
return connectionId;
}
test("virtual auto-combo pool excludes catalog-only models (openrouter/auto) when only free models are synced", async () => {
await createOpenRouterConnectionWithFreeSync();
const prepared = await virtualFactory.prepareVirtualAutoComboInputs();
const pool = prepared.regularCandidates;
assert.ok(pool.length > 0, "expected a non-empty pool for the active openrouter connection");
assert.ok(
!pool.some((c) => c.provider === "openrouter" && c.model === "auto"),
"openrouter/auto must NOT be a candidate: the user never synced it (catalog-only model)"
);
assert.ok(
pool.some((c) => c.provider === "openrouter" && c.model === "liquid/lfm-2.5-2.6b:free"),
"a synced free model must remain a candidate"
);
assert.ok(
pool.some(
(c) => c.provider === "openrouter" && c.model === "nvidia/nemotron-3.5-lightning:free"
),
"the second synced free model must remain a candidate"
);
});
test("expandAutoComboCandidatePool excludes catalog-only models (openrouter/auto) when only free models are synced", async () => {
await createOpenRouterConnectionWithFreeSync();
const expanded = await combo.expandAutoComboCandidatePool([], { config: {} });
assert.ok(expanded.length > 0, "expected expansion from the active openrouter connection");
assert.ok(
!expanded.some((t) => t.provider === "openrouter" && t.modelStr === "openrouter/auto"),
"expanded pool must NOT include openrouter/auto: the user never synced it"
);
assert.ok(
expanded.some((t) => t.provider === "openrouter" && t.modelStr === "openrouter/liquid/lfm-2.5-2.6b:free"),
"a synced free model must be expanded into the pool"
);
});
test("virtual auto-combo pool falls back to the static catalog when the provider has no synced/custom models", async () => {
await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "OpenAI",
apiKey: "sk-test-openai",
defaultModel: "gpt-4o-mini",
});
const prepared = await virtualFactory.prepareVirtualAutoComboInputs();
const pool = prepared.regularCandidates;
const openaiCandidates = pool.filter((c) => c.provider === "openai");
assert.ok(
openaiCandidates.length > 0,
"openai with no synced models must still get catalog candidates (fallback)"
);
assert.ok(
openaiCandidates.some((c) => c.model === "gpt-4o-mini"),
"the configured default must remain among catalog-fallback candidates"
);
});
test("virtual auto-combo pool filters EVERY provider with partial sync, not just openrouter", async () => {
// openai: sync only gpt-4o-mini (gpt-4o and gpt-4o-turbo exist in the static
// catalog but are NOT synced → must be absent). kilocode: 359 synced models,
// all with the kilocode provider prefix in the static registry → must be the
// only kilocode candidates.
const openaiConn = await providersDb.createProviderConnection({
provider: "openai",
authType: "apikey",
name: "OpenAI",
apiKey: "sk-test-openai",
});
const kilocodeConn = await providersDb.createProviderConnection({
provider: "kilocode",
authType: "apikey",
name: "KiloCode",
apiKey: "sk-test-kilocode",
});
const openaiId = (openaiConn as { id?: string }).id;
const kilocodeId = (kilocodeConn as { id?: string }).id;
await modelsDb.replaceSyncedAvailableModelsForConnection("openai", openaiId, [
{ id: "gpt-4o-mini", name: "GPT-4o mini", source: "imported" as const },
]);
await modelsDb.replaceSyncedAvailableModelsForConnection("kilocode", kilocodeId, [
{ id: "kilocode/gpt-oss-120b", name: "GPT-OSS 120B", source: "imported" as const },
{ id: "kilocode/qwen3-coder", name: "Qwen3 Coder", source: "imported" as const },
]);
const prepared = await virtualFactory.prepareVirtualAutoComboInputs();
const pool = prepared.regularCandidates;
const openaiCandidates = pool.filter((c) => c.provider === "openai");
assert.ok(
openaiCandidates.some((c) => c.model === "gpt-4o-mini"),
"synced openai model must be a candidate"
);
assert.ok(
!openaiCandidates.some((c) => c.model !== "gpt-4o-mini"),
`only the synced openai model may be a candidate, got: ${openaiCandidates.map((c) => c.model).join(", ")}`
);
const kilocodeCandidates = pool.filter((c) => c.provider === "kilocode");
assert.deepEqual(
kilocodeCandidates.map((c) => c.model).sort(),
["kilocode/gpt-oss-120b", "kilocode/qwen3-coder"],
"kilocode pool must contain exactly the two synced models"
);
});

View File

@@ -5,6 +5,7 @@ import {
buildSmokeEnv,
FATAL_LOG_PATTERNS,
LINUX_EXECUTABLE_NAMES,
stopApp,
} from "../../scripts/dev/smoke-electron-packaged.mjs";
test("electron smoke discovers the default Linux executable name", () => {
@@ -47,3 +48,26 @@ test("electron smoke treats Electron process errors as fatal startup logs", () =
);
}
});
test("electron smoke force-terminates the Windows process tree before the parent can exit", async () => {
const signals: string[] = [];
const waits: number[] = [];
const child = {
pid: 4242,
exitCode: 0,
signalCode: null,
};
await stopApp(child, {
currentPlatform: "win32",
signalProcessTreeFn: async (_child, signal) => {
signals.push(signal);
},
waitForProcessTreeExitFn: async (_child, timeoutMs) => {
waits.push(timeoutMs);
},
});
assert.deepEqual(signals, ["SIGKILL"]);
assert.deepEqual(waits, [2_000]);
});