mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 08:02:14 +03:00
Compare commits
4 Commits
fix/11289-
...
fix/11295-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b45e21ea5 | ||
|
|
a3c3117254 | ||
|
|
d9a883ec53 | ||
|
|
7913447bf0 |
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
|
||||
|
||||
@@ -379,6 +379,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 {
|
||||
@@ -4910,12 +4911,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 +4945,8 @@ export async function handleChatCore({
|
||||
responseBody,
|
||||
responsePayloadFormat,
|
||||
FORMATS.OPENAI,
|
||||
responseToolNameMap
|
||||
responseToolNameMap,
|
||||
responseToolSchemas
|
||||
)
|
||||
: responseBody;
|
||||
const firstChoice = cacheResponse?.choices?.[0];
|
||||
@@ -5465,7 +5469,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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -12,7 +12,21 @@
|
||||
* `sanitizeReasoningEffortForProvider` in `executors/base/reasoningEffort.ts`)
|
||||
* so the 4xx→retry round-trip is paid at most once per process per provider+model.
|
||||
*
|
||||
* `clampToLearned` implements downgrade-only clamping: greatest accepted <= demand.
|
||||
* `clampToLearned` implements nearest-tier clamping: smallest accepted >= demand,
|
||||
* falling back to the greatest accepted when demand exceeds every accepted value.
|
||||
* (#11295 — unified with the static "declared" clamp in
|
||||
* `executors/base/reasoningEffort.ts`, which already used nearest-tier semantics.
|
||||
* Before #11295, this learned clamp was downgrade-only — greatest accepted <=
|
||||
* demand — so the SAME accepted set {low,high,max} produced medium→low here but
|
||||
* medium→high via the declared path: identical inputs, opposite outputs,
|
||||
* depending only on whether the model had a static registry entry. #11274's
|
||||
* DeepSeek native mapping is the precedent for nearest-tier. This also fixes a
|
||||
* standalone bug: a request BELOW the learned floor (e.g. none/minimal on a
|
||||
* model that only ever advertised {low,high,max}) used to return null — no
|
||||
* clamp — so the too-low value passed straight through to the upstream, which
|
||||
* 400'd again on every subsequent request without ever learning a lower floor.
|
||||
* Nearest-tier naturally fixes this too: the smallest accepted value is always
|
||||
* >= any demand below the floor, so it is returned instead of null.
|
||||
*
|
||||
* In-memory only (same operator-accepted tradeoff as the thinking-budget cache):
|
||||
* restart resets, the first request after a restart may re-learn at the cost of
|
||||
@@ -132,25 +146,39 @@ export function recordLearnedReasoningEffort(
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the greatest accepted value <= effortStr (downgrade only), or null
|
||||
* if effortStr is already accepted, below the minimum, or not in ORDER.
|
||||
* Return the nearest-tier accepted value for effortStr: the smallest accepted
|
||||
* value with rank >= effortStr's rank, or — when effortStr's rank exceeds every
|
||||
* accepted value (demand above the learned ceiling) — the greatest accepted
|
||||
* value. Returns null only when effortStr is already accepted (no clamp
|
||||
* needed), empty, or not a recognized member of REASONING_EFFORT_ORDER.
|
||||
*
|
||||
* Mirrors the declared-capability clamp in `executors/base/reasoningEffort.ts`
|
||||
* (#11295): both now use nearest-tier semantics so the same accepted set
|
||||
* produces the same mapping regardless of whether the model has a static
|
||||
* registry entry or was only learned reactively from an upstream 4xx.
|
||||
*/
|
||||
export function clampToLearned(effortStr: string, accepted: Set<string>): string | null {
|
||||
if (!effortStr || accepted.has(effortStr)) return null;
|
||||
const rank = rankOf(effortStr);
|
||||
if (rank === -1) return null;
|
||||
const minRank = Math.min(...[...accepted].map((v) => rankOf(v)));
|
||||
if (rank < minRank) return null;
|
||||
let best: string | null = null;
|
||||
let bestRank = -1;
|
||||
|
||||
let nearestAbove: string | null = null;
|
||||
let nearestAboveRank = Infinity;
|
||||
let highest: string | null = null;
|
||||
let highestRank = -1;
|
||||
for (const v of accepted) {
|
||||
const r = rankOf(v);
|
||||
if (r <= rank && r > bestRank) {
|
||||
bestRank = r;
|
||||
best = v;
|
||||
if (r < 0) continue;
|
||||
if (r >= rank && r < nearestAboveRank) {
|
||||
nearestAboveRank = r;
|
||||
nearestAbove = v;
|
||||
}
|
||||
if (r > highestRank) {
|
||||
highestRank = r;
|
||||
highest = v;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
return nearestAbove ?? highest;
|
||||
}
|
||||
|
||||
// Matches prose shapes: OVH's "@ai-sdk/openai-compatible" deserializer
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
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/);
|
||||
});
|
||||
@@ -130,13 +130,18 @@ test("a later, lower accepted-list does ratchet the cap down", () => {
|
||||
assert.equal((getLearnedReasoningEffort("acme", "model-x") as unknown as Set<string>).size, 2);
|
||||
});
|
||||
|
||||
test("clampToLearned medium→low when accepted is low,high,max", async () => {
|
||||
// #11295: nearest-tier semantics (smallest accepted >= demand) — unified with
|
||||
// the declared/static clamp. Was downgrade-only (greatest accepted <= demand,
|
||||
// medium→low) before #11295.
|
||||
test("clampToLearned medium→high when accepted is low,high,max (nearest-tier, #11295)", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
assert.equal(clampToLearned("medium", new Set(["low", "high", "max"])), "low");
|
||||
assert.equal(clampToLearned("medium", new Set(["low", "high", "max"])), "high");
|
||||
});
|
||||
test("clampToLearned xhigh→high when accepted is low,high,max", async () => {
|
||||
// #11295: xhigh(rank 5) has no accepted tier >= it among {low,high,max}
|
||||
// (max=6 IS >= 5, so nearest-tier picks max) — was downgrade-only high before.
|
||||
test("clampToLearned xhigh→max when accepted is low,high,max (nearest-tier, #11295)", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
assert.equal(clampToLearned("xhigh", new Set(["low", "high", "max"])), "high");
|
||||
assert.equal(clampToLearned("xhigh", new Set(["low", "high", "max"])), "max");
|
||||
});
|
||||
test("clampToLearned ultra→max when accepted is low,high,max", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
@@ -154,17 +159,25 @@ test("clampToLearned returns null when already accepted", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
assert.equal(clampToLearned("low", new Set(["low", "high", "max"])), null);
|
||||
});
|
||||
test("clampToLearned returns null when effort < min (no upgrade)", async () => {
|
||||
// #11295: a sub-floor demand (below every accepted value) now maps to the
|
||||
// accepted floor instead of returning null. Pre-#11295 this returned null —
|
||||
// no clamp — so the too-low value passed straight through to the upstream,
|
||||
// which 400'd again on every subsequent request without ever learning a
|
||||
// lower floor.
|
||||
test("clampToLearned maps sub-floor demand to the accepted floor instead of null (#11295)", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
assert.equal(clampToLearned("low", new Set(["high", "max"])), null);
|
||||
assert.equal(clampToLearned("low", new Set(["high", "max"])), "high");
|
||||
});
|
||||
test("clampToLearned returns null for turbo (not in ORDER)", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
assert.equal(clampToLearned("turbo", new Set(["low", "high", "max"])), null);
|
||||
});
|
||||
test("clampToLearned returns null when effort is none but accepted is low,high,max", async () => {
|
||||
// #11295: none is below the learned floor {low,high,max} — nearest-tier maps
|
||||
// it to the floor (low) instead of returning null (no clamp, upstream 400s
|
||||
// again with no chance to ever learn a lower floor).
|
||||
test("clampToLearned maps none to the floor (low) when accepted is low,high,max (#11295)", async () => {
|
||||
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
|
||||
assert.equal(clampToLearned("none", new Set(["low", "high", "max"])), null);
|
||||
assert.equal(clampToLearned("none", new Set(["low", "high", "max"])), "low");
|
||||
});
|
||||
test("recordLearned stores Set and getLearned returns Set", () => {
|
||||
const s = recordLearnedReasoningEffort("acme", "m1", ["low", "high", "max"]);
|
||||
|
||||
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;
|
||||
|
||||
@@ -108,7 +108,7 @@ test("a second request for the same provider+model sends the learned value on th
|
||||
}
|
||||
});
|
||||
|
||||
test("400 please use low, high, or max clamps and retries once", async () => {
|
||||
test("400 please use low, high, or max clamps and retries once (nearest-tier: medium -> high, #11295)", async () => {
|
||||
const executor = new SimpleExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const capturedBodies: Record<string, unknown>[] = [];
|
||||
@@ -140,7 +140,10 @@ test("400 please use low, high, or max clamps and retries once", async () => {
|
||||
});
|
||||
assert.equal(capturedBodies.length, 2);
|
||||
assert.equal(capturedBodies[0].reasoning_effort, "medium");
|
||||
assert.equal(capturedBodies[1].reasoning_effort, "low");
|
||||
// #11295: nearest-tier — smallest accepted >= demand — maps medium(3) to
|
||||
// high(4), the smallest accepted rank at or above it (was "low" under the
|
||||
// old downgrade-only direction).
|
||||
assert.equal(capturedBodies[1].reasoning_effort, "high");
|
||||
const learned = getLearnedReasoningEffort("openai-compatible-chat-eaff6869", "x-preview-f-free") as unknown as Set<string>;
|
||||
assert.ok(learned instanceof Set);
|
||||
assert.ok(learned.has("low"));
|
||||
@@ -190,7 +193,7 @@ test("400 please use low, medium with ultra retries to medium", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("no-op clamp does not retry: learned {high,max} with low request stays single-fetch", async () => {
|
||||
test("sub-floor clamp now retries: learned {high,max} with low request clamps up to high (#11295)", async () => {
|
||||
const executor = new SimpleExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const capturedBodies: Record<string, unknown>[] = [];
|
||||
@@ -214,17 +217,20 @@ test("no-op clamp does not retry: learned {high,max} with low request stays sing
|
||||
};
|
||||
|
||||
try {
|
||||
// low is below the learned minimum {high,max}: downgrade-only passthrough,
|
||||
// sanitizer leaves the body unchanged -> no identical-body retry.
|
||||
// #11295: low is below the learned minimum {high,max}. Pre-#11295 this was
|
||||
// a downgrade-only passthrough (no clamp, no retry, upstream stayed 400
|
||||
// forever). Nearest-tier now clamps up to the accepted floor (high) and
|
||||
// retries once, succeeding.
|
||||
const result = await executor.execute({
|
||||
model: "x-preview-f-free-3",
|
||||
body: { reasoning_effort: "low" },
|
||||
stream: false,
|
||||
credentials: {},
|
||||
});
|
||||
assert.equal(capturedBodies.length, 1);
|
||||
assert.equal(capturedBodies.length, 2);
|
||||
assert.equal(capturedBodies[0].reasoning_effort, "low");
|
||||
assert.equal(result.response.status, 400);
|
||||
assert.equal(capturedBodies[1].reasoning_effort, "high");
|
||||
assert.equal(result.response.status, 200);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// #11295 — the learned clamp (reactive, from upstream 4xx) and the declared
|
||||
// clamp (static registry `supportedThinkingEfforts`) used to disagree on
|
||||
// direction for the identical accepted set {low,high,max}: the learned path
|
||||
// was downgrade-only (medium -> low) while the declared path was already
|
||||
// nearest-tier (medium -> high). Same inputs, opposite outputs, depending only
|
||||
// on whether the model happened to have a static registry entry. This test
|
||||
// proves the two paths now agree, and that a request below the learned floor
|
||||
// (previously silently passed through unmapped, returning null from
|
||||
// clampToLearned) is now mapped up to the nearest accepted tier instead.
|
||||
import { test, after, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { clampToLearned } from "../../open-sse/services/learnedReasoningEffortCaps.ts";
|
||||
import { sanitizeReasoningEffortForProvider } from "../../open-sse/executors/base/reasoningEffort.ts";
|
||||
import {
|
||||
recordLearnedReasoningEffort,
|
||||
__test_resetLearnedReasoningEffortCaps,
|
||||
} from "../../open-sse/services/learnedReasoningEffortCaps.ts";
|
||||
|
||||
beforeEach(() => {
|
||||
__test_resetLearnedReasoningEffortCaps();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
__test_resetLearnedReasoningEffortCaps();
|
||||
});
|
||||
|
||||
test("clampToLearned: nearest-tier medium -> high when accepted is {low,high,max} (was low pre-#11295)", () => {
|
||||
assert.equal(clampToLearned("medium", new Set(["low", "high", "max"])), "high");
|
||||
});
|
||||
|
||||
test("sanitizeReasoningEffortForProvider maps medium identically for a LEARNED-only model and a DECLARED model with the same {low,high,max} accepted set", () => {
|
||||
// Learned side: a custom OpenAI-compatible connection that has no static
|
||||
// registry entry — the only source of truth is the reactively-learned set.
|
||||
recordLearnedReasoningEffort("acme-oai-compatible", "custom-reasoner", [
|
||||
"low",
|
||||
"high",
|
||||
"max",
|
||||
]);
|
||||
const learnedResult = sanitizeReasoningEffortForProvider(
|
||||
{ reasoning_effort: "medium" },
|
||||
"acme-oai-compatible",
|
||||
"custom-reasoner"
|
||||
) as Record<string, unknown>;
|
||||
|
||||
// Declared side: opencode-go/ox-alpha-free, whose registry entry declares
|
||||
// supportedThinkingEfforts: ["low", "high", "max"] (see reasoningEffort.ts
|
||||
// comment referencing the Console Go 400 case).
|
||||
const declaredResult = sanitizeReasoningEffortForProvider(
|
||||
{ reasoning_effort: "medium" },
|
||||
"opencode-go",
|
||||
"ox-alpha-free"
|
||||
) as Record<string, unknown>;
|
||||
|
||||
assert.equal(learnedResult.reasoning_effort, "high");
|
||||
assert.equal(declaredResult.reasoning_effort, "high");
|
||||
assert.equal(learnedResult.reasoning_effort, declaredResult.reasoning_effort);
|
||||
});
|
||||
|
||||
test("sub-floor request (none) on a learned-only model with floor {low,high,max} maps to low, not a pass-through null-clamp", () => {
|
||||
recordLearnedReasoningEffort("acme-oai-compatible", "custom-reasoner-2", [
|
||||
"low",
|
||||
"high",
|
||||
"max",
|
||||
]);
|
||||
const result = sanitizeReasoningEffortForProvider(
|
||||
{ reasoning_effort: "none" },
|
||||
"acme-oai-compatible",
|
||||
"custom-reasoner-2"
|
||||
) as Record<string, unknown>;
|
||||
assert.equal(result.reasoning_effort, "low");
|
||||
});
|
||||
|
||||
test("clampToLearned: sub-floor demand (none) below accepted {low,high,max} maps to the accepted floor (low), not null", () => {
|
||||
assert.equal(clampToLearned("none", new Set(["low", "high", "max"])), "low");
|
||||
});
|
||||
|
||||
test("clampToLearned: sub-floor demand (low) below accepted {high,max} maps to the accepted floor (high), not null", () => {
|
||||
assert.equal(clampToLearned("low", new Set(["high", "max"])), "high");
|
||||
});
|
||||
@@ -90,23 +90,25 @@ test("deepseek's non-ordinal max<->xhigh translation is untouched by the learned
|
||||
assert.equal(result.reasoning_effort, "max");
|
||||
});
|
||||
|
||||
test("proactive clamp: medium→low for learned {low,high,max}", () => {
|
||||
// #11295: nearest-tier — smallest accepted >= demand — replaces the old
|
||||
// downgrade-only (greatest accepted <= demand) direction.
|
||||
test("proactive clamp: medium→high for learned {low,high,max} (nearest-tier, #11295)", () => {
|
||||
recordLearnedReasoningEffort("opencode-zen-direct", "x-preview-f-free", ["low", "high", "max"]);
|
||||
const out = sanitizeReasoningEffortForProvider(
|
||||
{ reasoning_effort: "medium", model: "x-preview-f-free" },
|
||||
"opencode-zen-direct",
|
||||
"x-preview-f-free"
|
||||
) as { reasoning_effort: string };
|
||||
assert.equal(out.reasoning_effort, "low");
|
||||
assert.equal(out.reasoning_effort, "high");
|
||||
});
|
||||
test("proactive clamp: xhigh→high for learned {low,high,max}", () => {
|
||||
test("proactive clamp: xhigh→max for learned {low,high,max} (nearest-tier, #11295)", () => {
|
||||
recordLearnedReasoningEffort("opencode-zen-direct", "x-preview-f-free-2", ["low", "high", "max"]);
|
||||
const out = sanitizeReasoningEffortForProvider(
|
||||
{ reasoning_effort: "xhigh", model: "x-preview-f-free-2" },
|
||||
"opencode-zen-direct",
|
||||
"x-preview-f-free-2"
|
||||
) as { reasoning_effort: string };
|
||||
assert.equal(out.reasoning_effort, "high");
|
||||
assert.equal(out.reasoning_effort, "max");
|
||||
});
|
||||
test("proactive clamp: ultra→max for learned {low,high,max}", () => {
|
||||
recordLearnedReasoningEffort("opencode-zen-direct", "x-preview-f-free-3", ["low", "high", "max"]);
|
||||
@@ -135,14 +137,16 @@ test("proactive clamp: high→medium for learned {low,medium}", () => {
|
||||
) as { reasoning_effort: string };
|
||||
assert.equal(out.reasoning_effort, "medium");
|
||||
});
|
||||
test("no upgrade: low stays low for learned {high,max}", () => {
|
||||
// #11295: sub-floor demand (low, below the learned floor {high,max}) now
|
||||
// clamps up to the floor instead of passing through unchanged.
|
||||
test("sub-floor clamp: low→high for learned {high,max} (#11295)", () => {
|
||||
recordLearnedReasoningEffort("acme", "m3", ["high", "max"]);
|
||||
const out = sanitizeReasoningEffortForProvider(
|
||||
{ reasoning_effort: "low", model: "m3" },
|
||||
"acme",
|
||||
"m3"
|
||||
) as { reasoning_effort: string };
|
||||
assert.equal(out.reasoning_effort, "low");
|
||||
assert.equal(out.reasoning_effort, "high");
|
||||
});
|
||||
test("custom model ultra→medium for learned {low,medium}", () => {
|
||||
recordLearnedReasoningEffort("openai-compatible-chat-eaff6869", "qwen3-coder-30b-a3b-instruct-2", ["low", "medium"]);
|
||||
|
||||
Reference in New Issue
Block a user