Merge pull request #2474 from Tentoxa/fix/memory-system-role-semantic-passthrough

fix: extract system role messages in semantic passthrough path to prevent 400 on memory injection
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-21 22:48:45 -03:00
committed by GitHub
21 changed files with 300 additions and 95 deletions

View File

@@ -600,7 +600,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup.
# Update these when providers release new CLI versions to avoid blocks.
CLAUDE_USER_AGENT="claude-cli/2.1.145 (external, cli)"
CLAUDE_USER_AGENT="claude-cli/2.1.146 (external, cli)"
CODEX_USER_AGENT="codex-cli/0.132.0 (Windows 10.0.26200; x64)"
GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1"
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0"

View File

@@ -12,6 +12,9 @@ const ANTHROPIC_BETA_BASE = Object.freeze([
"fast-mode-2026-02-01",
"redact-thinking-2026-02-12",
"token-efficient-tools-2026-03-28",
"advisor-tool-2026-03-01",
"extended-cache-ttl-2025-04-11",
"cache-diagnosis-2026-04-07",
]);
const CLAUDE_OAUTH_EXTRA_BETAS = Object.freeze(["fine-grained-tool-streaming-2025-05-14"]);
@@ -26,7 +29,7 @@ export const ANTHROPIC_BETA_CLAUDE_OAUTH = [
...ANTHROPIC_BETA_BASE.slice(3),
].join(",");
export const CLAUDE_CLI_VERSION = "2.1.137";
export const CLAUDE_CLI_VERSION = "2.1.146";
export const CLAUDE_CLI_USER_AGENT = `claude-cli/${CLAUDE_CLI_VERSION} (external, cli)`;
export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.81.0";
export const CLAUDE_CLI_STAINLESS_PACKAGE_VERSION = "0.94.0";
export const CLAUDE_CLI_STAINLESS_RUNTIME_VERSION = "v24.3.0";

View File

