mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 16:12:23 +03:00
Compare commits
8 Commits
fix/11289-
...
fix/11300-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
268d97cd43 | ||
|
|
5518916725 | ||
|
|
3daa455e1f | ||
|
|
d282ec7ad6 | ||
|
|
1e81e521c0 | ||
|
|
a3c3117254 | ||
|
|
d9a883ec53 | ||
|
|
7913447bf0 |
4
.github/workflows/opencode-plugin-ci.yml
vendored
4
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -2,11 +2,11 @@ name: opencode-plugin CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, release/v3.8.2]
|
||||
branches: [main, "release/**"]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
pull_request:
|
||||
branches: [main, release/v3.8.2]
|
||||
branches: [main, "release/**"]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
@@ -104,7 +104,10 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
|
||||
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId
|
||||
// ("omniroute"), not the OC-gate-prefixed hook.id ("opencode-omniroute") —
|
||||
// that prefix must never leak into anything OmniRoute's server parses.
|
||||
assert.ok(out["omniroute/claude-primary"]);
|
||||
// #10345/#10821: bare combo ids (owned_by: "combo") stay unprefixed —
|
||||
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under the
|
||||
// plugin provider, so `claude-primary` here carries no provider prefix.
|
||||
assert.ok(out["claude-primary"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
@@ -159,11 +162,15 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
||||
// omnirouteProviderId ("omniroute") — the OC-gate prefix ("opencode-")
|
||||
// must stay OC-internal (hook.id / AuthHook.provider) and never leak into
|
||||
// anything OmniRoute's own server parses for credential lookup.
|
||||
const claude = out["omniroute/claude-primary"];
|
||||
// #10345/#10821: bare **combo** ids (owned_by: "combo", e.g.
|
||||
// "claude-primary") must also stay unprefixed — OpenCode looks up
|
||||
// `-m <plugin>/<combo>` as model id `<combo>` under the plugin provider.
|
||||
const claude = out["claude-primary"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
|
||||
// static-catalog reader resolves `(providerID, modelID)` from the key.
|
||||
assert.equal(claude.id, "omniroute/claude-primary");
|
||||
// `mapRawModelToModelV2` leaves bare combo ids unprefixed (see
|
||||
// src/index.ts mapRawModelToModelV2) so OC's `-m <plugin>/<combo>` lookup
|
||||
// resolves the combo id directly.
|
||||
assert.equal(claude.id, "claude-primary");
|
||||
assert.equal(claude.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
|
||||
1
changelog.d/fixes/10851-openapi-spec-auth-contract.md
Normal file
1
changelog.d/fixes/10851-openapi-spec-auth-contract.md
Normal file
@@ -0,0 +1 @@
|
||||
- Document the conditional management authentication and 401/403 responses for `GET /api/openapi/spec`.
|
||||
1
changelog.d/fixes/11297-opencode-subagent-sessionid.md
Normal file
1
changelog.d/fixes/11297-opencode-subagent-sessionid.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(translator):** preserve omitted OpenCode `subagent.sessionID` values — optional default-less plain strings now use the Responses `null = omit` sentinel and are stripped before the client sees the tool call, so Codex/Responses no longer invent filler session IDs ([#11297](https://github.com/diegosouzapw/OmniRoute/pull/11297)) — thanks @ofonseca-pyming
|
||||
1
changelog.d/maintenance/11018-database-cache-docs.md
Normal file
1
changelog.d/maintenance/11018-database-cache-docs.md
Normal file
@@ -0,0 +1 @@
|
||||
- **docs(database):** align the SQLite cache guide with the 65,536 KiB runtime default, supported 1–1,000,000 KiB range, and live Settings application behavior ([#11018](https://github.com/diegosouzapw/OmniRoute/issues/11018))
|
||||
@@ -6866,7 +6866,11 @@ paths:
|
||||
Returns a structured JSON catalog parsed from this `openapi.yaml`,
|
||||
including info, servers, tags, schemas, and a flat list of endpoints
|
||||
(method, path, tags, summary, security, parameters, responses).
|
||||
Used by the in-app API explorer.
|
||||
Used by the in-app API explorer. When `requireLogin` is enabled, this
|
||||
management endpoint requires an authenticated dashboard session;
|
||||
otherwise it is available without authentication.
|
||||
security:
|
||||
- ManagementSessionAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: Parsed OpenAPI catalog
|
||||
@@ -6920,6 +6924,10 @@ paths:
|
||||
type: string
|
||||
"404":
|
||||
description: openapi.yaml file not found on disk
|
||||
"401":
|
||||
$ref: "#/components/responses/ManagementAuthenticationRequired"
|
||||
"403":
|
||||
$ref: "#/components/responses/ManagementInvalidToken"
|
||||
"500":
|
||||
description: Failed to parse OpenAPI spec
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "Database Schema & Operations Guide"
|
||||
version: 3.8.40
|
||||
lastUpdated: 2026-06-28
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-23
|
||||
---
|
||||
|
||||
# Database Schema & Operations Guide
|
||||
@@ -43,12 +43,17 @@ For **single-user, single-instance** deployments (the primary OmniRoute use case
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("busy_timeout = 2000");
|
||||
db.pragma("synchronous = NORMAL");
|
||||
// Settings > System & Storage > Cache Size is applied as KiB.
|
||||
db.pragma("cache_size = -16384");
|
||||
db.pragma(`cache_size = -${DEFAULT_DATABASE_SETTINGS.optimization.cacheSize}`);
|
||||
```
|
||||
|
||||
WAL allows **concurrent reads** during writes — important for the dashboard, which queries while requests are being recorded.
|
||||
|
||||
The default cache size is **65,536 KiB (64 MiB)**. SQLite interprets a negative
|
||||
`cache_size` as an approximate upper bound in KiB and allocates pages on demand.
|
||||
**Settings > System & Storage > Cache Size** accepts integer values from **1 to
|
||||
1,000,000 KiB**; saving the setting applies it to the live database connection,
|
||||
and OmniRoute restores the persisted value at startup.
|
||||
|
||||
---
|
||||
|
||||
## Database Location
|
||||
|
||||
@@ -20,6 +20,7 @@ import { checkSemanticCache } from "./chatCore/semanticCache.ts";
|
||||
import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts";
|
||||
import {
|
||||
shouldDefaultAllowClassifier,
|
||||
detectClassifierFormat,
|
||||
buildDefaultAllowClaudeMessage,
|
||||
} from "./chatCore/claudeClassifierCompat.ts";
|
||||
import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts";
|
||||
@@ -379,6 +380,7 @@ import { isCompactResponsesEndpoint } from "../executors/codex.ts";
|
||||
import { persistCodexChildQuotaResponse } from "../services/codexAccount/index.ts";
|
||||
import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts";
|
||||
import { translateNonStreamingResponse } from "./responseTranslator.ts";
|
||||
import { extractToolSchemaMap } from "../translator/response/openai-responses/toolSchemas.ts";
|
||||
import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts";
|
||||
import { extractUsageFromResponse } from "./usageExtractor.ts";
|
||||
import {
|
||||
@@ -778,11 +780,12 @@ export async function handleChatCore({
|
||||
classifierSettings.claudeClassifierCompat as string | undefined
|
||||
)
|
||||
) {
|
||||
const classifierFormat = detectClassifierFormat(body as Record<string, unknown>);
|
||||
log?.warn?.(
|
||||
"CHAT",
|
||||
`classifier compat=${classifierSettings.claudeClassifierCompat} | short-circuit default-allow`
|
||||
`classifier compat=${classifierSettings.claudeClassifierCompat} format=${classifierFormat} | short-circuit default-allow`
|
||||
);
|
||||
return buildDefaultAllowClaudeMessage(requestedModel);
|
||||
return buildDefaultAllowClaudeMessage(requestedModel, classifierFormat);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4910,12 +4913,14 @@ export async function handleChatCore({
|
||||
|
||||
// Translate response to client's expected format (usually OpenAI)
|
||||
// Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605)
|
||||
const responseToolSchemas = extractToolSchemaMap(finalBody || translatedBody || body);
|
||||
let translatedResponse = needsTranslation(responsePayloadFormat, clientResponseFormat)
|
||||
? translateNonStreamingResponse(
|
||||
responseBody,
|
||||
responsePayloadFormat,
|
||||
clientResponseFormat,
|
||||
responseToolNameMap
|
||||
responseToolNameMap,
|
||||
responseToolSchemas
|
||||
)
|
||||
: responseBody;
|
||||
const memoryExtractionResponse = translatedResponse;
|
||||
@@ -4942,7 +4947,8 @@ export async function handleChatCore({
|
||||
responseBody,
|
||||
responsePayloadFormat,
|
||||
FORMATS.OPENAI,
|
||||
responseToolNameMap
|
||||
responseToolNameMap,
|
||||
responseToolSchemas
|
||||
)
|
||||
: responseBody;
|
||||
const firstChoice = cacheResponse?.choices?.[0];
|
||||
@@ -5465,7 +5471,8 @@ export async function handleChatCore({
|
||||
streamBody,
|
||||
clientResponseFormat,
|
||||
FORMATS.OPENAI,
|
||||
responseToolNameMap
|
||||
responseToolNameMap,
|
||||
extractToolSchemaMap(finalBody || translatedBody || body)
|
||||
) as Record<string, unknown>)
|
||||
: streamBody;
|
||||
const choices = cacheStreamBody.choices as
|
||||
|
||||
@@ -24,14 +24,19 @@ const SECURITY_MONITOR_MARKER = "You are a security monitor for autonomous AI co
|
||||
|
||||
export type ClaudeClassifierCompatMode = "off" | "auto" | "always";
|
||||
|
||||
/** The two synthetic-response shapes Claude Code's classifier can expect. */
|
||||
export type ClaudeClassifierFormat = "block" | "severity";
|
||||
|
||||
function extractSystemTexts(body: Record<string, unknown> | null | undefined): string[] {
|
||||
const system = body?.system;
|
||||
if (typeof system === "string") return [system];
|
||||
if (Array.isArray(system)) {
|
||||
return system
|
||||
.map((part) => (part && typeof (part as { text?: unknown }).text === "string"
|
||||
? ((part as { text: string }).text)
|
||||
: ""))
|
||||
.map((part) =>
|
||||
part && typeof (part as { text?: unknown }).text === "string"
|
||||
? (part as { text: string }).text
|
||||
: ""
|
||||
)
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
@@ -60,6 +65,29 @@ export function shouldDefaultAllowClassifier(
|
||||
return extractSystemTexts(body).some((text) => text.includes(SECURITY_MONITOR_MARKER));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which synthetic-response shape the classifier request expects.
|
||||
*
|
||||
* Newer Claude Code builds send a "severity classifier" variant of the same internal
|
||||
* request: it carries `stop_sequences: [..., "</severity>", ...]` and parses a
|
||||
* `<severity>N</severity>` reply instead of `<block>no</block>`/`<block>yes</block>`.
|
||||
* Feeding it the legacy `<block>no</block>` shape is unparseable, so it retries both
|
||||
* stages and then fails closed — the same "blocking it for safety" failure this compat
|
||||
* shim exists to avoid. Only `stop_sequences` distinguishes the two shapes; callers
|
||||
* should only consult this after `shouldDefaultAllowClassifier` has already confirmed
|
||||
* the request is the classifier (via the system-prompt marker), so an unrelated app
|
||||
* that merely happens to use `</severity>` as a stop token is never affected (#8189).
|
||||
*/
|
||||
export function detectClassifierFormat(
|
||||
body: Record<string, unknown> | null | undefined
|
||||
): ClaudeClassifierFormat {
|
||||
const stopSequences = body?.stop_sequences;
|
||||
if (Array.isArray(stopSequences) && stopSequences.includes("</severity>")) {
|
||||
return "severity";
|
||||
}
|
||||
return "block";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the synthetic Claude `message` ALLOW response. Always returns a plain JSON
|
||||
* body (matching the upstream reference implementation) — Claude Code's classifier
|
||||
@@ -67,7 +95,10 @@ export function shouldDefaultAllowClassifier(
|
||||
* satisfies both streaming and non-streaming callers without needing to plumb a
|
||||
* synthetic SSE encoding through the streaming/sseToJson/non-streaming handlers.
|
||||
*/
|
||||
export function buildDefaultAllowClaudeMessage(model?: string | null): {
|
||||
export function buildDefaultAllowClaudeMessage(
|
||||
model?: string | null,
|
||||
format: ClaudeClassifierFormat = "block"
|
||||
): {
|
||||
success: true;
|
||||
response: Response;
|
||||
} {
|
||||
@@ -76,7 +107,12 @@ export function buildDefaultAllowClaudeMessage(model?: string | null): {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: model || "claude-3-5-sonnet-20241022",
|
||||
content: [{ type: "text", text: "<block>no</block>" }],
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: format === "severity" ? "<severity>0</severity>" : "<block>no</block>",
|
||||
},
|
||||
],
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { restoreClaudeToolName } from "../services/claudeCodeToolRemapper.ts";
|
||||
import { extractReplayableResponsesReasoningText } from "../services/reasoningInputPolicy.ts";
|
||||
import { sanitizeToolId } from "../translator/helpers/schemaCoercion.ts";
|
||||
import { stripEmptyOptionalToolArgs } from "../translator/response/openai-responses/pureHelpers.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -135,24 +136,28 @@ function findBestMessageText(output: unknown[]): {
|
||||
* Handles different provider response formats (Gemini, Claude, etc.)
|
||||
*
|
||||
* @param toolNameMap - Optional Map<prefixedName, originalName> for Claude OAuth tool name stripping
|
||||
* @param toolSchemas - Optional Map<toolName, parametersSchema> for schema-aware optional-arg cleanup
|
||||
*/
|
||||
export function translateNonStreamingResponse(
|
||||
responseBody: JsonRecord,
|
||||
targetFormat: string,
|
||||
sourceFormat: string,
|
||||
toolNameMap?: Map<string, string> | null
|
||||
toolNameMap?: Map<string, string> | null,
|
||||
toolSchemas?: Map<string, JsonRecord> | null
|
||||
): JsonRecord;
|
||||
export function translateNonStreamingResponse(
|
||||
responseBody: unknown,
|
||||
targetFormat: string,
|
||||
sourceFormat: string,
|
||||
toolNameMap?: Map<string, string> | null
|
||||
toolNameMap?: Map<string, string> | null,
|
||||
toolSchemas?: Map<string, JsonRecord> | null
|
||||
): unknown;
|
||||
export function translateNonStreamingResponse(
|
||||
responseBody: unknown,
|
||||
targetFormat: string,
|
||||
sourceFormat: string,
|
||||
toolNameMap?: Map<string, string> | null
|
||||
toolNameMap?: Map<string, string> | null,
|
||||
toolSchemas?: Map<string, JsonRecord> | null
|
||||
): unknown {
|
||||
// If already in source format, return as-is
|
||||
if (targetFormat === sourceFormat) {
|
||||
@@ -219,6 +224,11 @@ export function translateNonStreamingResponse(
|
||||
toString(itemObj.id) ||
|
||||
`call_${Date.now()}_${toolCalls.length}`;
|
||||
let argsToEmit = itemObj.arguments;
|
||||
const rawName = toString(itemObj.name);
|
||||
const toolSchema = toolSchemas?.get(rawName);
|
||||
if (toolSchema) {
|
||||
argsToEmit = stripEmptyOptionalToolArgs(argsToEmit, rawName, toolSchema);
|
||||
}
|
||||
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
|
||||
const cleaned: JsonRecord = { ...(argsToEmit as JsonRecord) };
|
||||
for (const [k, v] of Object.entries(cleaned)) {
|
||||
@@ -229,7 +239,6 @@ export function translateNonStreamingResponse(
|
||||
|
||||
const fnArgs =
|
||||
typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit || {});
|
||||
const rawName = toString(itemObj.name);
|
||||
// Strip Claude OAuth proxy_ prefix using toolNameMap
|
||||
const resolvedName = caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName;
|
||||
toolCalls.push({
|
||||
|
||||
@@ -290,6 +290,31 @@ export function coerceToolSchemas(tools: unknown): unknown {
|
||||
});
|
||||
}
|
||||
|
||||
const NULL_OMISSION_NOTE = "null = omit this parameter";
|
||||
|
||||
function schemaTypeIncludes(type: unknown, wanted: string): boolean {
|
||||
return type === wanted || (Array.isArray(type) && type.includes(wanted));
|
||||
}
|
||||
|
||||
function isPlainStringType(type: unknown): boolean {
|
||||
return type === "string" || (Array.isArray(type) && type.length === 1 && type[0] === "string");
|
||||
}
|
||||
|
||||
function appendNullOmissionMarker(description: unknown): string {
|
||||
if (typeof description === "string" && description.length > 0) {
|
||||
return description.includes(NULL_OMISSION_NOTE)
|
||||
? description
|
||||
: `${description} (${NULL_OMISSION_NOTE})`;
|
||||
}
|
||||
return NULL_OMISSION_NOTE;
|
||||
}
|
||||
|
||||
function widenTypeWithNull(type: unknown): unknown {
|
||||
if (typeof type === "string") return [type, "null"];
|
||||
if (Array.isArray(type) && !type.includes("null")) return [...type, "null"];
|
||||
return type;
|
||||
}
|
||||
|
||||
// #7023 — Responses API strict mode forces every "optional" tool property into
|
||||
// `required`, so a model that intends to OMIT an optional enum property (no declared
|
||||
// `default`) must still emit a concrete value (e.g. Agent.isolation:"remote"). Neither
|
||||
@@ -299,7 +324,11 @@ export function coerceToolSchemas(tools: unknown): unknown {
|
||||
// `null` (see pureHelpers.ts::isDroppableNullEntry). Scope: top-level
|
||||
// `properties[key].enum` only — does not recurse into `items`/`anyOf`/`oneOf` branches
|
||||
// (no real-world case beyond Agent.isolation is documented; extend with a concrete repro).
|
||||
function shouldInjectNullOmission(key: string, propSchema: unknown, required: Set<string>): boolean {
|
||||
function shouldInjectNullOmission(
|
||||
key: string,
|
||||
propSchema: unknown,
|
||||
required: Set<string>
|
||||
): boolean {
|
||||
return (
|
||||
isPlainObject(propSchema) &&
|
||||
Array.isArray(propSchema.enum) &&
|
||||
@@ -312,19 +341,38 @@ function widenPropertyForNullOmission(propSchema: JsonRecord): JsonRecord {
|
||||
const widened: JsonRecord = { ...propSchema };
|
||||
const enumValues = propSchema.enum as unknown[];
|
||||
widened.enum = enumValues.includes(null) ? enumValues : [...enumValues, null];
|
||||
if (typeof propSchema.type === "string") {
|
||||
widened.type = [propSchema.type, "null"];
|
||||
} else if (Array.isArray(propSchema.type) && !propSchema.type.includes("null")) {
|
||||
widened.type = [...propSchema.type, "null"];
|
||||
}
|
||||
const note = "null = omit this parameter";
|
||||
widened.description =
|
||||
typeof propSchema.description === "string" && propSchema.description.length > 0
|
||||
? `${propSchema.description} (${note})`
|
||||
: note;
|
||||
widened.type = widenTypeWithNull(propSchema.type);
|
||||
widened.description = appendNullOmissionMarker(propSchema.description);
|
||||
return widened;
|
||||
}
|
||||
|
||||
// OpenCode `subagent.sessionID` (and any other optional default-less plain string) has
|
||||
// the same strict-mode omission problem as #7023 enums, but no enum to widen. Inject
|
||||
// the same nullable-union sentinel on top-level `properties[key]` only — do not recurse
|
||||
// into `items`/`anyOf`/`$defs`, and do not touch enums (owned by the helper above).
|
||||
function shouldInjectStringNullOmission(
|
||||
key: string,
|
||||
propSchema: unknown,
|
||||
required: Set<string>
|
||||
): boolean {
|
||||
return (
|
||||
isPlainObject(propSchema) &&
|
||||
!Array.isArray(propSchema.enum) &&
|
||||
isPlainStringType(propSchema.type) &&
|
||||
!schemaTypeIncludes(propSchema.type, "null") &&
|
||||
!required.has(key) &&
|
||||
!hasOwn(propSchema, "default")
|
||||
);
|
||||
}
|
||||
|
||||
function widenStringPropertyForNullOmission(propSchema: JsonRecord): JsonRecord {
|
||||
return {
|
||||
...propSchema,
|
||||
type: widenTypeWithNull(propSchema.type),
|
||||
description: appendNullOmissionMarker(propSchema.description),
|
||||
};
|
||||
}
|
||||
|
||||
export function injectOptionalEnumOmissionSentinel(schema: unknown): unknown {
|
||||
if (!isPlainObject(schema) || !isPlainObject(schema.properties)) return schema;
|
||||
|
||||
@@ -356,6 +404,43 @@ export function injectOptionalEnumOmissionForTools(tools: unknown): unknown {
|
||||
});
|
||||
}
|
||||
|
||||
export function injectOptionalStringOmissionSentinel(schema: unknown): unknown {
|
||||
if (!isPlainObject(schema) || !isPlainObject(schema.properties)) return schema;
|
||||
|
||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||
let changed = false;
|
||||
const nextProperties: JsonRecord = { ...schema.properties };
|
||||
|
||||
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
||||
if (!shouldInjectStringNullOmission(key, propSchema, required)) continue;
|
||||
nextProperties[key] = widenStringPropertyForNullOmission(propSchema as JsonRecord);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return schema;
|
||||
return { ...schema, properties: nextProperties };
|
||||
}
|
||||
|
||||
export function injectOptionalStringOmissionForTools(tools: unknown): unknown {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
|
||||
return tools.map((tool) => {
|
||||
if (!isPlainObject(tool)) return tool;
|
||||
|
||||
const result: JsonRecord = { ...tool };
|
||||
if (isPlainObject(result.function) && "parameters" in result.function) {
|
||||
result.function = {
|
||||
...result.function,
|
||||
parameters: injectOptionalStringOmissionSentinel(result.function.parameters),
|
||||
};
|
||||
}
|
||||
if ("parameters" in result && !isPlainObject(result.function)) {
|
||||
result.parameters = injectOptionalStringOmissionSentinel(result.parameters);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeToolDescriptions(tools: unknown): unknown {
|
||||
if (!Array.isArray(tools)) return tools;
|
||||
return tools.map((tool) => sanitizeToolDescription(tool));
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
coerceToolSchemas,
|
||||
injectEmptyReasoningContentForToolCalls,
|
||||
injectOptionalEnumOmissionForTools,
|
||||
injectOptionalStringOmissionForTools,
|
||||
sanitizeToolDescriptions,
|
||||
} from "./helpers/schemaCoercion.ts";
|
||||
import { getRequestTranslator, getResponseTranslator } from "./registry.ts";
|
||||
@@ -595,6 +596,12 @@ export function translateRequest(
|
||||
}
|
||||
|
||||
if (result.tools !== undefined) {
|
||||
// Plain-string omission must run before coerceToolSchemas() strips `default`,
|
||||
// so defaulted optional strings stay unsentinelled. Enum injection stays after
|
||||
// coercion to preserve the #7023 pipeline.
|
||||
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
result.tools = injectOptionalStringOmissionForTools(result.tools);
|
||||
}
|
||||
result.tools = coerceToolSchemas(result.tools);
|
||||
result.tools = sanitizeToolDescriptions(result.tools);
|
||||
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
|
||||
@@ -866,13 +866,13 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
|
||||
function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
if (!chunk) {
|
||||
// Iterate every still-open call needing schema-aware normalization, not just a
|
||||
// single one — multiple parallel calls can each be pending here if the stream
|
||||
// ends before their output_item.done arrives.
|
||||
// Iterate every still-open call with a buffered argument payload — argument
|
||||
// deltas are buffered for every tool, so an incomplete stream must flush every
|
||||
// buffered call, not only the historical uppercase Agent path.
|
||||
const pendingNormalized: Array<{ index: number; argsStr: string }> = [];
|
||||
if (state.toolCallByCallId instanceof Map) {
|
||||
for (const entry of state.toolCallByCallId.values()) {
|
||||
if (entry.needsNormalization && entry.argsBuffer) {
|
||||
if (entry.argsBuffer) {
|
||||
const toolSchema = state.toolSchemas?.get(entry.name);
|
||||
const argsToEmit = stripEmptyOptionalToolArgs(entry.argsBuffer, entry.name, toolSchema);
|
||||
pendingNormalized.push({
|
||||
|
||||
@@ -56,21 +56,35 @@ function isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) {
|
||||
return allowlisted || (propSchema != null && !required.has(key));
|
||||
}
|
||||
|
||||
// #7023 — the request-side counterpart (injectOptionalEnumOmissionSentinel) widens
|
||||
// no-default optional enum properties to accept `null`, meaning "omitted" (OpenAI's own
|
||||
// nullable-union idiom for Responses-API strict mode). Drop the key when the model
|
||||
// follows that idiom for a non-required, schema-declared property.
|
||||
function schemaTypeIncludes(type, wanted) {
|
||||
return type === wanted || (Array.isArray(type) && type.includes(wanted));
|
||||
}
|
||||
|
||||
function hasOmissionSentinel(propSchema) {
|
||||
if (!propSchema || typeof propSchema !== "object") return false;
|
||||
if (
|
||||
typeof propSchema.description !== "string" ||
|
||||
!propSchema.description.includes("null = omit this parameter")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
schemaTypeIncludes(propSchema.type, "null") ||
|
||||
(Array.isArray(propSchema.enum) && propSchema.enum.includes(null))
|
||||
);
|
||||
}
|
||||
|
||||
// #7023 — the request-side counterpart widens no-default optional properties to accept
|
||||
// `null`, meaning "omitted" (OpenAI's own nullable-union idiom for Responses-API strict
|
||||
// mode). Enums use injectOptionalEnumOmissionSentinel; plain strings use
|
||||
// injectOptionalStringOmissionSentinel. Drop the key when the model follows that idiom
|
||||
// for a non-required, schema-declared property, or when OmniRoute's marker is present
|
||||
// even after an upstream strictifies the field into `required`.
|
||||
function isDroppableNullEntry(entry, propSchema, required, key, toolName) {
|
||||
if (entry !== null) return false;
|
||||
if (toolName === "Agent") return true;
|
||||
if (propSchema == null) return false;
|
||||
const omissionSentinel =
|
||||
typeof propSchema === "object" &&
|
||||
Array.isArray(propSchema.enum) &&
|
||||
propSchema.enum.includes(null) &&
|
||||
typeof propSchema.description === "string" &&
|
||||
propSchema.description.includes("null = omit this parameter");
|
||||
return !required.has(key) || omissionSentinel;
|
||||
return !required.has(key) || hasOmissionSentinel(propSchema);
|
||||
}
|
||||
|
||||
function stripEmptyOptionalToolArgsObject(value, toolName, schema) {
|
||||
@@ -110,7 +124,11 @@ export function stripEmptyOptionalToolArgs(value, toolName, schema) {
|
||||
// supplied (schema-aware normalization is not restricted to the allowlist).
|
||||
// "Agent" also passes without a schema: isDroppableNullEntry drops its null
|
||||
// omission sentinels even when the strict schema snapshot is unavailable (#9423).
|
||||
if (!hasUsableSchema(schema) && !STRIPPABLE_EMPTY_ARG_TOOLS.has(toolName) && toolName !== "Agent") {
|
||||
if (
|
||||
!hasUsableSchema(schema) &&
|
||||
!STRIPPABLE_EMPTY_ARG_TOOLS.has(toolName) &&
|
||||
toolName !== "Agent"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -75,76 +75,3 @@ omniroute mcp call <tool> [argsJson]
|
||||
```bash
|
||||
omniroute mcp scopes
|
||||
```
|
||||
|
||||
### `mcp tools`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp tools
|
||||
```
|
||||
|
||||
### `mcp list`
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `--scope <s>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp list
|
||||
```
|
||||
|
||||
### `mcp info <name>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp info <name>
|
||||
```
|
||||
|
||||
### `mcp schema <name>`
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `--io <kind>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp schema <name>
|
||||
```
|
||||
|
||||
### `mcp audit`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp audit
|
||||
```
|
||||
|
||||
### `mcp tail`
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `--follow`
|
||||
- `--limit <n>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp tail
|
||||
```
|
||||
|
||||
### `mcp stats`
|
||||
|
||||
**Flags:**
|
||||
|
||||
- `--period <p>`
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
omniroute mcp stats
|
||||
```
|
||||
|
||||
@@ -29,6 +29,7 @@ import { canUpdateProviderApiKey } from "@/shared/providers/webSessionCredential
|
||||
import {
|
||||
refreshConnectionRateLimits,
|
||||
enableRateLimitProtection,
|
||||
disableRateLimitProtection,
|
||||
} from "@/../open-sse/services/rateLimitManager";
|
||||
import {
|
||||
finalizeValidatedChatGptWebCodexSecrets,
|
||||
@@ -342,10 +343,18 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
// If rateLimitOverrides was included in the request, refresh the in-memory
|
||||
// rate limiter state so the change takes effect without a server restart.
|
||||
// Also ensure rate limit protection is active so the limiter is enforced.
|
||||
// Only (re)enable enforcement when rate limit protection is actually
|
||||
// persisted for this connection — this route never lets a caller flip
|
||||
// `rateLimitProtection` itself, so any drift here would silently start
|
||||
// queuing requests through Bottleneck for a connection whose DB row (and
|
||||
// the dashboard toggle reading it) both still say "off" (#11278).
|
||||
if (rateLimitOverrides !== undefined) {
|
||||
refreshConnectionRateLimits(id, updated?.rateLimitOverrides ?? null);
|
||||
enableRateLimitProtection(id);
|
||||
if (updated?.rateLimitProtection === true) {
|
||||
enableRateLimitProtection(id);
|
||||
} else {
|
||||
disableRateLimitProtection(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Hide sensitive fields
|
||||
|
||||
@@ -265,10 +265,6 @@ async function buildUnifiedModelsResponseCore(
|
||||
// try would let a crash here propagate as an unhandled rejection instead
|
||||
// (catalogCache.ts's in-flight coalescing does not fully consume rejections).
|
||||
const hiddenModelsByProvider = getHiddenModelsByProvider();
|
||||
const isModelHiddenBulk = (providerId: string, modelId: string): boolean => {
|
||||
const hiddenSet = hiddenModelsByProvider.get(providerId);
|
||||
return hiddenSet ? hiddenSet.has(modelId) : false;
|
||||
};
|
||||
let settings: Record<string, any> = {};
|
||||
try {
|
||||
settings = await getSettings();
|
||||
@@ -377,6 +373,35 @@ async function buildUnifiedModelsResponseCore(
|
||||
const resolvePublicOwnerId = (providerId: string, canonicalProviderId: string): string =>
|
||||
providerIdToPrefix[providerId] || canonicalProviderId;
|
||||
|
||||
// #11300: the visibility toggle on a provider's dashboard page persists the
|
||||
// hidden-model row under whatever key the route's `[id]` param happened to be
|
||||
// (a node UUID, an alias like `cc`/`gh`/`cx`, or a canonical provider id) —
|
||||
// see `PATCH /api/provider-models`. The catalog loops below each key their own
|
||||
// lookup differently (raw connection provider, canonical id, or alias), so a
|
||||
// single-key lookup missed the override whenever the write key and the read key
|
||||
// diverged. Check every key a model could plausibly have been hidden under:
|
||||
// the raw key passed in, its resolved canonical provider id, that canonical id's
|
||||
// alias, and the compatible-provider-node prefix for either.
|
||||
const isModelHiddenBulk = (
|
||||
providerKey: string | null | undefined,
|
||||
modelId: string,
|
||||
canonicalProviderId?: string | null
|
||||
): boolean => {
|
||||
if (!providerKey || !modelId) return false;
|
||||
const canonical = canonicalProviderId || resolveCanonicalProviderId(providerKey);
|
||||
const alias =
|
||||
providerIdToAlias[canonical] || providerIdToAlias[providerKey] || undefined;
|
||||
const nodePrefix = providerIdToPrefix[providerKey] || providerIdToPrefix[canonical];
|
||||
const keysToCheck = [providerKey, canonical, alias, nodePrefix].filter(
|
||||
(k): k is string => Boolean(k)
|
||||
);
|
||||
for (const key of keysToCheck) {
|
||||
const hiddenSet = hiddenModelsByProvider.get(key);
|
||||
if (hiddenSet?.has(modelId)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Get combos
|
||||
let combos = [];
|
||||
await yieldCatalogBuildTurn();
|
||||
@@ -955,7 +980,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
if (!isModelSelectable(canonicalProviderId, model.id)) continue;
|
||||
if (!providerSupportsModel(canonicalProviderId, model.id)) continue;
|
||||
const aliasId = `${alias}/${model.id}`;
|
||||
if (isModelHiddenBulk(canonicalProviderId, model.id)) continue;
|
||||
if (isModelHiddenBulk(alias, model.id, canonicalProviderId)) continue;
|
||||
if (isExcludedByProviderConnections(canonicalProviderId, model.id)) continue;
|
||||
if (shouldHidePaid(canonicalProviderId, model.id, (model as { pricing?: unknown }).pricing))
|
||||
continue;
|
||||
@@ -1018,7 +1043,15 @@ async function buildUnifiedModelsResponseCore(
|
||||
|
||||
for (const modelId of CODEX_NATIVE_UNPREFIXED_MODELS) {
|
||||
if (!providerSupportsModel("codex", modelId)) continue;
|
||||
if (isModelHiddenBulk("codex", modelId)) continue;
|
||||
// #11300: a codex-native unprefixed model can also be hidden via the
|
||||
// `openai` provider page (codex runs on the openai-compatible connection)
|
||||
// or via the `cx` alias — check all three so a hide from any of them
|
||||
// suppresses the bare model id here.
|
||||
if (
|
||||
isModelHiddenBulk("codex", modelId) ||
|
||||
isModelHiddenBulk("openai", modelId)
|
||||
)
|
||||
continue;
|
||||
|
||||
const alias = providerIdToAlias.codex || "cx";
|
||||
const aliasId = `${alias}/${modelId}`;
|
||||
@@ -1079,7 +1112,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
if (canonicalProviderId === "codex" && isCodexDiscoveryModelExcluded(sm)) {
|
||||
continue;
|
||||
}
|
||||
if (isModelHiddenBulk(providerId, sm.id)) continue;
|
||||
if (isModelHiddenBulk(providerId, sm.id, canonicalProviderId)) continue;
|
||||
if (isExcludedByProviderConnections(canonicalProviderId, sm.id)) continue;
|
||||
// #6457: some upstream discovery catalogs (e.g. HuggingFace's live
|
||||
// `/v1/models`) return image/diffusion models with no modality info,
|
||||
@@ -1498,7 +1531,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
if (!isUnifiedChatSourceModelSelectable(canonicalProviderId, { ...model, id: modelId }))
|
||||
continue;
|
||||
if (model.isHidden === true) continue;
|
||||
if (isModelHiddenBulk(canonicalProviderId, modelId)) continue;
|
||||
if (isModelHiddenBulk(providerId, modelId, canonicalProviderId)) continue;
|
||||
if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue;
|
||||
// #6328: apply hidePaidModels to user-defined custom rows too.
|
||||
// Custom entries do not carry pricing, so shouldHidePaid() decides
|
||||
@@ -1682,7 +1715,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isModelHiddenBulk(canonicalProviderId, modelId)) continue;
|
||||
if (isModelHiddenBulk(providerKey, modelId, canonicalProviderId)) continue;
|
||||
if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue;
|
||||
// #6328: apply hidePaidModels to alias-backed rows too. Alias mappings
|
||||
// point at providerKey/modelId with no pricing, so shouldHidePaid()
|
||||
@@ -1756,7 +1789,7 @@ async function buildUnifiedModelsResponseCore(
|
||||
for (const model of fallbackModels) {
|
||||
const modelId = typeof model.id === "string" ? model.id : null;
|
||||
if (!modelId) continue;
|
||||
if (isModelHiddenBulk(canonicalProviderId, modelId)) continue;
|
||||
if (isModelHiddenBulk(providerId, modelId, canonicalProviderId)) continue;
|
||||
if (isExcludedByProviderConnections(canonicalProviderId, modelId)) continue;
|
||||
// #6328: apply hidePaidModels to managed-fallback rows too. Compatible
|
||||
// provider fallbacks lack pricing; shouldHidePaid() decides via the
|
||||
|
||||
@@ -188,6 +188,32 @@ describe("injectMemory — edge cases", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectMemory — Claude-family cache-safe splice gate (#11290)", () => {
|
||||
test("does not splice mid-array on anthropic when the last turn before the splice point is plain assistant text", () => {
|
||||
const request = makeRequest({
|
||||
messages: [
|
||||
{ role: "system", content: "SYSTEM PROMPT" },
|
||||
{ role: "user", content: "turn 1 question" },
|
||||
{ role: "assistant", content: "turn 1 answer" },
|
||||
{ role: "user", content: "turn 2 question" },
|
||||
],
|
||||
});
|
||||
const memories = [makeMemory("dark mode")];
|
||||
|
||||
const result = injectMemory(request, memories, "anthropic", { cacheSafe: true });
|
||||
|
||||
// The plain-text assistant turn must stay immediately followed by the final user
|
||||
// turn — no system message spliced between them (that shape is what Opus 5 rejects
|
||||
// with HTTP 400, #11290). Memory is merged into the leading system message instead.
|
||||
expect(result.messages).toHaveLength(4);
|
||||
expect(result.messages[0].role).toBe("system");
|
||||
expect(result.messages[0].content).toContain("Memory context: dark mode");
|
||||
expect(result.messages[0].content).toContain("SYSTEM PROMPT");
|
||||
expect(result.messages[2]).toEqual({ role: "assistant", content: "turn 1 answer" });
|
||||
expect(result.messages[3]).toEqual({ role: "user", content: "turn 2 question" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldInjectMemory", () => {
|
||||
test("returns true when messages are present and enabled not set", () => {
|
||||
const request = makeRequest();
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
|
||||
import { Memory } from "./types";
|
||||
import { logger } from "../../../open-sse/utils/logger.ts";
|
||||
import {
|
||||
isAnthropicCompatibleProvider,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
} from "../../shared/constants/providers";
|
||||
|
||||
const log = logger("MEMORY_INJECTION");
|
||||
|
||||
@@ -170,6 +174,43 @@ function injectSystemFirst(
|
||||
return { ...request, messages: [memorySystemMessage, ...messages] };
|
||||
}
|
||||
|
||||
/**
|
||||
* #11290: providers in the Claude family (direct Anthropic, and any
|
||||
* anthropic-compatible / Claude-Code-compatible passthrough connection) — the
|
||||
* ones affected by the stricter Opus 5 message-ordering validation described
|
||||
* below. Deliberately narrower than `systemMessageMustBeFirst()`'s strict-set:
|
||||
* this only gates the cache-safe mid-array splice, not the leading-system-message
|
||||
* requirement, so non-Claude providers keep the #3890 cache-hit optimization
|
||||
* unconditionally.
|
||||
*/
|
||||
function isClaudeFamilyProvider(provider: string | null | undefined): boolean {
|
||||
if (!provider) return false;
|
||||
const normalized = provider.toLowerCase().trim();
|
||||
return (
|
||||
normalized === "claude" ||
|
||||
normalized === "anthropic" ||
|
||||
isClaudeCodeCompatibleProvider(provider) ||
|
||||
isAnthropicCompatibleProvider(provider)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an assistant message's content ends in a server-side tool result
|
||||
* block (e.g. `web_search_tool_result`, `code_execution_tool_result`,
|
||||
* `mcp_tool_result` — any Anthropic content block whose type ends in
|
||||
* `_tool_result`, produced by a server-executed tool rather than a
|
||||
* client-executed one). `content` is typed as `string` on `ChatMessage` for
|
||||
* the common case, but the Claude-native wire shape carries an array of
|
||||
* content blocks — this only recognizes that richer shape.
|
||||
*/
|
||||
function endsWithServerToolResult(message: ChatMessage | undefined): boolean {
|
||||
if (!message || message.role !== "assistant") return false;
|
||||
const content = message.content as unknown;
|
||||
if (!Array.isArray(content) || content.length === 0) return false;
|
||||
const lastBlock = content[content.length - 1] as { type?: unknown } | null | undefined;
|
||||
return typeof lastBlock?.type === "string" && lastBlock.type.endsWith("_tool_result");
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a memory message at the #3890 cache-safe anchor (just before the last
|
||||
* user turn) when one exists, else prepend it. Shared by the system and user
|
||||
@@ -222,6 +263,24 @@ export function injectMemory(
|
||||
return injectSystemFirst(request, messages, memoryText, memories.length);
|
||||
}
|
||||
|
||||
// #11290: Claude Opus 5 tightened server-side validation of the cache-safe
|
||||
// mid-array splice — a system message spliced right after a plain-text assistant
|
||||
// turn is rejected with HTTP 400 (the immediately preceding message must end in a
|
||||
// server-side tool result for a following system message to be accepted). Rather
|
||||
// than adding "claude"/"anthropic" outright to `systemMessageMustBeFirst()` (which
|
||||
// would revert the #3890 cache-hit optimization for every Claude request, including
|
||||
// the ones that work fine today), only fall back to the leading-system-message
|
||||
// placement for the specific requests where the turn right before the splice point
|
||||
// isn't a server tool result.
|
||||
if (
|
||||
supportsSystem &&
|
||||
cacheSafeIndex >= 0 &&
|
||||
isClaudeFamilyProvider(provider) &&
|
||||
!endsWithServerToolResult(messages[cacheSafeIndex - 1])
|
||||
) {
|
||||
return injectSystemFirst(request, messages, memoryText, memories.length);
|
||||
}
|
||||
|
||||
// Strategy 1 (system): prepend before existing system messages, preserving the
|
||||
// caller's own instructions. Strategy 2 (user, e.g. o1-mini): inject as a user
|
||||
// message. Both honor the #3890 cache-safe anchor via placeMessage.
|
||||
|
||||
15
tests/unit/11018-database-cache-docs.test.ts
Normal file
15
tests/unit/11018-database-cache-docs.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { DEFAULT_DATABASE_SETTINGS } from "../../src/types/databaseSettings.ts";
|
||||
|
||||
const guide = readFileSync(new URL("../../docs/ops/DATABASE_GUIDE.md", import.meta.url), "utf8");
|
||||
|
||||
test("database guide keeps cache tuning aligned with runtime settings (#11018)", () => {
|
||||
const defaultCacheSize = DEFAULT_DATABASE_SETTINGS.optimization.cacheSize;
|
||||
|
||||
assert.match(guide, new RegExp(`${defaultCacheSize.toLocaleString("en-US")} KiB`));
|
||||
assert.match(guide, /1 to\s+1,000,000 KiB/);
|
||||
assert.match(guide, /saving the setting applies it to the live database connection/);
|
||||
assert.match(guide, /restores the persisted value at startup/);
|
||||
});
|
||||
@@ -25,9 +25,8 @@ process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { updateSettings } = await import("../../src/lib/db/settings.ts");
|
||||
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
|
||||
const { shouldDefaultAllowClassifier, buildDefaultAllowClaudeMessage } = await import(
|
||||
"../../open-sse/handlers/chatCore/claudeClassifierCompat.ts"
|
||||
);
|
||||
const { shouldDefaultAllowClassifier, detectClassifierFormat, buildDefaultAllowClaudeMessage } =
|
||||
await import("../../open-sse/handlers/chatCore/claudeClassifierCompat.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -58,6 +57,14 @@ const CLASSIFIER_BODY = {
|
||||
max_tokens: 8,
|
||||
};
|
||||
|
||||
// Newer Claude Code builds send a "severity classifier" variant of the same internal
|
||||
// request: same security-monitor marker, but `stop_sequences` carries `</severity>`
|
||||
// instead of `</block>`, and it expects a `<severity>N</severity>` reply (#11289).
|
||||
const SEVERITY_CLASSIFIER_BODY = {
|
||||
...CLASSIFIER_BODY,
|
||||
stop_sequences: ["</severity>"],
|
||||
};
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
core.resetDbInstance();
|
||||
@@ -123,7 +130,12 @@ test("detector: always does NOT fire for normal chat without classifier marker (
|
||||
|
||||
test("detector: always fires when classifier marker is present", () => {
|
||||
const classifier = {
|
||||
system: [{ type: "text", text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action." }],
|
||||
system: [
|
||||
{
|
||||
type: "text",
|
||||
text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action.",
|
||||
},
|
||||
],
|
||||
stop_sequences: ["</block>"],
|
||||
};
|
||||
assert.equal(
|
||||
@@ -133,6 +145,21 @@ test("detector: always fires when classifier marker is present", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Pure detector: detectClassifierFormat (#11289) ──────────────────────────
|
||||
|
||||
test("format detector: defaults to 'block' for the legacy </block> classifier shape", () => {
|
||||
assert.equal(detectClassifierFormat(CLASSIFIER_BODY), "block");
|
||||
});
|
||||
|
||||
test("format detector: returns 'severity' when stop_sequences carries </severity>", () => {
|
||||
assert.equal(detectClassifierFormat(SEVERITY_CLASSIFIER_BODY), "severity");
|
||||
});
|
||||
|
||||
test("format detector: defaults to 'block' when stop_sequences is missing/empty", () => {
|
||||
assert.equal(detectClassifierFormat({}), "block");
|
||||
assert.equal(detectClassifierFormat({ stop_sequences: [] }), "block");
|
||||
});
|
||||
|
||||
// ─── Pure builder: buildDefaultAllowClaudeMessage ────────────────────────────
|
||||
|
||||
test("builder: synthetic message text STARTS WITH <block>no</block>", async () => {
|
||||
@@ -155,6 +182,16 @@ test("builder: synthetic message text STARTS WITH <block>no</block>", async () =
|
||||
assert.ok(!text.includes("<block>yes"), "must not signal BLOCK");
|
||||
});
|
||||
|
||||
test("builder: format='severity' returns <severity>0</severity> (#11289)", async () => {
|
||||
const built = buildDefaultAllowClaudeMessage("claude-3-5-haiku-20241022", "severity");
|
||||
assert.equal(built.success, true);
|
||||
const payload = (await built.response.json()) as {
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
|
||||
assert.equal(text, "<severity>0</severity>");
|
||||
});
|
||||
|
||||
// ─── Handler-level: end-to-end short-circuit through handleChatCore ──────────
|
||||
|
||||
test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstream, text starts with <block>no</block>", async () => {
|
||||
@@ -196,3 +233,44 @@ test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstre
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handler: claudeClassifierCompat=auto emits <severity>0</severity> for the severity-classifier shape (#11289)", async () => {
|
||||
await updateSettings({ claudeClassifierCompat: "auto" });
|
||||
|
||||
let fetchCalls = 0;
|
||||
globalThis.fetch = (async () => {
|
||||
fetchCalls++;
|
||||
throw new Error("upstream fetch should NOT be called when the classifier short-circuits");
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const result = await handleChatCore({
|
||||
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
|
||||
modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false },
|
||||
credentials: { apiKey: "sk-test", providerSpecificData: {} },
|
||||
log: noopLog(),
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/messages",
|
||||
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
|
||||
headers: new Headers({ accept: "application/json" }),
|
||||
},
|
||||
userAgent: "unit-test",
|
||||
});
|
||||
|
||||
assert.equal(fetchCalls, 0, "upstream fetch must NOT be called");
|
||||
assert.equal(result.success, true, "handleChatCore must report success");
|
||||
const payload = (await (result as { response: Response }).response.json()) as {
|
||||
type: string;
|
||||
content: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
assert.equal(payload.type, "message");
|
||||
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
|
||||
assert.equal(
|
||||
text,
|
||||
"<severity>0</severity>",
|
||||
`expected severity-classifier response to be <severity>0</severity>, got: ${text}`
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
177
tests/unit/hidden-models-leak-v1-models-11300.test.ts
Normal file
177
tests/unit/hidden-models-leak-v1-models-11300.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* #11300 — Models toggled to "Hidden" on Provider pages are still listed in
|
||||
* `GET /v1/models`.
|
||||
*
|
||||
* `PATCH /api/provider-models?provider=<key>&modelId=<id>` persists the hidden
|
||||
* override under whatever key the dashboard's `[id]` route param happened to be
|
||||
* (an alias like `cc`/`gh`/`cx`, a canonical provider id, a compatible-provider
|
||||
* node UUID, or its configured prefix). `catalog.ts`'s `isModelHiddenBulk()` did
|
||||
* a single-key lookup, so a model stayed listed in `/v1/models` whenever the key
|
||||
* used to READ diverged from the key used to WRITE:
|
||||
*
|
||||
* - Static `PROVIDER_MODELS` loop checked only `canonicalProviderId` — a model
|
||||
* hidden under the alias (e.g. `cc` for Claude Code) never matched.
|
||||
* - The Codex-native-unprefixed loop checked only `"codex"` — a model hidden
|
||||
* via the `openai` provider page (codex often shares the openai-compatible
|
||||
* connection) never matched.
|
||||
* - The synced-discovery loop checked only the raw connection `providerId` —
|
||||
* a model hidden via the compatible-provider node's configured *prefix*
|
||||
* (the identifier the operator actually sees/uses on that node's page)
|
||||
* never matched.
|
||||
*/
|
||||
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";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-11300-hidden-leak-"));
|
||||
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 { mergeModelCompatOverride } = await import("../../src/lib/localDb.ts");
|
||||
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
v1ModelsCatalog.__resetCatalogBuilderRunsForTest();
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function fetchCatalogIds(): Promise<string[]> {
|
||||
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { data: Array<{ id: string }> };
|
||||
assert.ok(Array.isArray(body.data), "response has data array");
|
||||
return body.data.map((m) => m.id);
|
||||
}
|
||||
|
||||
test("#11300 A: hiding a static model under its ALIAS (cc) excludes it under both cc/ and claude/ ids", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "claude",
|
||||
authType: "apikey",
|
||||
name: "claude-main",
|
||||
apiKey: "sk-test-11300a",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
// Sanity: before hiding, the model is advertised.
|
||||
let ids = await fetchCatalogIds();
|
||||
assert.ok(
|
||||
ids.includes("cc/claude-opus-5"),
|
||||
`expected cc/claude-opus-5 to be listed before hiding — got ${JSON.stringify(ids.filter((i) => i.includes("claude-opus-5")))}`
|
||||
);
|
||||
|
||||
// Operator hides the model on the provider page, whose route param is the
|
||||
// alias "cc" (not the canonical "claude").
|
||||
mergeModelCompatOverride("cc", "claude-opus-5", { isHidden: true });
|
||||
|
||||
ids = await fetchCatalogIds();
|
||||
assert.ok(
|
||||
!ids.includes("cc/claude-opus-5"),
|
||||
`#11300 RED: cc/claude-opus-5 hidden under alias "cc" must not appear — got ${JSON.stringify(ids.filter((i) => i.includes("claude-opus-5")))}`
|
||||
);
|
||||
assert.ok(
|
||||
!ids.includes("claude/claude-opus-5"),
|
||||
`#11300 RED: claude/claude-opus-5 hidden under alias "cc" must not appear either`
|
||||
);
|
||||
});
|
||||
|
||||
test("#11300 B: hiding a codex-native unprefixed model under \"openai\" excludes the bare model id", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
name: "codex-main",
|
||||
apiKey: "sk-test-11300b",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {},
|
||||
});
|
||||
|
||||
const nativeModelId = "gpt-5.6-sol";
|
||||
|
||||
let ids = await fetchCatalogIds();
|
||||
assert.ok(
|
||||
ids.includes(nativeModelId),
|
||||
`expected bare "${nativeModelId}" to be listed before hiding — got ${JSON.stringify(ids.filter((i) => i.includes("gpt-5.6-sol")))}`
|
||||
);
|
||||
|
||||
// Hidden via the "openai" provider page (codex native models are commonly
|
||||
// reached through the shared openai-compatible connection).
|
||||
mergeModelCompatOverride("openai", nativeModelId, { isHidden: true });
|
||||
|
||||
ids = await fetchCatalogIds();
|
||||
assert.ok(
|
||||
!ids.includes(nativeModelId),
|
||||
`#11300 RED: bare "${nativeModelId}" hidden under "openai" must not appear — got ${JSON.stringify(ids.filter((i) => i.includes("gpt-5.6-sol")))}`
|
||||
);
|
||||
});
|
||||
|
||||
test("#11300 C: hiding a compatible-node synced model under its configured PREFIX excludes prefix/<model>", async () => {
|
||||
const NODE_ID = "openai-compatible-chat-11300-c0ffee00-0000-4000-8000-000000000000";
|
||||
const PREFIX = "deepseek-node-11300";
|
||||
|
||||
await providersDb.createProviderNode({
|
||||
id: NODE_ID,
|
||||
type: "openai-compatible",
|
||||
name: "Deepseek Node (11300 probe)",
|
||||
prefix: PREFIX,
|
||||
baseUrl: "https://proxy.example.com",
|
||||
chatPath: "/v1/chat/completions",
|
||||
modelsPath: "/v1/models",
|
||||
});
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: NODE_ID,
|
||||
authType: "apikey",
|
||||
name: "deepseek-node-conn",
|
||||
apiKey: "sk-test-11300c",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example.com",
|
||||
chatPath: "/v1/chat/completions",
|
||||
modelsPath: "/v1/models",
|
||||
},
|
||||
});
|
||||
|
||||
const modelId = "deepseek-v4-flash-0731";
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection(NODE_ID, (connection as { id: string }).id, [
|
||||
{ id: modelId, name: "DeepSeek V4 Flash", source: "imported", supportedEndpoints: ["chat"] },
|
||||
]);
|
||||
|
||||
let ids = await fetchCatalogIds();
|
||||
assert.ok(
|
||||
ids.includes(`${PREFIX}/${modelId}`),
|
||||
`expected ${PREFIX}/${modelId} to be listed before hiding — got ${JSON.stringify(ids.filter((i) => i.includes(modelId)))}`
|
||||
);
|
||||
|
||||
// Operator hides the model via the node's page, which is keyed by the
|
||||
// configured prefix rather than the internal node UUID.
|
||||
mergeModelCompatOverride(PREFIX, modelId, { isHidden: true });
|
||||
|
||||
ids = await fetchCatalogIds();
|
||||
assert.ok(
|
||||
!ids.includes(`${PREFIX}/${modelId}`),
|
||||
`#11300 RED: ${PREFIX}/${modelId} hidden under prefix "${PREFIX}" must not appear — got ${JSON.stringify(ids.filter((i) => i.includes(modelId)))}`
|
||||
);
|
||||
assert.ok(
|
||||
!ids.includes(`${NODE_ID}/${modelId}`),
|
||||
`#11300 RED: ${NODE_ID}/${modelId} hidden under prefix "${PREFIX}" must not appear either`
|
||||
);
|
||||
});
|
||||
@@ -39,16 +39,20 @@ function multiTurn(): ChatRequest {
|
||||
|
||||
describe("injectMemory cache-safe positioning (#3890)", () => {
|
||||
it("default (cacheSafe off) prepends memory at index 0 — unchanged legacy behavior", () => {
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic");
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "openai");
|
||||
assert.equal(out.messages[0].role, "system");
|
||||
assert.ok(out.messages[0].content.includes("Memory context"));
|
||||
assert.equal(out.messages[1].content, "SYSTEM PROMPT");
|
||||
});
|
||||
|
||||
// Note: "openai" here stands in for any non-Claude-family provider that honors the
|
||||
// cache-safe mid-array splice (e.g. DashScope/Xiaomi MiMo via OpenAI-format
|
||||
// cache_control). Claude-family providers (anthropic/claude/CC-compatible) have their
|
||||
// own, narrower gate covered in the "#11290" describe block below.
|
||||
it("cacheSafe inserts memory just before the last user message, preserving the prefix", () => {
|
||||
const req = multiTurn();
|
||||
const prefixBefore = JSON.stringify(req.messages.slice(0, 3)); // sys, u1, a1
|
||||
const out = injectMemory(req, [mem("dark mode")], "anthropic", { cacheSafe: true });
|
||||
const out = injectMemory(req, [mem("dark mode")], "openai", { cacheSafe: true });
|
||||
|
||||
// The cacheable prefix (system + prior turns up to the last assistant) is byte-identical.
|
||||
assert.equal(JSON.stringify(out.messages.slice(0, 3)), prefixBefore);
|
||||
@@ -75,8 +79,8 @@ describe("injectMemory cache-safe positioning (#3890)", () => {
|
||||
};
|
||||
const turn2 = multiTurn();
|
||||
|
||||
const out1 = injectMemory(turn1, [mem("A")], "anthropic", { cacheSafe: true });
|
||||
const out2 = injectMemory(turn2, [mem("B")], "anthropic", { cacheSafe: true });
|
||||
const out1 = injectMemory(turn1, [mem("A")], "openai", { cacheSafe: true });
|
||||
const out2 = injectMemory(turn2, [mem("B")], "openai", { cacheSafe: true });
|
||||
|
||||
// The cache-breakpoint-bearing system message stays at the head, byte-identical, in
|
||||
// both turns (and is NOT displaced by the per-query memory) — so the prompt cache
|
||||
@@ -103,3 +107,76 @@ describe("injectMemory cache-safe positioning (#3890)", () => {
|
||||
assert.equal(out.messages[1].content, "SYS");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #11290: Claude Opus 5 tightened server-side validation and started rejecting the
|
||||
* #3890 cache-safe mid-array splice with HTTP 400 whenever the assistant turn
|
||||
* immediately before the splice point is a plain-text turn (not a server-side tool
|
||||
* result). These tests pin the narrower, Claude-family-only gate added to
|
||||
* `injectMemory()`: fall back to leading-system-message placement in that specific
|
||||
* case, while still honoring the mid-array splice everywhere it is safe (non-Claude
|
||||
* providers unconditionally, and Claude providers whose preceding turn IS a server
|
||||
* tool result).
|
||||
*/
|
||||
describe("injectMemory cache-safe positioning — Claude-family server-tool-result gate (#11290)", () => {
|
||||
it("falls back to leading system-message placement for anthropic when the preceding assistant turn is plain text", () => {
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic", { cacheSafe: true });
|
||||
|
||||
// No splice: the memory is merged into the leading system message instead of being
|
||||
// inserted right after the plain-text "turn 1 answer" assistant turn.
|
||||
assert.equal(out.messages.length, 4);
|
||||
assert.equal(out.messages[0].role, "system");
|
||||
assert.ok(out.messages[0].content.includes("Memory context: dark mode"));
|
||||
assert.ok(out.messages[0].content.includes("SYSTEM PROMPT"));
|
||||
assert.equal(out.messages[1].content, "turn 1 question");
|
||||
assert.equal(out.messages[2].content, "turn 1 answer");
|
||||
assert.equal(out.messages[3].content, "turn 2 question");
|
||||
});
|
||||
|
||||
it("still splices mid-array for anthropic when the preceding assistant turn ends in a server tool result", () => {
|
||||
const req: ChatRequest = {
|
||||
model: "anthropic/claude-opus-5",
|
||||
messages: [
|
||||
{ role: "system", content: "SYSTEM PROMPT" },
|
||||
{ role: "user", content: "turn 1 question" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "server_tool_use", id: "srvtoolu_1", name: "web_search", input: {} },
|
||||
{ type: "web_search_tool_result", tool_use_id: "srvtoolu_1", content: [] },
|
||||
],
|
||||
} as unknown as ChatRequest["messages"][number],
|
||||
{ role: "user", content: "turn 2 question" },
|
||||
],
|
||||
};
|
||||
|
||||
const out = injectMemory(req, [mem("dark mode")], "anthropic", { cacheSafe: true });
|
||||
|
||||
assert.equal(out.messages.length, 5);
|
||||
assert.equal(out.messages[0].content, "SYSTEM PROMPT");
|
||||
assert.equal(out.messages[3].role, "system");
|
||||
assert.ok(out.messages[3].content.includes("Memory context"));
|
||||
assert.equal(out.messages[4].content, "turn 2 question");
|
||||
});
|
||||
|
||||
it("applies the same fallback to a Claude-Code-compatible passthrough provider id", () => {
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic-compatible-cc-github-copilot", {
|
||||
cacheSafe: true,
|
||||
});
|
||||
|
||||
assert.equal(out.messages.length, 4);
|
||||
assert.equal(out.messages[0].role, "system");
|
||||
assert.ok(out.messages[0].content.includes("Memory context: dark mode"));
|
||||
assert.ok(out.messages[0].content.includes("SYSTEM PROMPT"));
|
||||
});
|
||||
|
||||
it("does not gate non-Claude providers even without a server tool result", () => {
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "openai", { cacheSafe: true });
|
||||
|
||||
// Unaffected by #11290: the mid-array splice is preserved for non-Claude providers.
|
||||
assert.equal(out.messages.length, 5);
|
||||
assert.equal(out.messages[3].role, "system");
|
||||
assert.ok(out.messages[3].content.includes("Memory context"));
|
||||
assert.equal(out.messages[4].content, "turn 2 question");
|
||||
});
|
||||
});
|
||||
|
||||
515
tests/unit/openai-responses-opencode-subagent-sessionid.test.ts
Normal file
515
tests/unit/openai-responses-opencode-subagent-sessionid.test.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// OpenCode `subagent.sessionID` is an optional plain string. Absence means "spawn a
|
||||
// new child". Responses/Codex strict mode forces every declared property into
|
||||
// `required`, so models invent fillers (`ses_`, `ses_new`, parent IDs) unless
|
||||
// OmniRoute offers `null` as the omission sentinel and strips it before the client
|
||||
// sees the tool call. This is the string counterpart of the #7023 enum sentinel.
|
||||
|
||||
const { injectOptionalStringOmissionSentinel, injectOptionalStringOmissionForTools } =
|
||||
await import("../../open-sse/translator/helpers/schemaCoercion.ts");
|
||||
const { stripEmptyOptionalToolArgs } =
|
||||
await import("../../open-sse/translator/response/openai-responses/pureHelpers.ts");
|
||||
const { openaiResponsesToOpenAIResponse } =
|
||||
await import("../../open-sse/translator/response/openai-responses.ts");
|
||||
const { translateRequest } = await import("../../open-sse/translator/index.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
const { translateNonStreamingResponse } =
|
||||
await import("../../open-sse/handlers/responseTranslator.ts");
|
||||
const { extractToolSchemaMap } =
|
||||
await import("../../open-sse/translator/response/openai-responses/toolSchemas.ts");
|
||||
|
||||
const OMISSION_MARKER = "null = omit this parameter";
|
||||
|
||||
const OPENCODE_SUBAGENT_SCHEMA = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
agent: { type: "string" },
|
||||
description: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
sessionID: {
|
||||
type: "string",
|
||||
description: "Continue a specific previous subagent conversation",
|
||||
},
|
||||
background: { type: "boolean" },
|
||||
},
|
||||
required: ["agent", "description", "prompt"],
|
||||
};
|
||||
|
||||
const SUBAGENT_TOOL_CHAT = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: "subagent",
|
||||
parameters: structuredClone(OPENCODE_SUBAGENT_SCHEMA),
|
||||
},
|
||||
};
|
||||
|
||||
const SUBAGENT_TOOL_RESPONSES = {
|
||||
type: "function",
|
||||
name: "subagent",
|
||||
parameters: structuredClone(OPENCODE_SUBAGENT_SCHEMA),
|
||||
};
|
||||
|
||||
const NATIVE_CUSTOM_TOOL = {
|
||||
type: "custom",
|
||||
name: "apply_patch",
|
||||
format: { type: "grammar", syntax: "lark", definition: "start: /.+/ " },
|
||||
};
|
||||
|
||||
function findTool(tools, name) {
|
||||
return tools.find((t) => t?.name === name || t?.function?.name === name);
|
||||
}
|
||||
|
||||
function toolParameters(tool) {
|
||||
return tool.parameters ?? tool.function?.parameters ?? tool.input_schema;
|
||||
}
|
||||
|
||||
function sessionIdSchema(params) {
|
||||
return params.properties.sessionID;
|
||||
}
|
||||
|
||||
function assertOmissionSentinel(prop) {
|
||||
assert.deepEqual(prop.type, ["string", "null"]);
|
||||
assert.match(
|
||||
prop.description,
|
||||
new RegExp(OMISSION_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
);
|
||||
assert.equal(Array.isArray(prop.enum), false);
|
||||
}
|
||||
|
||||
function collectArgs(chunks) {
|
||||
const list = Array.isArray(chunks) ? chunks : chunks ? [chunks] : [];
|
||||
let raw = "";
|
||||
let finishReason = null;
|
||||
for (const chunk of list) {
|
||||
const choice = chunk?.choices?.[0];
|
||||
if (!choice) continue;
|
||||
const args = choice.delta?.tool_calls?.[0]?.function?.arguments;
|
||||
if (typeof args === "string") raw += args;
|
||||
if (choice.finish_reason) finishReason = choice.finish_reason;
|
||||
}
|
||||
return { raw, finishReason, parsed: raw ? JSON.parse(raw) : null };
|
||||
}
|
||||
|
||||
test("RED: translateRequest OpenAI→Responses widens optional default-less sessionID", () => {
|
||||
const body = {
|
||||
model: "gpt-5.1-codex",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [structuredClone(SUBAGENT_TOOL_CHAT)],
|
||||
};
|
||||
|
||||
const toResponses = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
"gpt-5.1-codex",
|
||||
structuredClone(body)
|
||||
);
|
||||
const tool = findTool(toResponses.tools, "subagent");
|
||||
const params = toolParameters(tool);
|
||||
assertOmissionSentinel(sessionIdSchema(params));
|
||||
assert.equal(params.properties.agent.type, "string");
|
||||
assert.equal(params.properties.background.type, "boolean");
|
||||
assert.deepEqual(params.required, ["agent", "description", "prompt"]);
|
||||
});
|
||||
|
||||
test("RED: same-format Responses applies string omission without flattening native tools", () => {
|
||||
const body = {
|
||||
model: "gpt-5.1-codex",
|
||||
input: [{ role: "user", content: "hi" }],
|
||||
tools: [structuredClone(SUBAGENT_TOOL_RESPONSES), structuredClone(NATIVE_CUSTOM_TOOL)],
|
||||
};
|
||||
|
||||
const sameFormat = translateRequest(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
"gpt-5.1-codex",
|
||||
structuredClone(body)
|
||||
);
|
||||
const functionTool = findTool(sameFormat.tools, "subagent");
|
||||
assertOmissionSentinel(sessionIdSchema(toolParameters(functionTool)));
|
||||
|
||||
const custom = sameFormat.tools.find((t) => t.name === "apply_patch");
|
||||
assert.equal(custom.type, "custom");
|
||||
assert.deepEqual(custom.format, NATIVE_CUSTOM_TOOL.format);
|
||||
assert.equal(custom.parameters, undefined);
|
||||
});
|
||||
|
||||
test("characterization: non-Responses target leaves sessionID unchanged", () => {
|
||||
const body = {
|
||||
model: "claude-3-7-sonnet",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [structuredClone(SUBAGENT_TOOL_CHAT)],
|
||||
};
|
||||
const toClaude = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE,
|
||||
"claude-3-7-sonnet",
|
||||
structuredClone(body)
|
||||
);
|
||||
const tool = toClaude.tools.find((t) => String(t.name).includes("subagent"));
|
||||
const schema = toolParameters(tool);
|
||||
assert.equal(schema.properties.sessionID.type, "string");
|
||||
assert.equal(
|
||||
String(schema.properties.sessionID.description || "").includes(OMISSION_MARKER),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("characterization: required string stays non-nullable; unmarked required null is kept", () => {
|
||||
const requiredOnly = injectOptionalStringOmissionSentinel({
|
||||
type: "object",
|
||||
properties: { sessionID: { type: "string" } },
|
||||
required: ["sessionID"],
|
||||
});
|
||||
assert.equal(requiredOnly.properties.sessionID.type, "string");
|
||||
|
||||
const requiredNull = stripEmptyOptionalToolArgs(
|
||||
{ sessionID: null, agent: "explore" },
|
||||
"subagent",
|
||||
{
|
||||
type: "object",
|
||||
properties: { sessionID: { type: "string" }, agent: { type: "string" } },
|
||||
required: ["sessionID", "agent"],
|
||||
}
|
||||
);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(requiredNull, "sessionID"), true);
|
||||
assert.equal(requiredNull.sessionID, null);
|
||||
});
|
||||
|
||||
test("characterization: optional string with default stays unsentinelled through translateRequest", () => {
|
||||
const body = {
|
||||
model: "gpt-5.1-codex",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "subagent",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
agent: { type: "string" },
|
||||
sessionID: { type: "string", default: "" },
|
||||
},
|
||||
required: ["agent"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const toResponses = translateRequest(
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
"gpt-5.1-codex",
|
||||
structuredClone(body)
|
||||
);
|
||||
const params = toolParameters(findTool(toResponses.tools, "subagent"));
|
||||
assert.equal(params.properties.sessionID.type, "string");
|
||||
assert.equal(
|
||||
String(params.properties.sessionID.description || "").includes(OMISSION_MARKER),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("characterization: optional unmarked null is already stripped; real IDs are kept", () => {
|
||||
const optionalSchema = structuredClone(OPENCODE_SUBAGENT_SCHEMA);
|
||||
const stripped = stripEmptyOptionalToolArgs(
|
||||
{
|
||||
agent: "explore",
|
||||
description: "spawn",
|
||||
prompt: "do work",
|
||||
sessionID: null,
|
||||
},
|
||||
"subagent",
|
||||
optionalSchema
|
||||
);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(stripped, "sessionID"), false);
|
||||
|
||||
const kept = stripEmptyOptionalToolArgs(
|
||||
{
|
||||
agent: "explore",
|
||||
description: "continue",
|
||||
prompt: "do work",
|
||||
sessionID: "ses_valid_child",
|
||||
},
|
||||
"subagent",
|
||||
optionalSchema
|
||||
);
|
||||
assert.equal(kept.sessionID, "ses_valid_child");
|
||||
});
|
||||
|
||||
test("RED: strictified required sessionID with OmniRoute marker still drops null", () => {
|
||||
const strictified = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
agent: { type: "string" },
|
||||
description: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
sessionID: {
|
||||
type: ["string", "null"],
|
||||
description: `Continue a specific previous subagent conversation (${OMISSION_MARKER})`,
|
||||
},
|
||||
background: { type: "boolean" },
|
||||
},
|
||||
required: ["agent", "description", "prompt", "sessionID", "background"],
|
||||
};
|
||||
const stripped = stripEmptyOptionalToolArgs(
|
||||
{
|
||||
agent: "explore",
|
||||
description: "spawn",
|
||||
prompt: "do work",
|
||||
sessionID: null,
|
||||
},
|
||||
"subagent",
|
||||
strictified
|
||||
);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(stripped, "sessionID"), false);
|
||||
assert.equal(stripped.agent, "explore");
|
||||
});
|
||||
|
||||
test("characterization: empty sessionID is stripped; nested optional strings are not widened", () => {
|
||||
const emptyStripped = stripEmptyOptionalToolArgs(
|
||||
{
|
||||
agent: "explore",
|
||||
description: "spawn",
|
||||
prompt: "do work",
|
||||
sessionID: "",
|
||||
},
|
||||
"subagent",
|
||||
OPENCODE_SUBAGENT_SCHEMA
|
||||
);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(emptyStripped, "sessionID"), false);
|
||||
|
||||
const nested = injectOptionalStringOmissionSentinel({
|
||||
type: "object",
|
||||
properties: {
|
||||
items: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { sessionID: { type: "string" } },
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
wrapper: {
|
||||
anyOf: [{ type: "object", properties: { sessionID: { type: "string" } } }],
|
||||
},
|
||||
$defs: {
|
||||
child: { type: "object", properties: { sessionID: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
});
|
||||
assert.equal(nested.properties.items.items.properties.sessionID.type, "string");
|
||||
assert.equal(nested.properties.wrapper.anyOf[0].properties.sessionID.type, "string");
|
||||
assert.equal(nested.properties.$defs.child.properties.sessionID.type, "string");
|
||||
|
||||
const mixedUnion = injectOptionalStringOmissionSentinel({
|
||||
type: "object",
|
||||
properties: { value: { type: ["string", "number"] } },
|
||||
required: [],
|
||||
});
|
||||
assert.deepEqual(mixedUnion.properties.value.type, ["string", "number"]);
|
||||
});
|
||||
|
||||
test("characterization: string omission injection is idempotent", () => {
|
||||
const once = injectOptionalStringOmissionSentinel(structuredClone(OPENCODE_SUBAGENT_SCHEMA));
|
||||
const twice = injectOptionalStringOmissionSentinel(once);
|
||||
assertOmissionSentinel(sessionIdSchema(twice));
|
||||
assert.equal(twice.properties.sessionID.description.split(OMISSION_MARKER).length - 1, 1);
|
||||
const toolsOnce = injectOptionalStringOmissionForTools([
|
||||
structuredClone(SUBAGENT_TOOL_RESPONSES),
|
||||
]);
|
||||
const toolsTwice = injectOptionalStringOmissionForTools(toolsOnce);
|
||||
assertOmissionSentinel(toolParameters(toolsTwice[0]).properties.sessionID);
|
||||
});
|
||||
|
||||
test("characterization: fragmented deltas + output_item.done emit cleaned lowercase subagent args", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
agent: { type: "string" },
|
||||
description: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
sessionID: {
|
||||
type: ["string", "null"],
|
||||
description: `Continue a specific previous subagent conversation (${OMISSION_MARKER})`,
|
||||
},
|
||||
},
|
||||
required: ["agent", "description", "prompt"],
|
||||
};
|
||||
const state = { toolSchemas: new Map([["subagent", schema]]) };
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_1", name: "subagent" },
|
||||
},
|
||||
state
|
||||
);
|
||||
const raw = JSON.stringify({
|
||||
agent: "explore",
|
||||
description: "spawn",
|
||||
prompt: "do work",
|
||||
sessionID: null,
|
||||
});
|
||||
const firstDelta = openaiResponsesToOpenAIResponse(
|
||||
{ type: "response.function_call_arguments.delta", delta: raw.slice(0, 40) },
|
||||
state
|
||||
);
|
||||
const secondDelta = openaiResponsesToOpenAIResponse(
|
||||
{ type: "response.function_call_arguments.delta", delta: raw.slice(40) },
|
||||
state
|
||||
);
|
||||
const done = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", call_id: "call_1", name: "subagent", arguments: raw },
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.equal(firstDelta, null);
|
||||
assert.equal(secondDelta, null);
|
||||
const args = JSON.parse(done.choices[0].delta.tool_calls[0].function.arguments);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(args, "sessionID"), false);
|
||||
assert.equal(args.agent, "explore");
|
||||
assert.equal(args.prompt, "do work");
|
||||
});
|
||||
|
||||
test("RED: incomplete-stream flush emits cleaned lowercase subagent arguments", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
agent: { type: "string" },
|
||||
description: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
sessionID: {
|
||||
type: ["string", "null"],
|
||||
description: `Continue a specific previous subagent conversation (${OMISSION_MARKER})`,
|
||||
},
|
||||
},
|
||||
required: ["agent", "description", "prompt"],
|
||||
};
|
||||
const state = { toolSchemas: new Map([["subagent", schema]]) };
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_1", name: "subagent" },
|
||||
},
|
||||
state
|
||||
);
|
||||
const raw = JSON.stringify({
|
||||
agent: "explore",
|
||||
description: "spawn",
|
||||
prompt: "do work",
|
||||
sessionID: null,
|
||||
});
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{ type: "response.function_call_arguments.delta", delta: raw },
|
||||
state
|
||||
);
|
||||
const flushed = openaiResponsesToOpenAIResponse(null, state);
|
||||
const { parsed, finishReason } = collectArgs(flushed);
|
||||
assert.ok(parsed);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(parsed, "sessionID"), false);
|
||||
assert.equal(parsed.agent, "explore");
|
||||
assert.equal(finishReason, "tool_calls");
|
||||
});
|
||||
|
||||
test("RED: non-streaming Responses translation drops sessionID null when given the schema", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
agent: { type: "string" },
|
||||
description: { type: "string" },
|
||||
prompt: { type: "string" },
|
||||
sessionID: {
|
||||
type: ["string", "null"],
|
||||
description: `Continue a specific previous subagent conversation (${OMISSION_MARKER})`,
|
||||
},
|
||||
},
|
||||
required: ["agent", "description", "prompt", "sessionID"],
|
||||
};
|
||||
const responseBody = {
|
||||
id: "resp_1",
|
||||
object: "response",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_1",
|
||||
name: "subagent",
|
||||
arguments: JSON.stringify({
|
||||
agent: "explore",
|
||||
description: "spawn",
|
||||
prompt: "do work",
|
||||
sessionID: null,
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
const translated = translateNonStreamingResponse(
|
||||
responseBody,
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI,
|
||||
null,
|
||||
new Map([["subagent", schema]])
|
||||
);
|
||||
const args = JSON.parse(translated.choices[0].message.tool_calls[0].function.arguments);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(args, "sessionID"), false);
|
||||
assert.equal(args.agent, "explore");
|
||||
});
|
||||
|
||||
test("characterization: non-streaming keeps a real sessionID and legacy empty cleanup without schema", () => {
|
||||
const withId = translateNonStreamingResponse(
|
||||
{
|
||||
id: "resp_2",
|
||||
object: "response",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_2",
|
||||
name: "subagent",
|
||||
arguments: JSON.stringify({
|
||||
agent: "explore",
|
||||
description: "continue",
|
||||
prompt: "do work",
|
||||
sessionID: "ses_valid_child",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI
|
||||
);
|
||||
const kept = JSON.parse(withId.choices[0].message.tool_calls[0].function.arguments);
|
||||
assert.equal(kept.sessionID, "ses_valid_child");
|
||||
|
||||
const noSchema = translateNonStreamingResponse(
|
||||
{
|
||||
id: "resp_3",
|
||||
object: "response",
|
||||
output: [
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_3",
|
||||
name: "other",
|
||||
arguments: { note: "", tags: [] },
|
||||
},
|
||||
],
|
||||
},
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI
|
||||
);
|
||||
const cleaned = JSON.parse(noSchema.choices[0].message.tool_calls[0].function.arguments);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(cleaned, "note"), false);
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(cleaned, "tags"), false);
|
||||
});
|
||||
|
||||
test("characterization: extractToolSchemaMap still keys OpenCode subagent by lowercase name", () => {
|
||||
const map = extractToolSchemaMap({ tools: [structuredClone(SUBAGENT_TOOL_RESPONSES)] });
|
||||
assert.ok(map?.has("subagent"));
|
||||
assert.equal(map.get("subagent").properties.sessionID.type, "string");
|
||||
});
|
||||
@@ -34,6 +34,21 @@ test("every x-loopback-only path matches a LOCAL_ONLY prefix in routeGuard.ts",
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /api/openapi/spec documents its conditional management auth contract", () => {
|
||||
const operation = paths["/api/openapi/spec"]?.get;
|
||||
|
||||
assert.deepEqual(operation?.security, [{ ManagementSessionAuth: [] }]);
|
||||
assert.match(operation?.description ?? "", /When `requireLogin` is enabled/);
|
||||
assert.equal(
|
||||
operation?.responses?.["401"]?.$ref,
|
||||
"#/components/responses/ManagementAuthenticationRequired"
|
||||
);
|
||||
assert.equal(
|
||||
operation?.responses?.["403"]?.$ref,
|
||||
"#/components/responses/ManagementInvalidToken"
|
||||
);
|
||||
});
|
||||
|
||||
test("every x-always-protected path matches ALWAYS_PROTECTED_API_PATHS in routeGuard.ts", () => {
|
||||
for (const [pathStr, methods] of Object.entries(paths)) {
|
||||
if (!methods || typeof methods !== "object") continue;
|
||||
|
||||
139
tests/unit/provider-patch-ratelimit-protection-11278.test.ts
Normal file
139
tests/unit/provider-patch-ratelimit-protection-11278.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// Regression guard for #11278 — PATCH/PUT /api/providers/[id] silently enabled
|
||||
// runtime rate-limit protection (Bottleneck queuing) for ANY connection whose
|
||||
// request body included the `rateLimitOverrides` key, even `null`, regardless
|
||||
// of whether `rate_limit_protection` was actually persisted as on for that
|
||||
// connection in the DB.
|
||||
//
|
||||
// Root cause: src/app/api/providers/[id]/route.ts unconditionally called
|
||||
// enableRateLimitProtection(id) whenever `rateLimitOverrides !== undefined`
|
||||
// in the validated body. `EditConnectionModal.tsx` sends `rateLimitOverrides`
|
||||
// on every save regardless of whether the operator touched that section, so
|
||||
// saving ANY connection silently started queuing its requests through
|
||||
// Bottleneck — with the DB (`rate_limit_protection` column) and the dashboard
|
||||
// toggle both still showing the feature as off.
|
||||
//
|
||||
// Fix: only (re)enable the in-memory limiter when the persisted connection
|
||||
// (`updated.rateLimitProtection`, mapped from the DB row) is actually `true`;
|
||||
// otherwise explicitly disable it so runtime state can't drift ahead of the
|
||||
// DB. `rateLimitProtection` is never itself part of updateProviderConnectionSchema,
|
||||
// so this route can only read it from the persisted row — never set it.
|
||||
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";
|
||||
import { makeManagementSessionRequest } from "../helpers/managementSession.ts";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-11278-ratelimit-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.APP_LOG_TO_FILE = "false";
|
||||
process.env.JWT_SECRET = "test-jwt-secret-11278-ratelimit";
|
||||
process.env.INITIAL_PASSWORD = "admin-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const { createProviderConnection, getProviderConnectionById } =
|
||||
await import("../../src/lib/db/providers.ts");
|
||||
const providerByIdRoute = await import("../../src/app/api/providers/[id]/route.ts");
|
||||
const rateLimitManager = await import("../../open-sse/services/rateLimitManager.ts");
|
||||
|
||||
function resetDb() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetDb();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function createConnection(rateLimitProtection: boolean) {
|
||||
return createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "OpenAI key",
|
||||
apiKey: "sk-test-key-value",
|
||||
priority: 1,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
rateLimitProtection,
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
"PUT /api/providers/[id] does NOT enable rate-limit protection just because " +
|
||||
"rateLimitOverrides is present, when protection is off in the DB (#11278 RED->GREEN)",
|
||||
async () => {
|
||||
const connection = (await createConnection(false)) as Record<string, unknown>;
|
||||
assert.equal(connection.rateLimitProtection, false);
|
||||
assert.equal(rateLimitManager.isRateLimitEnabled(connection.id as string), false);
|
||||
|
||||
// Mirrors EditConnectionModal.tsx's handleSubmit(): it always sends
|
||||
// `rateLimitOverrides` on every save, even when the operator never
|
||||
// touched that section of the form.
|
||||
const payload = {
|
||||
name: connection.name,
|
||||
priority: connection.priority,
|
||||
rateLimitOverrides: null,
|
||||
};
|
||||
|
||||
const request = await makeManagementSessionRequest(
|
||||
`http://localhost/api/providers/${connection.id}`,
|
||||
{ method: "PUT", body: payload }
|
||||
);
|
||||
const response = await providerByIdRoute.PUT(request, {
|
||||
params: Promise.resolve({ id: connection.id as string }),
|
||||
});
|
||||
assert.equal(response.status, 200, `expected the save to succeed, got ${response.status}`);
|
||||
|
||||
const persisted = (await getProviderConnectionById(connection.id as string)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(
|
||||
persisted.rateLimitProtection,
|
||||
false,
|
||||
"DB row must still show protection off — this route never sets rateLimitProtection"
|
||||
);
|
||||
assert.equal(
|
||||
rateLimitManager.isRateLimitEnabled(connection.id as string),
|
||||
false,
|
||||
"in-memory limiter must not silently diverge from the persisted DB state"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"PUT /api/providers/[id] keeps rate-limit protection ENABLED when it is " +
|
||||
"actually persisted as on in the DB",
|
||||
async () => {
|
||||
const connection = (await createConnection(true)) as Record<string, unknown>;
|
||||
assert.equal(connection.rateLimitProtection, true);
|
||||
|
||||
const payload = {
|
||||
name: connection.name,
|
||||
priority: connection.priority,
|
||||
rateLimitOverrides: { rpm: 30 },
|
||||
};
|
||||
|
||||
const request = await makeManagementSessionRequest(
|
||||
`http://localhost/api/providers/${connection.id}`,
|
||||
{ method: "PUT", body: payload }
|
||||
);
|
||||
const response = await providerByIdRoute.PUT(request, {
|
||||
params: Promise.resolve({ id: connection.id as string }),
|
||||
});
|
||||
assert.equal(response.status, 200, `expected the save to succeed, got ${response.status}`);
|
||||
|
||||
const persisted = (await getProviderConnectionById(connection.id as string)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(persisted.rateLimitProtection, true);
|
||||
assert.equal(rateLimitManager.isRateLimitEnabled(connection.id as string), true);
|
||||
}
|
||||
);
|
||||
Reference in New Issue
Block a user