mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 08:02:14 +03:00
Compare commits
9 Commits
fix/11289-
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9deb6bf995 | ||
|
|
d861b76b5f | ||
|
|
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
|
||||
```
|
||||
|
||||
@@ -439,13 +439,22 @@ type ProviderConnectionLike = {
|
||||
* whose stored `providerSpecificData.profileArn` matches the given ARN.
|
||||
* Returns null when profileArn is undefined/null or no match is found.
|
||||
*
|
||||
* #10815 hardened `findKiroConnectionByIdentity` to require an account-level
|
||||
* identifier (email or clientId) alongside a matching profileArn before
|
||||
* trusting the match — distinct Builder ID accounts (Google/GitHub social
|
||||
* login) can share the same CodeWhisperer profile ARN, and matching on ARN
|
||||
* alone let a second social login silently overwrite the first connection.
|
||||
* `email`/`clientId` here let a caller supply that account identifier; the
|
||||
* real `saveAndRespond()` call sites already do (see below).
|
||||
*
|
||||
* Exported for unit tests (#3615).
|
||||
*/
|
||||
export function findKiroConnectionByProfileArn(
|
||||
connections: ProviderConnectionLike[],
|
||||
profileArn: string | undefined
|
||||
profileArn: string | undefined,
|
||||
accountIdentity?: { email?: string | null; clientId?: string | null }
|
||||
): ProviderConnectionLike | null {
|
||||
return findKiroConnectionByIdentity(connections, { profileArn });
|
||||
return findKiroConnectionByIdentity(connections, { profileArn, ...accountIdentity });
|
||||
}
|
||||
|
||||
// ── Save to OmniRoute DB ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1267,6 +1267,7 @@
|
||||
"agentBridgeSubtitle": "Interceptar tráfego de agentes IDE",
|
||||
"trafficInspector": "Inspector de Tráfego",
|
||||
"trafficInspectorSubtitle": "Monitorar chamadas LLM + debugar tráfego HTTPS",
|
||||
"trafficInspectorPurpose": "Veja exatamente o que sua aplicação envia e recebe dos provedores de IA. Funciona com qualquer cliente compatível com OpenAI.",
|
||||
"cliCode": "CLI Code's",
|
||||
"cliCodeSubtitle": "Ferramentas de código que apontam para o OmniRoute",
|
||||
"cliAgents": "CLI Agents",
|
||||
@@ -1868,7 +1869,16 @@
|
||||
"directDownloadHint": "Ou baixe o formato do instalador respectivo diretamente:",
|
||||
"releaseNotes": "Notas de Lançamento",
|
||||
"readMore": "Leia Mais",
|
||||
"noAuthLabel": "Sem Autenticação"
|
||||
"noAuthLabel": "Sem Autenticação",
|
||||
"readinessEyebrow": "Prepare-se para rotear",
|
||||
"readinessTitle": "Envie sua primeira requisição",
|
||||
"readinessSubtitle": "Quatro pequenos passos. O OmniRoute verifica a prontidão conforme você avança.",
|
||||
"readinessStep1": "Conecte um provedor",
|
||||
"readinessStep2": "Configure a autenticação do endpoint",
|
||||
"readinessStep3": "Copie seu endpoint",
|
||||
"readinessStep4": "Envie uma requisição de teste",
|
||||
"readinessContinue": "Continuar configuração",
|
||||
"readinessDismiss": "Dispensar por agora"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Análises",
|
||||
@@ -6700,6 +6710,18 @@
|
||||
"sidebarVisibility": "Hide sidebar items",
|
||||
"sidebarVisibilityDesc": "Hide any sidebar navigation entry to reduce visual clutter.",
|
||||
"sidebarVisibilityHint": "Any sidebar section is hidden automatically when a...",
|
||||
"presetAll": "Tudo",
|
||||
"presetAllDesc": "Mostrar tudo",
|
||||
"presetEssentials": "Essenciais",
|
||||
"presetEssentialsDesc": "Caminho para iniciantes - Ferramentas avançadas continuam pesquisáveis",
|
||||
"presetMinimal": "Mínimo",
|
||||
"presetMinimalDesc": "Apenas páginas principais",
|
||||
"presetDeveloper": "Desenvolvedor",
|
||||
"presetDeveloperDesc": "Ferramentas de dev & proxy",
|
||||
"presetAdmin": "Admin",
|
||||
"presetAdminDesc": "Monitoramento & auditoria",
|
||||
"settingsSidebarTitle": "Personalização da Barra Lateral",
|
||||
"settingsSidebarDesc": "Escolha quais itens da barra lateral exibir. Essenciais mantém as ferramentas avançadas pesquisáveis.",
|
||||
"hideHealthLogs": "Ocultar Logs de Health Check",
|
||||
"hideHealthLogsDesc": "Quando ATIVADO, suprime mensagens [HealthCheck] no console do servidor",
|
||||
"themeAccent": "Cor do tema",
|
||||
|
||||
@@ -1267,6 +1267,7 @@
|
||||
"agentBridgeSubtitle": "Chặn lưu lượng agent IDE",
|
||||
"trafficInspector": "Traffic Inspector",
|
||||
"trafficInspectorSubtitle": "Giám sát lệnh gọi LLM + gỡ lỗi mọi lưu lượng HTTPS",
|
||||
"trafficInspectorPurpose": "Xem chính xác những gì ứng dụng của bạn gửi đến và nhận từ các nhà cung cấp AI. Hoạt động với bất kỳ ứng dụng khách nào tương thích với OpenAI.",
|
||||
"cliCode": "CLI Code",
|
||||
"cliCodeSubtitle": "Các công cụ lập trình trỏ đến OmniRoute",
|
||||
"cliAgents": "CLI Agents",
|
||||
@@ -1868,7 +1869,16 @@
|
||||
"directDownloadHint": "Hoặc tải trực tiếp định dạng trình cài đặt phù hợp:",
|
||||
"releaseNotes": "Ghi chú phát hành",
|
||||
"readMore": "Đọc thêm",
|
||||
"noAuthLabel": "Không xác thực"
|
||||
"noAuthLabel": "Không xác thực",
|
||||
"readinessEyebrow": "Chuẩn bị định tuyến",
|
||||
"readinessTitle": "Gửi yêu cầu đầu tiên của bạn",
|
||||
"readinessSubtitle": "Bốn bước nhỏ. OmniRoute kiểm tra mức độ sẵn sàng khi bạn thực hiện.",
|
||||
"readinessStep1": "Kết nối một nhà cung cấp",
|
||||
"readinessStep2": "Định cấu hình xác thực endpoint",
|
||||
"readinessStep3": "Sao chép endpoint của bạn",
|
||||
"readinessStep4": "Gửi một yêu cầu thử nghiệm",
|
||||
"readinessContinue": "Tiếp tục thiết lập",
|
||||
"readinessDismiss": "Bỏ qua lúc này"
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Phân tích",
|
||||
@@ -6700,6 +6710,18 @@
|
||||
"sidebarVisibility": "Ẩn các mục trên thanh bên",
|
||||
"sidebarVisibilityDesc": "Ẩn bất kỳ mục điều hướng nào trên thanh bên để giảm bớt sự lộn xộn về mặt trực quan mà không vô hiệu hóa bất kỳ tính năng nào",
|
||||
"sidebarVisibilityHint": "Bất kỳ phần nào trên thanh bên sẽ tự động bị ẩn khi tất cả các mục bên trong nó đều bị ẩn",
|
||||
"presetAll": "Tất cả",
|
||||
"presetAllDesc": "Hiển thị mọi thứ",
|
||||
"presetEssentials": "Thiết yếu",
|
||||
"presetEssentialsDesc": "Lộ trình cho người mới bắt đầu - Công cụ nâng cao vẫn có thể tìm kiếm",
|
||||
"presetMinimal": "Tối giản",
|
||||
"presetMinimalDesc": "Chỉ các trang cốt lõi",
|
||||
"presetDeveloper": "Nhà phát triển",
|
||||
"presetDeveloperDesc": "Công cụ dev & proxy",
|
||||
"presetAdmin": "Quản trị",
|
||||
"presetAdminDesc": "Giám sát & kiểm toán",
|
||||
"settingsSidebarTitle": "Tùy chỉnh thanh bên",
|
||||
"settingsSidebarDesc": "Chọn các mục trên thanh bên sẽ hiển thị. Thiết yếu giữ cho các công cụ nâng cao vẫn có thể tìm kiếm.",
|
||||
"hideHealthLogs": "Ẩn nhật ký kiểm tra sức khỏe",
|
||||
"hideHealthLogsDesc": "Khi BẬT, sẽ chặn các thông báo [HealthCheck] trong bảng điều khiển máy chủ",
|
||||
"themeAccent": "Màu chủ đề",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,9 +2,15 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Repro for #6571 — REST-fallback path of `omniroute compression` (hit only when
|
||||
// /api/mcp/tools/call is not mounted, i.e. mcpCall()'s 404/501 branch) uses the
|
||||
// the MCP surface is not mounted, i.e. mcpCall()'s 404/501 branch) uses the
|
||||
// nonexistent `engine` field instead of the canonical `defaultMode` field, and
|
||||
// the table renderer prints "[object Object]" for nested object cells.
|
||||
//
|
||||
// #10960 moved the MCP transport from the never-mounted `/api/mcp/tools/call`
|
||||
// to the real Streamable HTTP endpoint `/api/mcp/stream` (mcpClient.mjs ->
|
||||
// callMcpEndpoint()). The REST-fallback trigger in these mocks must match
|
||||
// that endpoint, not the retired one, or mcpCallTool() throws on an
|
||||
// unmocked fetch instead of exercising the fallback path this test targets.
|
||||
|
||||
type MockResponse = Pick<Response, "ok" | "status" | "headers" | "json" | "text">;
|
||||
|
||||
@@ -45,7 +51,7 @@ test("restCompressionStatus (via runCompressionStatus REST fallback) should surf
|
||||
const origFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (url: string | URL | Request) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/mcp/tools/call")) return makeResp({ error: "not mounted" }, 404);
|
||||
if (u.includes("/api/mcp/stream")) return makeResp({ error: "not mounted" }, 404);
|
||||
if (u.includes("/api/settings/compression")) {
|
||||
// Canonical server payload — NOTE: field is `defaultMode`, there is no `engine` key.
|
||||
// src/lib/db/compression.ts COMPRESSION_MODES / GET route just returns getCompressionSettings().
|
||||
@@ -85,7 +91,7 @@ test("restSetEngine (via runCompressionEngineSet REST fallback) should PUT `defa
|
||||
const putBodies: Record<string, unknown>[] = [];
|
||||
globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/api/mcp/tools/call")) return makeResp({ error: "not mounted" }, 404);
|
||||
if (u.includes("/api/mcp/stream")) return makeResp({ error: "not mounted" }, 404);
|
||||
if (u.includes("/api/settings/compression") && init?.method === "PUT") {
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : {};
|
||||
putBodies.push(body);
|
||||
|
||||
@@ -113,12 +113,18 @@ test("derived name is never empty or null", () => {
|
||||
|
||||
const FAKE_PROFILE_ARN = "arn:aws:iam::123456789012:user/sso-user";
|
||||
|
||||
const FAKE_CLIENT_ID = "client-abc";
|
||||
|
||||
const fakeConnectionWithArn = {
|
||||
id: "conn-abc",
|
||||
provider: "kiro",
|
||||
authType: "oauth",
|
||||
email: null,
|
||||
providerSpecificData: { profileArn: FAKE_PROFILE_ARN, region: "us-east-1" },
|
||||
providerSpecificData: {
|
||||
profileArn: FAKE_PROFILE_ARN,
|
||||
region: "us-east-1",
|
||||
clientId: FAKE_CLIENT_ID,
|
||||
},
|
||||
};
|
||||
|
||||
const fakeConnectionNoArn = {
|
||||
@@ -129,13 +135,29 @@ const fakeConnectionNoArn = {
|
||||
providerSpecificData: { region: "us-east-1" },
|
||||
};
|
||||
|
||||
test("findKiroConnectionByProfileArn returns the matching connection", async () => {
|
||||
// The function should scan existing kiro connections and match by profileArn.
|
||||
test("findKiroConnectionByProfileArn returns the matching connection when an account identifier agrees", async () => {
|
||||
// #10815 — matching on profileArn alone is unsafe (distinct Builder ID
|
||||
// accounts can share a profile ARN), so the caller must also supply an
|
||||
// account-level identifier (email or clientId) that does not contradict
|
||||
// the stored connection, exactly like saveAndRespond()'s real call sites do.
|
||||
const result = await findKiroConnectionByProfileArn(
|
||||
[fakeConnectionWithArn, fakeConnectionNoArn],
|
||||
FAKE_PROFILE_ARN,
|
||||
{ clientId: FAKE_CLIENT_ID }
|
||||
);
|
||||
assert.deepEqual(result, fakeConnectionWithArn);
|
||||
});
|
||||
|
||||
test("findKiroConnectionByProfileArn returns null for a profileArn-only match with no account identifier (#10815)", async () => {
|
||||
// Guards the #10815 fix: two different Builder ID accounts (Google/GitHub
|
||||
// social login) can share the same CodeWhisperer profile ARN, so trusting
|
||||
// an ARN match without any account identifier would let a second social
|
||||
// login silently overwrite the first connection.
|
||||
const result = await findKiroConnectionByProfileArn(
|
||||
[fakeConnectionWithArn, fakeConnectionNoArn],
|
||||
FAKE_PROFILE_ARN
|
||||
);
|
||||
assert.deepEqual(result, fakeConnectionWithArn);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("findKiroConnectionByProfileArn returns null when no match exists", async () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,7 +111,13 @@ describe("injectMemory system-must-be-first (#6135)", () => {
|
||||
|
||||
it("regression: a NON-flagged provider keeps the existing cache-safe placement", () => {
|
||||
const req = multiTurn();
|
||||
const out = injectMemory(req, [mem("dark mode")], "anthropic", { cacheSafe: true });
|
||||
// #11290/#11303 added a Claude-family-specific reroute to injectSystemFirst()
|
||||
// for the mid-array splice (a system message right after a plain-text
|
||||
// assistant turn is rejected by Claude Opus 5), so "anthropic" no longer
|
||||
// exercises the plain cache-safe splice path this test targets. Use a
|
||||
// provider outside both the strict-system-first set AND the Claude family
|
||||
// to keep testing the original (still-current) cache-safe behavior.
|
||||
const out = injectMemory(req, [mem("dark mode")], "openai", { cacheSafe: true });
|
||||
// Existing behavior: memory inserted just before the last user message (index 3).
|
||||
assert.equal(out.messages[3].role, "system");
|
||||
assert.ok(out.messages[3].content.includes("Memory context"));
|
||||
|
||||
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