@@ -12,9 +12,9 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
// ---------- Versions ------------------------------------------------------
export const CLAUDE_CODE_VERSION = "2.1.131";
export const CLAUDE_CODE_VERSION = "2.1.146";
/** Bundled @anthropic-ai/sdk version for the pinned CLI release. */
export const CLAUDE_CODE_STAINLESS_VERSION = "0.81.0";
export const CLAUDE_CODE_STAINLESS_VERSION = "0.94.0";
// ---------- Stainless OS / Arch / Runtime --------------------------------
@@ -340,7 +340,7 @@ export function selectBetaFlags(
if (hasStructuredOutput && !isFullAgent) flags.push("structured-outputs-2025-12-15");
// extended-cache-ttl is sent for all full-agent shapes (incl. Haiku); the
// heavier advanced-tool-use / effort flags are Opus/Sonnet-only.
if (isFullAgent) flags.push("extended-cache-ttl-2025-04-11");
if (isFullAgent) flags.push("extended-cache-ttl-2025-04-11", "cache-diagnosis-2026-04-07");
if (isHeavyAgent) {
flags.push("advanced-tool-use-2025-11-20", "effort-2025-11-24");
}

View File

@@ -1273,6 +1273,41 @@ function isCopilotClient(
return false;
}
export function extractSystemRoleMessages(payload: Record<string, unknown>): void {
if (!Array.isArray(payload.messages)) return;
const messages = payload.messages as Array<{ role?: unknown; content?: unknown }>;
const systemMessages = messages.filter(
(m) => typeof m.role === "string" && m.role.toLowerCase() === "system"
);
if (systemMessages.length === 0) return;
const extraBlocks: Array<Record<string, unknown>> = [];
for (const sm of systemMessages) {
if (typeof sm.content === "string" && sm.content.length > 0) {
extraBlocks.push({ type: "text", text: sm.content });
} else if (Array.isArray(sm.content)) {
for (const block of sm.content as Array<Record<string, unknown>>) {
if (block?.type === "text" && typeof block.text === "string" && block.text.length > 0) {
extraBlocks.push({ ...block });
}
}
}
}
if (extraBlocks.length > 0) {
const existingSystem = payload.system;
if (typeof existingSystem === "string" && existingSystem.length > 0) {
payload.system = [{ type: "text", text: existingSystem }, ...extraBlocks];
} else if (Array.isArray(existingSystem)) {
payload.system = [...(existingSystem as Array<Record<string, unknown>>), ...extraBlocks];
} else {
payload.system = extraBlocks;
}
}
payload.messages = messages.filter(
(m) => typeof m.role !== "string" || m.role.toLowerCase() !== "system"
);
}
export async function handleChatCore({
body,
modelInfo,

View File

@@ -114,7 +114,7 @@ export const CCH_SALT = "59cf53e54c78";
/** Character positions sampled from the first user message text. */
export const CCH_POSITIONS = [4, 7, 20] as const;
/** Default `cc_version=` value embedded in the billing header. */
export const DEFAULT_CLAUDE_CODE_VERSION = "2.1.137";
export const DEFAULT_CLAUDE_CODE_VERSION = "2.1.146";
/** Identity sentinel prepended for Claude Agent SDK callers. */
export const CLAUDE_AGENT_SDK_IDENTITY =
"You are a Claude agent, built on Anthropic's Claude Agent SDK.";

View File

@@ -39,9 +39,9 @@ export const CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA = [
"interleaved-thinking-2025-05-14",
"effort-2025-11-24",
].join(",");
export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.137";
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.137 (external, sdk-cli)";
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.81.0";
export const CLAUDE_CODE_COMPATIBLE_VERSION = "2.1.146";
export const CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.146 (external, sdk-cli)";
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0";
export const CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0";
export const CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07";
const CLAUDE_CODE_COMPATIBLE_DEFAULT_SYSTEM_BLOCKS = [

View File

@@ -34,7 +34,6 @@ for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) {
// opencode/ → opencode-zen (the main free/open tier; opencode-go is a separate paid tier)
ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen";
// Manual aliases for external compatibility not covered by PROVIDER_ID_TO_ALIAS.
// OpenCode's Zen provider now uses the "opencode" slug, but OmniRoute registers
// it as "opencode-zen". This alias ensures `opencode/<model>` resolves correctly.

View File

@@ -11,7 +11,11 @@ interface Role {
const HERMES_ROLES: Role[] = [
{ id: "default", label: "Default (main)", description: "Primary conversation model" },
{ id: "delegation", label: "Delegation (subagents)", description: "Orchestrator and sub-agent model" },
{
id: "delegation",
label: "Delegation (subagents)",
description: "Orchestrator and sub-agent model",
},
{ id: "vision", label: "Vision", description: "Image and screenshot understanding" },
{ id: "compression", label: "Compression", description: "Prompt compression & summarization" },
{ id: "web_extract", label: "Web Extract", description: "Web page content extraction" },
@@ -60,9 +64,11 @@ export default function HermesAgentToolCard({
useEffect(() => {
if (isExpanded) {
// Phase 3: Seed from detector snapshot (batchStatus) for instant UI
if (!seededFromBatchRef.current &&
Object.keys(currentRoles).length === 0 &&
batchStatus?.hermesAgentRoles) {
if (
!seededFromBatchRef.current &&
Object.keys(currentRoles).length === 0 &&
batchStatus?.hermesAgentRoles
) {
const seeded: Record<string, any> = {};
Object.entries(batchStatus.hermesAgentRoles).forEach(([role, info]: [string, any]) => {
seeded[role] = {
@@ -252,10 +258,7 @@ export default function HermesAgentToolCard({
return (
<Card padding="sm" className="overflow-hidden">
{/* Collapsed header — exact match to OpenClaw / Kilo / other Auto-Configured entries */}
<div
className="flex items-center justify-between hover:cursor-pointer"
onClick={onToggle}
>
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
<div className="flex items-center gap-3">
<div className="size-8 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-[22px] text-text-muted">terminal</span>
@@ -274,8 +277,8 @@ export default function HermesAgentToolCard({
</span>
)}
</h3>
{(Object.keys(currentRoles).length > 0 ||
Object.keys(selections).length > 0 ||
{(Object.keys(currentRoles).length > 0 ||
Object.keys(selections).length > 0 ||
Object.keys(batchStatus?.hermesAgentRoles || {}).length > 0) && (
<span className="text-[10px] px-1.5 py-px rounded bg-emerald-500/10 text-emerald-600">
{configuredRolesCount}/{HERMES_ROLES.length} roles
@@ -368,7 +371,9 @@ export default function HermesAgentToolCard({
{/* Left: role label + subtitle (now has room so long descriptions stay on one line) */}
<div className="min-w-0 pr-3">
<div className="font-medium text-sm text-text-main">{role.label}</div>
<div className="text-[10px] leading-tight text-text-muted">{role.description}</div>
<div className="text-[10px] leading-tight text-text-muted">
{role.description}
</div>
</div>
{/* Right cluster: model name + status badge + actions (pushed to the right) */}
@@ -392,7 +397,8 @@ export default function HermesAgentToolCard({
: "bg-emerald-500/10 text-emerald-600"
}`}
>
{badge.label}{badge.pending ? " *" : ""}
{badge.label}
{badge.pending ? " *" : ""}
</div>
)}
@@ -453,7 +459,11 @@ export default function HermesAgentToolCard({
variant="ghost"
size="sm"
onClick={handleTogglePreview}
disabled={isSaving || isLoading || (Object.keys(selections).length === 0 && Object.keys(currentRoles).length === 0)}
disabled={
isSaving ||
isLoading ||
(Object.keys(selections).length === 0 && Object.keys(currentRoles).length === 0)
}
loading={isPreviewLoading}
>
<span className="material-symbols-outlined text-[14px] mr-1">visibility</span>
@@ -462,7 +472,8 @@ export default function HermesAgentToolCard({
{Object.keys(selections).length > 0 && (
<span className="text-xs text-text-muted ml-1">
{Object.keys(selections).length} role{Object.keys(selections).length === 1 ? "" : "s"} will be updated
{Object.keys(selections).length} role
{Object.keys(selections).length === 1 ? "" : "s"} will be updated
</span>
)}
@@ -510,4 +521,3 @@ export default function HermesAgentToolCard({
</Card>
);
}

View File

@@ -1740,12 +1740,24 @@ function ComboCard({
className="text-xs py-1 px-2 rounded border border-black/10 dark:border-white/10 bg-surface text-text-main focus:border-primary focus:outline-none transition-colors disabled:opacity-50 max-w-[130px] md:max-w-none"
title={t("compressionOverride")}
>
<option value="" className="bg-surface text-text-main">Default</option>
<option value="off" className="bg-surface text-text-main">Off</option>
<option value="lite" className="bg-surface text-text-main">Lite</option>
<option value="standard" className="bg-surface text-text-main">Standard</option>
<option value="aggressive" className="bg-surface text-text-main">Aggressive</option>
<option value="ultra" className="bg-surface text-text-main">Ultra</option>
<option value="" className="bg-surface text-text-main">
Default
</option>
<option value="off" className="bg-surface text-text-main">
Off
</option>
<option value="lite" className="bg-surface text-text-main">
Lite
</option>
<option value="standard" className="bg-surface text-text-main">
Standard
</option>
<option value="aggressive" className="bg-surface text-text-main">
Aggressive
</option>
<option value="ultra" className="bg-surface text-text-main">
Ultra
</option>
</select>
)}
<button

View File

@@ -112,7 +112,9 @@ export default function ProviderQuotaWidget() {
{/* Header with title + Refresh All in upper right */}
<div className="flex items-center justify-between border-b border-border px-4 py-3 bg-surface/60">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary text-[20px]">account_balance</span>
<span className="material-symbols-outlined text-primary text-[20px]">
account_balance
</span>
<div>
<h3 className="font-semibold text-base">{t("providerQuota") || "Provider Quota"}</h3>
<p className="text-[11px] text-text-muted -mt-0.5">
@@ -127,7 +129,9 @@ export default function ProviderQuotaWidget() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border bg-bg-subtle text-xs font-medium text-text-main disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface transition-colors"
title={t("refreshAll") || "Refresh All"}
>
<span className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}>
<span
className={`material-symbols-outlined text-[16px] ${refreshingAll ? "animate-spin" : ""}`}
>
refresh
</span>
<span>{t("refreshAll") || "Refresh All"}</span>
@@ -144,7 +148,9 @@ export default function ProviderQuotaWidget() {
) : providerEntries.length === 0 ? (
<div className="text-center py-6 text-sm text-text-muted">
No quota-supported providers connected yet.
<div className="mt-1 text-xs">Add accounts on the Providers page to see quota status here.</div>
<div className="mt-1 text-xs">
Add accounts on the Providers page to see quota status here.
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">

View File

@@ -257,7 +257,8 @@ async function saveQwenConfig({ baseUrl, apiKey, model }) {
* keys, but make sure the OmniRoute provider entry is present and selected.
*/
async function saveHermesConfig({ baseUrl, apiKey, model }) {
const configPath = getCliPrimaryConfigPath("hermes") || path.join(os.homedir(), ".hermes", "config.yaml");
const configPath =
getCliPrimaryConfigPath("hermes") || path.join(os.homedir(), ".hermes", "config.yaml");
const configDir = path.dirname(configPath);
await fs.mkdir(configDir, { recursive: true });
@@ -265,7 +266,9 @@ async function saveHermesConfig({ baseUrl, apiKey, model }) {
const normalizedBaseUrl = String(baseUrl || "")
.trim()
.replace(/\/+$/, "");
const providerBaseUrl = normalizedBaseUrl.endsWith("/v1") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1`;
const providerBaseUrl = normalizedBaseUrl.endsWith("/v1")
? normalizedBaseUrl
: `${normalizedBaseUrl}/v1`;
if (!model) {
return NextResponse.json({ error: "model is required for Hermes" }, { status: 400 });
@@ -296,7 +299,12 @@ async function saveHermesConfig({ baseUrl, apiKey, model }) {
omniroute: {
...((existingConfig.providers && existingConfig.providers.omniroute) || {}),
base_url: providerBaseUrl,
api_key: apiKey || ((existingConfig.providers && existingConfig.providers.omniroute && existingConfig.providers.omniroute.api_key) || ""),
api_key:
apiKey ||
(existingConfig.providers &&
existingConfig.providers.omniroute &&
existingConfig.providers.omniroute.api_key) ||
"",
},
},
};
@@ -309,5 +317,3 @@ async function saveHermesConfig({ baseUrl, apiKey, model }) {
configPath,
});
}

View File

@@ -4,7 +4,10 @@ import path from "path";
import os from "os";
import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth";
import { getCliPrimaryConfigPath } from "@/shared/services/cliRuntime";
import { generateHermesAgentConfig, getCurrentHermesAgentRoles } from "@/lib/cli-helper/config-generator/hermes-agent";
import {
generateHermesAgentConfig,
getCurrentHermesAgentRoles,
} from "@/lib/cli-helper/config-generator/hermes-agent";
/**
* Dedicated endpoint for Hermes Agent (the advanced Nous Research terminal agent).

View File

@@ -151,7 +151,16 @@ export async function GET(request: Request) {
);
// Check config status for installed+runnable tools via direct file reads
const settingsTools = ["claude", "codex", "droid", "openclaw", "cline", "kilo", "qwen", "hermes"];
const settingsTools = [
"claude",
"codex",
"droid",
"openclaw",
"cline",
"kilo",
"qwen",
"hermes",
];
await Promise.all(
settingsTools.map(async (toolId) => {

View File

@@ -31,16 +31,20 @@ import os from "node:os";
import * as yaml from "js-yaml";
export const HERMES_AGENT_ROLES = [
{ id: "default", label: "Default (main)", description: "Primary conversation model" },
{ id: "delegation", label: "Delegation (subagents)", description: "Orchestrator and sub-agent spawning model" },
{ id: "vision", label: "Vision", description: "Image and screenshot understanding" },
{ id: "compression", label: "Compression", description: "Prompt compression and summarization" },
{ id: "web_extract", label: "Web Extract", description: "Web page / content extraction" },
{ id: "skills_hub", label: "Skills Hub", description: "Skills and tool-use reasoning" },
{ id: "approval", label: "Approval", description: "Safety and approval decisions" },
{ id: "default", label: "Default (main)", description: "Primary conversation model" },
{
id: "delegation",
label: "Delegation (subagents)",
description: "Orchestrator and sub-agent spawning model",
},
{ id: "vision", label: "Vision", description: "Image and screenshot understanding" },
{ id: "compression", label: "Compression", description: "Prompt compression and summarization" },
{ id: "web_extract", label: "Web Extract", description: "Web page / content extraction" },
{ id: "skills_hub", label: "Skills Hub", description: "Skills and tool-use reasoning" },
{ id: "approval", label: "Approval", description: "Safety and approval decisions" },
] as const;
export type HermesAgentRole = typeof HERMES_AGENT_ROLES[number]["id"];
export type HermesAgentRole = (typeof HERMES_AGENT_ROLES)[number]["id"];
export interface HermesAgentRoleSelection {
role: HermesAgentRole;

View File

@@ -48,8 +48,6 @@ const TOOL_CONFIG_PATHS: Record<string, string> = {
"hermes-agent": path.join(os.homedir(), ".hermes", "config.yaml"),
};
type ConfigGenerator = (options: GenerateOptions) => string | Promise<string>;
const GENERATORS: Record<string, ConfigGenerator> = {
@@ -94,7 +92,15 @@ export async function generateConfig(
}
export async function generateAllConfigs(options: GenerateOptions): Promise<GenerateResult[]> {
const toolIds = ["claude", "codex", "opencode", "cline", "kilocode", "continue", "hermes"] as const;
const toolIds = [
"claude",
"codex",
"opencode",
"cline",
"kilocode",
"continue",
"hermes",
] as const;
const results = await Promise.allSettled(toolIds.map((id) => generateConfig(id, options)));
return results.map((r) =>

View File

@@ -21,11 +21,14 @@ export interface DetectedTool {
configContents?: string;
// Rich per-role status for Hermes Agent
hermesAgentRoles?: Record<string, {
model: string;
provider?: string;
usingOmniRoute: boolean;
}>;
hermesAgentRoles?: Record<
string,
{
model: string;
provider?: string;
usingOmniRoute: boolean;
}
>;
}
const TOOLS = [
@@ -119,7 +122,8 @@ export async function detectTool(id: string): Promise<DetectedTool | null> {
const richRoles: Record<string, any> = {};
Object.entries(roles).forEach(([role, info]) => {
const usingOmni = info?.provider === "omniroute" ||
const usingOmni =
info?.provider === "omniroute" ||
(info?.base_url || "").includes("20128") ||
(info?.base_url || "").includes("localhost:20128");

View File

@@ -85,7 +85,8 @@ describe("config-generator", () => {
describe("hermes-agent (rich multi-role)", () => {
it("exports HERMES_AGENT_ROLES with expected roles", async () => {
const hermesAgent = await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
const hermesAgent =
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
assert.ok(Array.isArray(hermesAgent.HERMES_AGENT_ROLES));
const ids = hermesAgent.HERMES_AGENT_ROLES.map((r: any) => r.id);
assert.ok(ids.includes("default"));
@@ -95,13 +96,15 @@ describe("config-generator", () => {
});
it("getCurrentHermesAgentRoles returns an object", async () => {
const hermesAgent = await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
const hermesAgent =
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
const roles = await hermesAgent.getCurrentHermesAgentRoles();
assert.ok(typeof roles === "object" && roles !== null);
});
it("generateHermesAgentConfig returns yaml string for valid payload", async () => {
const hermesAgent = await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
const hermesAgent =
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
const result = await hermesAgent.generateHermesAgentConfig({
baseUrl: "http://localhost:20128",
apiKey: "sk-test-omniroute",
@@ -119,7 +122,8 @@ describe("config-generator", () => {
});
it("generateHermesAgentConfig includes auxiliary section for non-default roles", async () => {
const hermesAgent = await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
const hermesAgent =
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
const result = await hermesAgent.generateHermesAgentConfig({
baseUrl: "http://localhost:20128",
apiKey: "sk-test",
@@ -134,7 +138,8 @@ describe("config-generator", () => {
});
it("generateHermesAgentConfig returns error when baseUrl is missing", async () => {
const hermesAgent = await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
const hermesAgent =
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
const result = await hermesAgent.generateHermesAgentConfig({
baseUrl: "",
selections: [{ role: "default", model: "x" }],
@@ -145,7 +150,8 @@ describe("config-generator", () => {
});
it("generateHermesAgentConfig correctly structures delegation and auxiliary roles", async () => {
const hermesAgent = await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
const hermesAgent =
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
const result = await hermesAgent.generateHermesAgentConfig({
baseUrl: "http://localhost:20128",
apiKey: "sk-test",
@@ -166,7 +172,8 @@ describe("config-generator", () => {
it("generateHermesAgentConfig performs non-destructive merge (preserves other keys)", async () => {
// This test mainly verifies the function doesn't blow away unrelated config
const hermesAgent = await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
const hermesAgent =
await import("../../../src/lib/cli-helper/config-generator/hermes-agent.ts");
const result = await hermesAgent.generateHermesAgentConfig({
baseUrl: "http://localhost:20128",
apiKey: "sk-test",

View File

@@ -47,7 +47,6 @@ describe("tool-detector", () => {
assert.ok(result!.configPath.includes(".hermes/config.yaml"));
assert.strictEqual(typeof result!.configured, "boolean");
});
});
describe("detectAllTools", () => {

View File

@@ -95,7 +95,9 @@ test("guide-settings POST creates new hermes config.yaml if it doesn't exist", a
apiKey: "sk-hermes",
model: "gpt-5.4-mini",
});
const response = (await guideSettingsRoute.POST(req, { params: { toolId: "hermes" } })) as Response;
const response = (await guideSettingsRoute.POST(req, {
params: { toolId: "hermes" },
})) as Response;
const data = (await response.json()) as any;
assert.equal(response.status, 200, "Response should be OK");
@@ -109,7 +111,6 @@ test("guide-settings POST creates new hermes config.yaml if it doesn't exist", a
assert.ok(String(content.providers?.omniroute?.api_key || "").startsWith("sk-"));
});
test("guide-settings POST merges into existing qwen settings.json", async () => {
await fs.mkdir(path.dirname(QWEN_CONFIG_PATH), { recursive: true });
await fs.writeFile(

View File

@@ -0,0 +1,119 @@
import test from "node:test";
import assert from "node:assert/strict";
import { extractSystemRoleMessages } from "../../open-sse/handlers/chatCore.ts";
test("extractSystemRoleMessages moves role=system to top-level system", () => {
const payload = {
messages: [
{ role: "system", content: "Memory context: foo" },
{ role: "user", content: "hello" },
{ role: "assistant", content: "hi" },
],
};
extractSystemRoleMessages(payload);
assert.equal(payload.messages.length, 2);
assert.equal(payload.messages[0].role, "user");
assert.deepEqual(payload.system, [{ type: "text", text: "Memory context: foo" }]);
});
test("extractSystemRoleMessages merges with existing top-level system string", () => {
const payload = {
system: "You are Claude.",
messages: [
{ role: "system", content: "Memory context: bar" },
{ role: "user", content: "hello" },
],
};
extractSystemRoleMessages(payload);
assert.equal(payload.messages.length, 1);
assert.deepEqual(payload.system, [
{ type: "text", text: "You are Claude." },
{ type: "text", text: "Memory context: bar" },
]);
});
test("extractSystemRoleMessages merges with existing top-level system array", () => {
const payload = {
system: [{ type: "text", text: "Existing system" }],
messages: [
{ role: "system", content: "Memory context: baz" },
{ role: "user", content: "hello" },
],
};
extractSystemRoleMessages(payload);
assert.equal(payload.messages.length, 1);
assert.deepEqual(payload.system, [
{ type: "text", text: "Existing system" },
{ type: "text", text: "Memory context: baz" },
]);
});
test("extractSystemRoleMessages does nothing when no system role messages", () => {
const payload = {
messages: [
{ role: "user", content: "hello" },
{ role: "assistant", content: "hi" },
],
};
extractSystemRoleMessages(payload);
assert.equal(payload.messages.length, 2);
assert.equal(payload.system, undefined);
});
test("extractSystemRoleMessages handles non-array messages gracefully", () => {
const payload = { messages: "not-an-array" };
extractSystemRoleMessages(payload);
assert.equal(payload.messages, "not-an-array");
});
test("extractSystemRoleMessages handles empty messages array", () => {
const payload = { messages: [] };
extractSystemRoleMessages(payload);
assert.equal(payload.messages.length, 0);
});
test("extractSystemRoleMessages handles case-insensitive role System", () => {
const payload = {
messages: [
{ role: "System", content: "Memory context: caps" },
{ role: "user", content: "hello" },
],
};
extractSystemRoleMessages(payload);
assert.equal(payload.messages.length, 1);
assert.deepEqual(payload.system, [{ type: "text", text: "Memory context: caps" }]);
});
test("extractSystemRoleMessages drops empty text content from system messages", () => {
const payload = {
messages: [
{ role: "system", content: "" },
{ role: "system", content: "valid" },
{ role: "user", content: "hello" },
],
};
extractSystemRoleMessages(payload);
assert.equal(payload.messages.length, 1);
assert.deepEqual(payload.system, [{ type: "text", text: "valid" }]);
});
test("extractSystemRoleMessages handles system messages with array content", () => {
const payload = {
messages: [
{
role: "system",
content: [
{ type: "text", text: "Block 1" },
{ type: "text", text: "Block 2" },
],
},
{ role: "user", content: "hello" },
],
};
extractSystemRoleMessages(payload);
assert.equal(payload.messages.length, 1);
assert.deepEqual(payload.system, [
{ type: "text", text: "Block 1" },
{ type: "text", text: "Block 2" },
]);
});

View File

@@ -84,45 +84,27 @@ test("#2232 — whitespace-only VISION_BRIDGE_BASE_URL falls through to OPENAI_A
test("#2232 — OmniRoute-internal providers default to self-loop when no env vars set", () => {
clearEnv();
// Non-standard prefixes (kr/, if/, pol/, groq/) should use OmniRoute self-loop
assert.equal(
resolveVisionBridgeBaseUrl("kr/claude-sonnet-4-5"),
"http://localhost:20128/v1"
);
assert.equal(
resolveVisionBridgeBaseUrl("if/kimi-k2-thinking"),
"http://localhost:20128/v1"
);
assert.equal(
resolveVisionBridgeBaseUrl("pol/gpt-5"),
"http://localhost:20128/v1"
);
assert.equal(resolveVisionBridgeBaseUrl("kr/claude-sonnet-4-5"), "http://localhost:20128/v1");
assert.equal(resolveVisionBridgeBaseUrl("if/kimi-k2-thinking"), "http://localhost:20128/v1");
assert.equal(resolveVisionBridgeBaseUrl("pol/gpt-5"), "http://localhost:20128/v1");
});
test("#2232 — OpenAI and Anthropic models still default to api.openai.com", () => {
clearEnv();
// Standard prefixes should keep default behavior
assert.equal(
resolveVisionBridgeBaseUrl("openai/gpt-4o"),
"https://api.openai.com/v1"
);
assert.equal(resolveVisionBridgeBaseUrl("openai/gpt-4o"), "https://api.openai.com/v1");
assert.equal(
resolveVisionBridgeBaseUrl("anthropic/claude-sonnet-4-5"),
"https://api.openai.com/v1" // anthropic goes through a different code path
// but if passed here, should not self-loop
"https://api.openai.com/v1" // anthropic goes through a different code path
// but if passed here, should not self-loop
);
});
test("#2232 — unprefixed model names default to api.openai.com", () => {
clearEnv();
// Models without provider prefix should keep default behavior
assert.equal(
resolveVisionBridgeBaseUrl("gpt-4o-mini"),
"https://api.openai.com/v1"
);
assert.equal(
resolveVisionBridgeBaseUrl("deepseek-v4-flash"),
"https://api.openai.com/v1"
);
assert.equal(resolveVisionBridgeBaseUrl("gpt-4o-mini"), "https://api.openai.com/v1");
assert.equal(resolveVisionBridgeBaseUrl("deepseek-v4-flash"), "https://api.openai.com/v1");
});
test("#2232 — VISION_BRIDGE_BASE_URL env var takes precedence over self-loop auto-detection", () => {