mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 19:52:50 +03:00
Compare commits
4 Commits
fix/10244-
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ff7f7daf0 | ||
|
|
e168b2347e | ||
|
|
4adf50dbcb | ||
|
|
370c1b9ae7 |
@@ -1 +0,0 @@
|
||||
- **fix(cliproxy):** read platform/arch at runtime via `os.platform()`/`os.arch()` in `binaryManager` so the embedded installer selects the Windows/ARM assets even when the release bundle is built on a Linux runner (fixes #10244)
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type Platform = "linux" | "darwin" | "windows" | "freebsd";
|
||||
type Arch = "amd64" | "arm64";
|
||||
|
||||
function detectPlatform(): Platform {
|
||||
const p = os.platform();
|
||||
const p = process.platform;
|
||||
if (p === "linux") return "linux";
|
||||
if (p === "darwin") return "darwin";
|
||||
if (p === "win32") return "windows";
|
||||
@@ -24,7 +24,7 @@ function detectPlatform(): Platform {
|
||||
}
|
||||
|
||||
function detectArch(): Arch {
|
||||
const a = os.arch();
|
||||
const a = process.arch;
|
||||
if (a === "x64") return "amd64";
|
||||
if (a === "arm64") return "arm64";
|
||||
return "amd64";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, afterEach, after, mock } from "node:test";
|
||||
import { describe, it, afterEach, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
@@ -63,22 +63,6 @@ describe("binaryManager", () => {
|
||||
assert.ok(["linux", "darwin", "windows"].includes(platform));
|
||||
assert.ok(["amd64", "arm64"].includes(arch));
|
||||
});
|
||||
|
||||
it("should read platform/arch at runtime from os (anti build-folding guard) (#10244)", () => {
|
||||
// Regression guard for #10244/#10293: detectPlatform/detectArch must read
|
||||
// os.platform()/os.arch() at call time, NOT the build-machine foldable
|
||||
// process.platform/process.arch constants. Turbopack `next build` running
|
||||
// on Linux constant-folds `process.platform` and prunes every Windows/arm64
|
||||
// branch from the published npm artifact. Simulate a Windows arm64 host via
|
||||
// the runtime os.* functions; the Windows/arm64 branch must be reachable.
|
||||
mock.method(os, "platform", () => "win32");
|
||||
mock.method(os, "arch", () => "arm64");
|
||||
assert.deepEqual(mod.getTargetPlatform(), { platform: "windows", arch: "arm64" });
|
||||
assert.equal(
|
||||
mod.getAssetName(),
|
||||
"CLIProxyAPI_{version}_windows_arm64.zip"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentBinaryPath", () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
120
tests/unit/claude-directive-midconv-passthrough.test.ts
Normal file
120
tests/unit/claude-directive-midconv-passthrough.test.ts
Normal 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" });
|
||||
});
|
||||
291
tests/unit/claude-directive-only-relocation.test.ts
Normal file
291
tests/unit/claude-directive-only-relocation.test.ts
Normal 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);
|
||||
});
|
||||
173
tests/unit/combo-auto-pool-visible-only.test.ts
Normal file
173
tests/unit/combo-auto-pool-visible-only.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user