diff --git a/changelog.d/features/11104-operator-error-rules.md b/changelog.d/features/11104-operator-error-rules.md new file mode 100644 index 0000000000..f31e78c01f --- /dev/null +++ b/changelog.d/features/11104-operator-error-rules.md @@ -0,0 +1 @@ +- **feat(providers):** let operators declare per-provider error rules through `settings.providerErrorRules` instead of patching the catalog — an operator-supplied rule for a provider is consulted before the built-in `providerRuleRegistry`, receives the raw error text, and has its declared scope/cooldown/reason actually honored end to end, for any provider (declaring the rule is the opt-in — no extra allowlist entry needed). Matches are plain case-insensitive substrings (never RegExp) and bounded to 50 rules to keep the hot path safe ([#11104](https://github.com/diegosouzapw/OmniRoute/pull/11104)) diff --git a/changelog.d/fixes/11060-perplexity-filter.md b/changelog.d/fixes/11060-perplexity-filter.md new file mode 100644 index 0000000000..c221d3ccab --- /dev/null +++ b/changelog.d/fixes/11060-perplexity-filter.md @@ -0,0 +1 @@ +- fix(providers): filter Perplexity model import to the Sonar family so Agent-API catalog ids stop surfacing as routable chat models (#11060) diff --git a/changelog.d/fixes/11095-termux-onnx.md b/changelog.d/fixes/11095-termux-onnx.md new file mode 100644 index 0000000000..8c26803710 --- /dev/null +++ b/changelog.d/fixes/11095-termux-onnx.md @@ -0,0 +1 @@ +- fix(install): make the ONNX dependency chain optional so Termux/Android installs succeed again (#11095) diff --git a/changelog.d/fixes/11101-reject-silent-validation.md b/changelog.d/fixes/11101-reject-silent-validation.md new file mode 100644 index 0000000000..04b2a67d5a --- /dev/null +++ b/changelog.d/fixes/11101-reject-silent-validation.md @@ -0,0 +1 @@ +- **fix(providers):** Reject silent validation degradation on provider connection patch — unknown `rateLimitOverrides` keys (e.g. a typo'd `tpm`) and empty/non-numeric values now return `400` with the rejected key list instead of being silently dropped ([#11101](https://github.com/diegosouzapw/OmniRoute/pull/11101)) diff --git a/changelog.d/fixes/11102-combo-suggestion-count.md b/changelog.d/fixes/11102-combo-suggestion-count.md new file mode 100644 index 0000000000..3cbf6f11d3 --- /dev/null +++ b/changelog.d/fixes/11102-combo-suggestion-count.md @@ -0,0 +1 @@ +- **Autopilot suggestion counter:** the combo health autopilot summary now reports `suggestionCount` (the real number of suggested actions across all issues) instead of conflating it with link counts, while keeping `actionableCount` as a deprecated alias for backward compatibility. The `run_combo_test` action now links to the dashboard with the combo id (`/dashboard/combos?test=`) rather than the read-only API route, so operators can actually trigger a test from the UI ([#11102](https://github.com/diegosouzapw/OmniRoute/pull/11102)). diff --git a/changelog.d/fixes/11103-persist-config-audit-log.md b/changelog.d/fixes/11103-persist-config-audit-log.md new file mode 100644 index 0000000000..aeb53b1781 --- /dev/null +++ b/changelog.d/fixes/11103-persist-config-audit-log.md @@ -0,0 +1 @@ +- **Config audit persistence:** persist the configuration audit trail to SQLite (`config_audit_log`) instead of an in-memory buffer capped at 1000 volatile entries, and bound its growth with `cleanupConfigAudit()` driven by the `retention.configAudit` setting (default 30 days), wired into `runAutoCleanup` ([#11103](https://github.com/diegosouzapw/OmniRoute/pull/11103)). diff --git a/changelog.d/fixes/11109-stream-recovery-toolcall.md b/changelog.d/fixes/11109-stream-recovery-toolcall.md new file mode 100644 index 0000000000..04a43382e4 --- /dev/null +++ b/changelog.d/fixes/11109-stream-recovery-toolcall.md @@ -0,0 +1 @@ +- fix(sse): resume mid-stream recovery after a _completed_ tool call — `finish_reason: "tool_calls"` is now tracked per-call instead of as a general terminal marker, so truncation of trailing prose after a fully-delivered tool call is recoverable while in-flight calls stay blocked ([#11109](https://github.com/diegosouzapw/OmniRoute/pull/11109)) diff --git a/changelog.d/fixes/11116-reasoning-effort-capability-discovery.md b/changelog.d/fixes/11116-reasoning-effort-capability-discovery.md new file mode 100644 index 0000000000..fbc4dfe694 --- /dev/null +++ b/changelog.d/fixes/11116-reasoning-effort-capability-discovery.md @@ -0,0 +1 @@ +- **fix(providers):** `reasoning_effort` now learns the accepted values from a provider's own 400/422 response and clamps to the highest one instead of forwarding an unsupported `xhigh`/`max` (or a hardcoded `"high"` fallback) — fixes custom OpenAI-compatible connections and registered providers with no reasoning metadata ([#11116](https://github.com/diegosouzapw/OmniRoute/pull/11116)) — thanks @maxmad64bis diff --git a/changelog.d/fixes/11144-responses-parallel-tool-calls-index.md b/changelog.d/fixes/11144-responses-parallel-tool-calls-index.md new file mode 100644 index 0000000000..35ba19b279 --- /dev/null +++ b/changelog.d/fixes/11144-responses-parallel-tool-calls-index.md @@ -0,0 +1 @@ +- **fix(sse):** parallel `function_call` items in a Responses API stream (e.g. several tool calls dispatched in the same turn) now each get a stable, distinct `index`/`id` when translated to Chat Completions streaming deltas, instead of colliding on index 0 and tripping strict stream parsers with `Expected 'id' to be a string.` ([#11144](https://github.com/diegosouzapw/OmniRoute/pull/11144)) diff --git a/changelog.d/maintenance/vi-harimport-parity.md b/changelog.d/maintenance/vi-harimport-parity.md new file mode 100644 index 0000000000..b08b8dc92f --- /dev/null +++ b/changelog.d/maintenance/vi-harimport-parity.md @@ -0,0 +1 @@ +- fix(i18n): translate the 14 `providers.harImport*` keys into Vietnamese (parity gap left by #11069) diff --git a/docs/architecture/RESILIENCE_GUIDE.md b/docs/architecture/RESILIENCE_GUIDE.md index 030f65dd41..0048761e60 100644 --- a/docs/architecture/RESILIENCE_GUIDE.md +++ b/docs/architecture/RESILIENCE_GUIDE.md @@ -448,14 +448,14 @@ classification rules pick the fallback `reason` and lock `scope` Classification rules only see full error **text** (needed to match body markers like `额度不足`) for providers listed in the `FULL_TEXT_RULE_PROVIDERS` allowlist in `providerErrorRules.ts` — currently only `"agentrouter"`. For -every other provider, `checkFallbackError` hands `getProviderErrorRuleMatch` -only the structured error (`{code, type}`), which is enough for -header/status/code-based rules but blind to body-text markers. The helper -`resolveRuleMatchBody()` performs this selection: full error text for -allowlisted providers, the structured error otherwise. Adding a provider to -`FULL_TEXT_RULE_PROVIDERS` is an explicit per-provider opt-in — it exists so -that the default path for every provider not on the list stays -byte-for-byte unchanged. +every other **built-in catalog** provider, `checkFallbackError` hands +`getProviderErrorRuleMatch` only the structured error (`{code, type}`), which +is enough for header/status/code-based rules but blind to body-text markers. +The helper `resolveRuleMatchBody()` performs this selection: full error text +for allowlisted providers, the structured error otherwise. Adding a +**built-in** provider to `FULL_TEXT_RULE_PROVIDERS` is an explicit per-provider +opt-in — it exists so that the default path for every provider not on the +list stays byte-for-byte unchanged. A rule's `scope` (`model` / `provider` / `connection`) is a separate opt-in from `FULL_TEXT_RULE_PROVIDERS`: `checkFallbackError` only surfaces it as @@ -466,6 +466,31 @@ honorsRuleLockScope()` — today only `"agentrouter"`). See "Restated quota errors" above for what a `scope: "connection"` match actually does once a provider is on that allowlist. +**#11104 — operator-declared rules bypass both allowlists.** An operator can +declare a per-provider rule at runtime via `settings.providerErrorRules` +(`open-sse/config/providerErrorRules.ts::setOperatorProviderErrorRules`) +without editing this file. Gating an operator rule behind +`FULL_TEXT_RULE_PROVIDERS`/`HONORS_RULE_LOCK_SCOPE_PROVIDERS` — allowlists +meant to protect the **default** behavior of built-in catalog rules — would +make the settings mechanism inert for every provider except the ones already +listed there, since declaring the rule is already the operator's explicit +opt-in. `resolveRuleMatchBody()` and `honorsRuleLockScope()` both check +`hasOperatorRuleForProvider()` first: a provider with an operator rule gets +the raw error text and has its declared `scope` honored, regardless of +whether it also appears in either allowlist. + +**Known gap — `providerRuleRegistry` is never consulted for HTTP 400.** +`checkFallbackError`'s `BAD_REQUEST` branch classifies status 400 entirely +through its own pattern arrays (`MODEL_ACCESS_DENIED_PATTERNS`, +`CONTEXT_OVERFLOW_PATTERNS`, etc. in `accountFallback.ts`) and returns before +the `configuredRule`/`getProviderErrorRuleMatch` branch above it is reached. +A built-in catalog rule (or an operator rule) with `status: 400` is +syntactically valid but will never fire. No existing rule targets 400 today, +so nothing in production is affected — but a future 400 rule needs this +branch touched first, which is a larger change than adding a rule (it +reclassifies 400 for every provider already relying on the pattern-array +behavior) and is out of scope for a single-provider rule addition. + ### Adding a new quota-misstating gateway 1. Register one rule array in `statusRestatementRegistry` diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index c45cf542a6..f1fa055c93 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -171,6 +171,7 @@ export const HTTP_STATUS = { FORBIDDEN: 403, NOT_FOUND: 404, NOT_ACCEPTABLE: 406, + UNPROCESSABLE_ENTITY: 422, REQUEST_TIMEOUT: 408, GONE: 410, RATE_LIMITED: 429, @@ -263,11 +264,17 @@ export const PROVIDER_PROFILES = { circuitBreakerReset: envInt("OMNIROUTE_CIRCUIT_BREAKER_API_KEY_RESET_MS", 30000), // Provider-level circuit breaker (entire provider cooldown after repeated failures) providerFailureThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_THRESHOLD", 15), // Scaled for 500+ connections (was 5) - providerFailureWindowMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS", 1800000), // 30min window (was 20min) + providerFailureWindowMs: envInt( + "OMNIROUTE_PROVIDER_BREAKER_API_KEY_FAILURE_WINDOW_MS", + 1800000 + ), // 30min window (was 20min) providerCooldownMs: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_COOLDOWN_MS", 600000), // 10min cooldown when threshold reached degradationThreshold: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_DEGRADATION_THRESHOLD", 7), maxBackoffMultiplier: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_MAX_BACKOFF_MULTIPLIER", 4), - backoffEscalationCount: envInt("OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT", 3), + backoffEscalationCount: envInt( + "OMNIROUTE_PROVIDER_BREAKER_API_KEY_BACKOFF_ESCALATION_COUNT", + 3 + ), }, // Local providers (localhost inference backends like Ollama, LM Studio, oMLX). // Not yet wired into getProviderProfile() — will be used when local provider_nodes diff --git a/open-sse/config/providerErrorRules.ts b/open-sse/config/providerErrorRules.ts index c910e3fb4d..6f00d2c5f1 100644 --- a/open-sse/config/providerErrorRules.ts +++ b/open-sse/config/providerErrorRules.ts @@ -30,21 +30,63 @@ export type ProviderErrorRule = { export type ProviderErrorRuleMatch = { reason: ConfiguredErrorReason; /** - * Intended lock scope. #10334: this field is CONSUMED end-to-end only for - * providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS` (agentrouter-exclusive - * today, gated by `honorsRuleLockScope()`) — for those, `checkFallbackError` - * surfaces it as `ruleScope` on its return value for the persistence layer - * to honor instead of re-deriving scope from `hasPerModelQuota()`. For - * every other provider it remains INFORMATIONAL: `getProviderErrorRuleMatch` - * callers still read only `reason`/`cooldownMs`, and the actual lock scope - * is decided independently by each call site. Widening the allowlist is - * tracked as a follow-up — see `docs/architecture/RESILIENCE_GUIDE.md` §7. + * Intended lock scope. #10334: for a BUILT-IN catalog rule, this field is + * CONSUMED end-to-end only for providers in `HONORS_RULE_LOCK_SCOPE_PROVIDERS` + * (agentrouter-exclusive today, gated by `honorsRuleLockScope()`) — for those, + * `checkFallbackError` surfaces it as `ruleScope` on its return value for the + * persistence layer to honor instead of re-deriving scope from + * `hasPerModelQuota()`. For every other built-in-rule provider it remains + * INFORMATIONAL. #11104: an OPERATOR-declared rule (`OperatorProviderErrorRule`) + * is exempt from this allowlist — `honorsRuleLockScope()` always returns true + * when the provider has one, since the operator already opted in by declaring + * the rule. Widening `HONORS_RULE_LOCK_SCOPE_PROVIDERS` itself (for a new + * built-in catalog rule) is tracked as a follow-up — see + * `docs/architecture/RESILIENCE_GUIDE.md` §7. */ scope: "model" | "provider" | "connection"; /** Optional explicit cooldown; falls back to the existing per-reason defaults. */ cooldownMs?: number; }; +/** + * Operator-declared per-provider error rule (settings-driven). + * + * Mirrors the catalog `ProviderErrorRule` but is data-only so an operator can + * add a scope/cooldown/reason override for a provider without editing this + * file. `match` is a plain case-insensitive SUBSTRING of the error body — never + * a RegExp — so an operator-supplied pattern can never introduce a ReDoS on the + * error-classification hot path. Bounded to <= 50 rules total by the settings + * schema. An operator rule is consulted BEFORE the built-in `providerRuleRegistry` + * and wins on the first status+substring match for a provider. + */ +export type OperatorProviderErrorRule = { + status: number; + match: string; + scope: "model" | "provider" | "connection"; + reason?: ConfiguredErrorReason; + cooldownMs?: number; +}; + +let operatorProviderErrorRules: Record = {}; + +/** + * Inject operator-declared rules. Called from the runtime-settings applier + * (`applyRuntimeSettings`) once at boot and on every settings update, with the + * value validated by the settings schema. Pass `undefined`/empty/null to clear. + * Provider keys are lowercased so lookups are case-insensitive. + */ +export function setOperatorProviderErrorRules( + rules: Record | undefined | null +): void { + operatorProviderErrorRules = {}; + if (!rules) return; + for (const [provider, list] of Object.entries(rules)) { + if (Array.isArray(list) && list.length > 0) { + operatorProviderErrorRules[provider.toLowerCase()] = list; + } + } +} + // ─── Opencode ─────────────────────────────────────────────────────────────────── // Opencode Go uses an account-wide quota. The body usually says "rate limit // reached" but the presence of `x-ratelimit-remaining-requests: 0` is the @@ -272,11 +314,21 @@ export const providerRuleRegistry = new Map([ * FULL_TEXT_RULE_PROVIDERS: that set controls what body a rule matches against * (input), this one controls whether the matched scope changes caller behavior * (output). A provider could need one without the other. + * + * Providers with an operator-declared rule (`setOperatorProviderErrorRules`) + * are honored too, without being added here: the allowlist exists to gate + * BUILT-IN catalog rules, which change default behavior for every operator + * running that provider — an operator rule is already an explicit, per-operator + * opt-in, so gating it a second time behind this list would make the settings + * mechanism (#11104) silently inert for every provider except the ones listed + * below. See `hasOperatorRuleForProvider`. */ const HONORS_RULE_LOCK_SCOPE_PROVIDERS = new Set(["agentrouter"]); export function honorsRuleLockScope(provider: string | null | undefined): boolean { - return !!provider && HONORS_RULE_LOCK_SCOPE_PROVIDERS.has(provider.toLowerCase()); + if (!provider) return false; + const key = provider.toLowerCase(); + return HONORS_RULE_LOCK_SCOPE_PROVIDERS.has(key) || hasOperatorRuleForProvider(key); } /** @@ -310,28 +362,51 @@ export function egressBucketedLockProviders(): string[] { } /** - * Providers whose rules match on the FULL upstream error text. - * checkFallbackError's rule lookup normally passes only the structured + * Providers whose BUILT-IN catalog rules match on the FULL upstream error + * text. checkFallbackError's rule lookup normally passes only the structured * error ({code, type} — message stripped by the combo callers), which is * enough for header/status/code rules but blind to body-text markers like * agentrouter's "额度不足". Providers in this set get the raw error text as * the match body instead. EXCLUSIVE allowlist by owner decision (2026-08-13): * adding a provider here is an explicit opt-in — the default path for every * other provider must remain byte-for-byte unchanged. + * + * Operator-declared rules bypass this allowlist entirely (see + * `hasOperatorRuleForProvider`): the operator's `match` is a literal substring + * of the error body by construction, so a rule that never sees body text could + * never match anything, defeating the point of declaring it. */ const FULL_TEXT_RULE_PROVIDERS = new Set(["agentrouter"]); +/** + * True when an operator has declared at least one rule for this provider via + * `settings.providerErrorRules` (injected through `setOperatorProviderErrorRules`). + * Presence of the rule IS the opt-in — no separate allowlist to maintain, and + * no widening decision needed as new operators configure new providers. + */ +export function hasOperatorRuleForProvider(provider: string | null | undefined): boolean { + if (!provider) return false; + const rules = operatorProviderErrorRules[provider.toLowerCase()]; + return !!rules && rules.length > 0; +} + /** * Resolve the body handed to getProviderErrorRuleMatch inside - * checkFallbackError: full error text for FULL_TEXT_RULE_PROVIDERS, - * the structured error for everyone else. + * checkFallbackError: full error text for FULL_TEXT_RULE_PROVIDERS or any + * provider with an operator-declared rule, the structured error for everyone + * else. */ export function resolveRuleMatchBody( provider: string | null | undefined, structuredError: unknown, errorText: string | null | undefined ): unknown { - if (provider && FULL_TEXT_RULE_PROVIDERS.has(provider.toLowerCase()) && errorText) { + if ( + provider && + (FULL_TEXT_RULE_PROVIDERS.has(provider.toLowerCase()) || + hasOperatorRuleForProvider(provider)) && + errorText + ) { return errorText; } return structuredError ?? null; @@ -346,10 +421,32 @@ export function getProviderErrorRuleMatch( provider: string | null | undefined, status: number, headers: Headers | Record | null | undefined, - body?: unknown + body?: unknown, + operatorRules?: Record ): ProviderErrorRuleMatch | null { if (!provider) return null; - const rules = providerRuleRegistry.get(provider.toLowerCase()); + const key = provider.toLowerCase(); + + // Operator-declared rules win first: an operator can override any catalog + // rule for a provider without editing this file. `operatorRules` is the + // injected source (tests / direct callers); when omitted we fall back to the + // settings-backed cache populated by `setOperatorProviderErrorRules`. + const opRules = (operatorRules ?? operatorProviderErrorRules)?.[key]; + if (opRules && opRules.length > 0) { + const text = typeof body === "string" ? body : JSON.stringify(body ?? ""); + const lowered = text.toLowerCase(); + for (const r of opRules) { + if (r.status === status && lowered.includes(r.match.toLowerCase())) { + return { + reason: r.reason ?? "quota_exhausted", + scope: r.scope, + cooldownMs: r.cooldownMs, + }; + } + } + } + + const rules = providerRuleRegistry.get(key); if (!rules) return null; // Normalize headers: accept either a `Headers` object (from `fetch()`) or // a plain record. Provider rules access headers via plain object indexing. diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index bb3b6e0f9f..94bb036c95 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -10,6 +10,7 @@ export { } from "./providers/registry/alibaba/index.ts"; export { REGISTRY } from "./providers/index.ts"; import { REGISTRY } from "./providers/index.ts"; +import { isPrivateHost } from "@/shared/network/outboundUrlGuard"; import { RegistryModel, REASONING_UNSUPPORTED, @@ -132,11 +133,8 @@ export function isLocalProvider(baseUrl?: string | null): boolean { try { const url = new URL(baseUrl); const hostname = url.hostname; - // Strictly matching 172.16.0.0/12 (Docker/local) and explicitly blocking ::1 per SSRF hardening - return ( - LOCAL_HOSTNAMES.has(hostname) || - /^172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname) - ); + if (!hostname) return false; + return LOCAL_HOSTNAMES.has(hostname) || isPrivateHost(hostname); } catch { return false; } diff --git a/open-sse/config/providers/registry/cline/index.ts b/open-sse/config/providers/registry/cline/index.ts index 21e8c600ed..aecf811f19 100644 --- a/open-sse/config/providers/registry/cline/index.ts +++ b/open-sse/config/providers/registry/cline/index.ts @@ -27,7 +27,7 @@ export const clineProvider: RegistryEntry = { // the official free bucket and text-output models advertised as zero-cost. models: [ { - id: "zai/glm-5.2", + id: "z-ai/glm-5.2", name: "GLM 5.2", toolCalling: true, supportsReasoning: true, diff --git a/open-sse/config/providers/registry/opencode/go/index.ts b/open-sse/config/providers/registry/opencode/go/index.ts index 14304426c5..abebd92c0f 100644 --- a/open-sse/config/providers/registry/opencode/go/index.ts +++ b/open-sse/config/providers/registry/opencode/go/index.ts @@ -14,8 +14,6 @@ export const opencode_goProvider: RegistryEntry = { authPrefix: "Bearer", defaultContextLength: 200000, models: [ - ...OPENCODE_ZEN_GO_SHARED_MODELS, - // Port from decolua/9router 8efacc11: align with official Go endpoints — // glm-5.2 is now advertised and Kimi chat traffic must route through // `kimi-k2.7-code` (the live API rejects the plain `kimi-k2.7` alias for @@ -26,6 +24,10 @@ export const opencode_goProvider: RegistryEntry = { { id: "glm-5.2", name: "GLM-5.2", supportsReasoning: true }, { id: "glm-5.2-high", name: "GLM-5.2 (high effort)", supportsReasoning: true }, { id: "glm-5.2-max", name: "GLM-5.2 (max effort)", supportsReasoning: true }, + + ...OPENCODE_ZEN_GO_SHARED_MODELS, + // models[0] (glm-5.2) is the dashboard default (LlmChatCard/ProviderTestSlideOver take models[0]). + { id: "glm-5.1", name: "GLM-5.1" }, { id: "glm-5", name: "GLM-5" }, // kimi-k2.7-code declared identically on opencode-zen — see OPENCODE_ZEN_GO_SHARED_MODELS. diff --git a/open-sse/config/providers/registry/opencode/zen/index.ts b/open-sse/config/providers/registry/opencode/zen/index.ts index 76b3811e39..9fdca2fc0a 100644 --- a/open-sse/config/providers/registry/opencode/zen/index.ts +++ b/open-sse/config/providers/registry/opencode/zen/index.ts @@ -16,8 +16,6 @@ export const opencode_zenProvider: RegistryEntry = { // from the live API response so new models work without a code deploy. passthroughModels: true, models: [ - ...OPENCODE_ZEN_GO_SHARED_MODELS, - // ── Chat / Coding ────────────────────────────────────────── // #2900: big-pickle's upstream runs DeepSeek thinking mode — declare the // interleaved reasoning_content contract so follow-up/tool-use turns replay @@ -28,6 +26,10 @@ export const opencode_zenProvider: RegistryEntry = { supportsReasoning: true, interleavedField: "reasoning_content", }, + + ...OPENCODE_ZEN_GO_SHARED_MODELS, + // models[0] (big-pickle) is the dashboard default; SHARED spread kept after it. + { id: "gpt-5.6-sol", name: "GPT 5.6 Sol" }, { id: "gpt-5.6-terra", name: "GPT 5.6 Terra" }, { id: "gpt-5.6-luna", name: "GPT 5.6 Luna" }, @@ -67,6 +69,8 @@ export const opencode_zenProvider: RegistryEntry = { supportsReasoning: true, targetFormat: "openai-responses", }, + // Explicit wire-format overlay of the base opencode provider's muse-spark entry + // (targetFormat: openai-responses). Keep in sync with base on catalog syncs. { id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Contributor Free", diff --git a/open-sse/executors/base.ts b/open-sse/executors/base.ts index 66e10b67e9..1c13442aff 100644 --- a/open-sse/executors/base.ts +++ b/open-sse/executors/base.ts @@ -20,6 +20,10 @@ import { recordLearnedThinkingCap, parseThinkingBudgetMax, } from "../services/learnedThinkingCaps.ts"; +import { + recordLearnedReasoningEffort, + parseReasoningEffortEnum, +} from "../services/learnedReasoningEffortCaps.ts"; import { getParamFilterConfig, addParamToBlocklist, @@ -826,6 +830,9 @@ export class BaseExecutor { // loop. The learned cap is also recorded process-wide via // recordLearnedThinkingCap so future requests skip the 400 entirely. let thinkingBudgetClampedMax: number | null = null; + // Set by the reasoning_effort 4xx clamp-and-retry below — guards the same + // "fires at most once per URL" invariant as thinkingBudgetClampedMax above. + let reasoningEffortClamped = false; for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) { const requestCredentials = withForcedResponsesUpstream( @@ -1529,6 +1536,49 @@ export class BaseExecutor { } } + // Reasoning-effort enum 4xx clamp-and-retry (any provider/model without a + // declared reasoning_effort capability — custom OpenAI-compatible + // connections, or a registered provider the registry hasn't caught up + // with). Mirrors the thinking_budget clamp-and-retry above: parse the + // upstream-advertised accepted values, record them process-wide (so + // FUTURE requests clamp proactively via sanitizeReasoningEffortForProvider + // → getLearnedReasoningEffort), clamp the live transformedBody by + // re-running the sanitizer, and retry the same URL once. + if ( + (response.status === HTTP_STATUS.BAD_REQUEST || + response.status === HTTP_STATUS.UNPROCESSABLE_ENTITY) && + !reasoningEffortClamped && + transformedBody && + typeof transformedBody === "object" + ) { + const errText = await response + .clone() + .text() + .catch(() => ""); + const acceptedValues = parseReasoningEffortEnum(errText); + if (acceptedValues) { + reasoningEffortClamped = true; + const learned = recordLearnedReasoningEffort(this.provider, model, acceptedValues); + if (learned) { + transformedBody = sanitizeReasoningEffortForProvider( + transformedBody, + this.provider, + model, + log + ); + let retryBody = JSON.stringify(transformedBody); + if (usesClaudeCodeProtocol || this.provider === "claude") { + retryBody = await signRequestBody(retryBody); + } + log?.info?.( + "REASONING_SANITIZE", + `Upstream ${response.status} rejected reasoning_effort on ${url} — clamped to ${learned} and retrying (learned for ${this.provider}/${model})` + ); + response = await fetchWithStartTimeout(url, { ...fetchOptions, body: retryBody }); + } + } + } + // Generic reactive 400 field-downgrade; each field is stripped at most once. if ( response.status === HTTP_STATUS.BAD_REQUEST && diff --git a/open-sse/executors/base/reasoningEffort.ts b/open-sse/executors/base/reasoningEffort.ts index fa416dbc1a..8dd99904fd 100644 --- a/open-sse/executors/base/reasoningEffort.ts +++ b/open-sse/executors/base/reasoningEffort.ts @@ -8,6 +8,10 @@ import { getProviderModel, getProviderModels, } from "../../config/providerModels.ts"; +import { + getLearnedReasoningEffort, + REASONING_EFFORT_ORDER, +} from "../../services/learnedReasoningEffortCaps.ts"; /** * Sanitize reasoning_effort for providers that don't accept all values. @@ -338,10 +342,24 @@ export function sanitizeReasoningEffortForProvider( const supportsXHigh = supportsXHighEffort(provider, modelStr); const supportsMax = supportsMaxEffortForProvider(provider, modelStr); + // Highest value we've actually seen this provider+model accept in a real + // upstream 4xx (learnedReasoningEffortCaps.ts) — takes priority over the + // static registry (which defaults to "supports everything" when there's no + // entry, e.g. custom OpenAI-compatible connections) and over the hardcoded + // "high" fallback below (which isn't always valid either). + const learnedCap = getLearnedReasoningEffort(provider, modelStr); + const learnedRank = learnedCap ? REASONING_EFFORT_ORDER.indexOf(learnedCap) : -1; // ── xhigh handling ────────────────────────────────────────────────────── // xhigh is OmniRoute-internal. Map it to the best effort the model accepts. if (effortStr === "xhigh") { + if (learnedCap && learnedRank < REASONING_EFFORT_ORDER.indexOf("xhigh")) { + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: clamped reasoning_effort xhigh → ${learnedCap} (learned)` + ); + return writeEffortValue(b, learnedCap, c); + } if (supportsXHigh) return body; // model accepts xhigh natively if (supportsMax) { log?.info?.( @@ -366,6 +384,13 @@ export function sanitizeReasoningEffortForProvider( // upstream, and if it 400s the user gets a clear signal. This prevents // new models from being unusable for weeks until they're whitelisted (#8057). if (effortStr === "max") { + if (learnedCap && learnedRank < REASONING_EFFORT_ORDER.indexOf("max")) { + log?.info?.( + "REASONING_SANITIZE", + `${provider}/${modelStr}: clamped reasoning_effort max → ${learnedCap} (learned)` + ); + return writeEffortValue(b, learnedCap, c); + } if (supportsMax) return body; // explicitly known to accept max // A model that explicitly advertises its accepted tiers is safe to normalize. diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index d63c58e94c..9e1f5f2ee3 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -21,7 +21,7 @@ import { honorsRuleLockScope, } from "../config/providerErrorRules.ts"; import * as rot from "./rotationConfig.ts"; -import { getPassthroughProviders, getProviderCategory } from "../config/providerRegistry.ts"; +import { getPassthroughProviders, getProviderCategory, isLocalProvider } from "../config/providerRegistry.ts"; import { DEFAULT_RESILIENCE_SETTINGS, resolveResilienceSettings, @@ -37,7 +37,7 @@ import { type FailureKind, } from "../../src/shared/utils/classify429"; import { recordProviderSuccess as resetCooldownFailureCount } from "./providerCooldownTracker.ts"; -import { resolveProviderId } from "../../src/shared/constants/providers"; +import { resolveProviderId, isLocalProvider as isLocalProviderId, isSelfHostedChatProvider } from "../../src/shared/constants/providers"; import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints"; import { getCodexModelScope } from "../config/codexQuotaScopes.ts"; import { getQuotaScopedModelForProvider } from "./antigravityQuotaFamily.ts"; @@ -791,12 +791,14 @@ export function hasPerModelQuota( return connectionPassthroughModels; } if (!provider) return false; - if (getCanonicalLockProvider(provider) === "antigravity") return true; - if (getCanonicalLockProvider(provider) === "codex") return true; - if (provider === "gemini" || provider === "github") return true; - if (provider === "antigravity" || provider === "agy") return true; - if (getPassthroughProviders().has(provider)) return true; - if (isCompatibleProvider(provider)) return true; + const canonicalId = resolveProviderId(provider); + if (getCanonicalLockProvider(canonicalId) === "antigravity") return true; + if (getCanonicalLockProvider(canonicalId) === "codex") return true; + if (canonicalId === "gemini" || canonicalId === "github") return true; + if (canonicalId === "antigravity" || canonicalId === "agy") return true; + if (getPassthroughProviders().has(canonicalId)) return true; + if (isCompatibleProvider(canonicalId)) return true; + if (isLocalProviderId(canonicalId) || isSelfHostedChatProvider(canonicalId)) return true; return false; } diff --git a/open-sse/services/learnedReasoningEffortCaps.ts b/open-sse/services/learnedReasoningEffortCaps.ts new file mode 100644 index 0000000000..b0125d8683 --- /dev/null +++ b/open-sse/services/learnedReasoningEffortCaps.ts @@ -0,0 +1,126 @@ +/** + * Learned Reasoning-Effort Caps — reactive capability memory for providers/models + * OmniRoute has no static registry entry for (custom OpenAI-compatible connections, + * or any registered provider whose registry entry carries no reasoning metadata). + * + * Same shape as `learnedThinkingCaps.ts` (thinking_budget), generalized from a + * numeric budget to an ordinal reasoning_effort scale: on a 4xx whose body + * enumerates the accepted values, `base.ts`'s executor calls + * `recordLearnedReasoningEffort`, which stores the highest recognized value in a + * module-level Map keyed "provider:model" (lowercased). Subsequent requests for + * the same provider+model read the cap via `getLearnedReasoningEffort` (consulted + * by `sanitizeReasoningEffortForProvider` in `executors/base/reasoningEffort.ts`) + * so the 4xx→retry round-trip is paid at most once per process per provider+model. + * + * 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 + * one upstream 4xx. + */ + +export const REASONING_EFFORT_ORDER: readonly string[] = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +]; + +// key: `${provider}:${model}` lowercased → highest value known to be accepted. +const learnedCaps = new Map(); + +function buildKey(provider: string | null | undefined, model: string | null | undefined): string { + const p = typeof provider === "string" ? provider.trim().toLowerCase() : ""; + const m = typeof model === "string" ? model.trim().toLowerCase() : ""; + if (!p || !m) return ""; + return `${p}:${m}`; +} + +function rankOf(value: string): number { + return REASONING_EFFORT_ORDER.indexOf(value); +} + +/** + * Return the learned cap for provider+model, or null when nothing has been + * learned yet (no upstream 4xx recorded). Keyed case-insensitively. + */ +export function getLearnedReasoningEffort( + provider: string | null | undefined, + model: string | null | undefined +): string | null { + const key = buildKey(provider, model); + if (!key) return null; + return learnedCaps.get(key) ?? null; +} + +/** + * Record that `acceptedValues` is the enum the upstream advertised for + * provider+model, and store the highest recognized value as the learned cap. + * Returns the stored value, or null when `acceptedValues` contained no token + * from `REASONING_EFFORT_ORDER` (nothing usable to learn) or the key is unusable. + * + * Always monotonically decreases: if a cap already stored ranks lower than the + * newly computed highest, the stored (lower) value wins and is returned + * unchanged. This keeps a later, laxer-looking response (or a race between + * concurrent requests) from ratcheting the cap back up. + */ +export function recordLearnedReasoningEffort( + provider: string | null | undefined, + model: string | null | undefined, + acceptedValues: string[] +): string | null { + const key = buildKey(provider, model); + if (!key) return null; + + let best: string | null = null; + let bestRank = -1; + for (const raw of acceptedValues) { + const rank = rankOf(raw); + if (rank > bestRank) { + bestRank = rank; + best = raw; + } + } + if (best === null) return null; + + const existing = learnedCaps.get(key); + if (existing !== undefined && rankOf(existing) <= bestRank) { + return existing; // already learned an equal-or-lower cap; keep it + } + learnedCaps.set(key, best); + return best; +} + +// Matches both prose shapes observed: OVH's `@ai-sdk/openai-compatible` +// deserializer ("expected one of `a`, `b`") and a generic vendor prose form +// ("Supported types are a, b, and c"). +const LIST_INTRO = /(?:expected one of|supported (?:types|values) are)[:\s]*([^.]+)/i; + +/** + * Extract the upstream-advertised accepted reasoning_effort values from a 4xx + * error body. Returns only tokens present in REASONING_EFFORT_ORDER (unknown + * tokens are dropped defensively) in the order they appeared, or null when the + * text names no recognized enum member. + */ +export function parseReasoningEffortEnum(errText: unknown): string[] | null { + if (typeof errText !== "string" || !errText) return null; + const match = LIST_INTRO.exec(errText); + if (!match) return null; + const tokens = match[1] + .split(/,|\band\b|&/i) + .map((t) => + t + .replace(/`/g, "") + .replace(/\([^)]*\)/g, "") + .trim() + .toLowerCase() + ) + .filter((t) => t.length > 0 && REASONING_EFFORT_ORDER.includes(t)); + return tokens.length > 0 ? tokens : null; +} + +/** Test-only: clear the learned-cap Map between tests. */ +export function __test_resetLearnedReasoningEffortCaps(): void { + learnedCaps.clear(); +} diff --git a/open-sse/services/reasoningInputPolicy.ts b/open-sse/services/reasoningInputPolicy.ts index 71a283a706..94f27ddcdc 100644 --- a/open-sse/services/reasoningInputPolicy.ts +++ b/open-sse/services/reasoningInputPolicy.ts @@ -1,5 +1,6 @@ import { REGISTRY } from "../config/providerRegistry.ts"; import type { ReasoningTransport } from "../config/providerRegistry.ts"; +import { isValidResponsesItemId } from "./responsesItemId.ts"; type JsonRecord = Record; @@ -279,13 +280,27 @@ function sanitizeResponsesInput( if (!hasPlaintext && !hasOpaque && (!hasDisplaySummary(next) || stripOrphanedSummaries)) { continue; } - if (!hasOpaque && typeof next.id === "string") delete next.id; + // `id` is only worth keeping on an opaque item with a valid string value — + // non-opaque items don't replay their id, and a malformed value (e.g. `null`, + // observed on opencode/zen) must not survive either way (#11108). + if (!hasOpaque || !isValidResponsesItemId(next.id)) delete next.id; + // Some upstreams (e.g. opencode/zen) omit `summary` entirely on opaque + // reasoning items instead of sending an empty array. Replaying that shape + // verbatim trips strict Responses-API validators that require the field + // to be present on every `input[]` item of type `reasoning` (#11108). + // Plaintext-only items intentionally have no `summary` key and must stay + // untouched. + if (hasOpaque && next.summary === undefined) next.summary = []; filtered.push(next); continue; } const cloned = { ...record }; - if (typeof cloned.id === "string") delete cloned.id; + // Strip `id` whenever present, valid or not: these items don't need a + // replayed server id, and a malformed one (e.g. `null`, same opencode/zen + // omission pattern as the reasoning branch above) must not survive either + // (#11108). + if (cloned.id !== undefined) delete cloned.id; filtered.push(cloned); } return filtered; diff --git a/open-sse/services/responsesInputSanitizer.ts b/open-sse/services/responsesInputSanitizer.ts index 5d81b787a1..94cd99f934 100644 --- a/open-sse/services/responsesInputSanitizer.ts +++ b/open-sse/services/responsesInputSanitizer.ts @@ -1,3 +1,5 @@ +import { isValidResponsesItemId } from "./responsesItemId.ts"; + type JsonRecord = Record; type SanitizeResponsesInputOptions = { dropInternalAssistantMessages?: boolean; @@ -40,7 +42,12 @@ function sanitizeFunctionName(name: string): string { } function sanitizeInputItemId(record: JsonRecord): JsonRecord { - if (typeof record.id !== "string") return record; + if (record.id === undefined) return record; + if (!isValidResponsesItemId(record.id)) { + const next = { ...record }; + delete next.id; + return next; + } const type = typeof record.type === "string" ? record.type : ""; const expectedPrefix = SERVER_ITEM_ID_PREFIX_BY_TYPE[type]; diff --git a/open-sse/services/responsesItemId.ts b/open-sse/services/responsesItemId.ts new file mode 100644 index 0000000000..a57ac92e42 --- /dev/null +++ b/open-sse/services/responsesItemId.ts @@ -0,0 +1,7 @@ +// Shared by reasoningInputPolicy.ts and responsesInputSanitizer.ts: both strip a +// Responses-API `input[]` item's `id` field when it isn't a valid string before +// replay, so a malformed value (e.g. `null`, observed on opencode/zen) never +// survives to trip a strict upstream with "Expected 'id' to be a string." (#11108). +export function isValidResponsesItemId(id: unknown): id is string { + return typeof id === "string"; +} diff --git a/open-sse/services/streamRecovery.ts b/open-sse/services/streamRecovery.ts index a0a397fd87..37a4867f13 100644 --- a/open-sse/services/streamRecovery.ts +++ b/open-sse/services/streamRecovery.ts @@ -185,7 +185,21 @@ export interface OpenAiSseScan { text: string; /** True if any `choices[].delta.tool_calls` appeared — NEVER continue those. */ sawToolCall: boolean; - /** True if a terminal marker (`[DONE]` or a non-null `finish_reason`) appeared. */ + /** + * True only when `tool_calls` appeared in this scan AND its own + * `finish_reason: "tool_calls"` has NOT also appeared in the same scan — i.e. the + * call is still being streamed (arguments may be mid-flight). Once + * `finish_reason: "tool_calls"` closes it, the call is complete, not in flight: the + * client has the full arguments and a truncation past this point only drops + * trailing prose, which continuation can safely recover. + */ + sawToolCallInFlight: boolean; + /** + * True if a terminal marker for the OVERALL stream appeared: `[DONE]`, or a + * `finish_reason` other than `"tool_calls"`. A `finish_reason: "tool_calls"` ends + * that one choice but is not terminal for continuation purposes — the model turn + * (and the client-visible SSE) is still eligible to be resumed past it. + */ terminal: boolean; /** True if at least one OpenAI-shaped `choices[].delta` was parsed (format gate). */ parsedOpenAi: boolean; @@ -199,10 +213,11 @@ export interface OpenAiSseScan { export function scanOpenAiSseText(sse: string): OpenAiSseScan { let text = ""; let sawToolCall = false; + let toolCallFinished = false; let terminal = false; let parsedOpenAi = false; if (typeof sse !== "string" || sse.length === 0) { - return { text, sawToolCall, terminal, parsedOpenAi }; + return { text, sawToolCall, sawToolCallInFlight: false, terminal, parsedOpenAi }; } for (const line of sse.split("\n")) { const trimmed = line.trimStart(); @@ -231,10 +246,17 @@ export function scanOpenAiSseText(sse: string): OpenAiSseScan { if (Array.isArray(toolCalls) && toolCalls.length > 0) sawToolCall = true; } const finishReason = (choice as { finish_reason?: unknown })?.finish_reason; - if (finishReason != null) terminal = true; + if (finishReason === "tool_calls") { + // Ends this one choice, but the overall stream/turn stays continuable — + // never counts as the general terminal marker (see OpenAiSseScan.terminal). + toolCallFinished = true; + } else if (finishReason != null) { + terminal = true; + } } } - return { text, sawToolCall, terminal, parsedOpenAi }; + const sawToolCallInFlight = sawToolCall && !toolCallFinished; + return { text, sawToolCall, sawToolCallInFlight, terminal, parsedOpenAi }; } export interface ContinuableBody { @@ -369,7 +391,7 @@ export function createRecoverableStream( let emittedTail = ""; // raw SSE not yet scanned (awaiting an event boundary) let emittedText = ""; // assistant text already delivered to the client let emittedTerminal = false; - let emittedToolCall = false; + let emittedToolCallInFlight = false; let emittedParsedOpenAi = false; // Enqueue to the client and, when continuation is enabled, fold the chunk into the @@ -388,7 +410,7 @@ export function createRecoverableStream( const scan = scanOpenAiSseText(complete); emittedText += scan.text; if (scan.terminal) emittedTerminal = true; - if (scan.sawToolCall) emittedToolCall = true; + if (scan.sawToolCallInFlight) emittedToolCallInFlight = true; if (scan.parsedOpenAi) emittedParsedOpenAi = true; }; @@ -402,7 +424,7 @@ export function createRecoverableStream( continueEnabled && continuations < maxContinuations && emittedParsedOpenAi && - !emittedToolCall && + !emittedToolCallInFlight && !emittedTerminal && emittedText.length > 0; diff --git a/open-sse/translator/request/openai-responses/toResponses.ts b/open-sse/translator/request/openai-responses/toResponses.ts index bee5efab1a..988835edea 100644 --- a/open-sse/translator/request/openai-responses/toResponses.ts +++ b/open-sse/translator/request/openai-responses/toResponses.ts @@ -201,6 +201,14 @@ export function openaiToOpenAIResponsesRequest( input.push({ type: "reasoning", content: [{ type: "reasoning_text", text: reasoning }], + // Strict Responses-API upstreams (e.g. opencode/zen) require `summary` + // on every `input[]` item of type "reasoning", plaintext or opaque — + // omitting it rejects the request with `input[N] missing required + // field summary`. This item is always freshly built from a chat + // client's plaintext reasoning, so there is no source summary to + // preserve; default to an empty array like the replay sanitizer does + // for opaque items in reasoningInputPolicy.ts (#11108). + summary: [], }); } diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index 0c59fac4fb..01ad55d72f 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -866,21 +866,25 @@ export function openaiResponsesToOpenAIResponse(chunk, state) { function openaiResponsesToOpenAIResponseStream(chunk, state) { if (!chunk) { - if ( - state.currentToolCallNeedsNormalization && - state.currentToolCallArgsBuffer && - state.currentToolCallName - ) { - const toolSchema = state.toolSchemas?.get(state.currentToolCallName); - const argsToEmit = stripEmptyOptionalToolArgs( - state.currentToolCallArgsBuffer, - state.currentToolCallName, - toolSchema - ); - const argsStr = - typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit ?? {}); - state.currentToolCallArgsBuffer = ""; - state.currentToolCallNeedsNormalization = false; + // 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. + const pendingNormalized: Array<{ index: number; argsStr: string }> = []; + if (state.toolCallByCallId instanceof Map) { + for (const entry of state.toolCallByCallId.values()) { + if (entry.needsNormalization && entry.argsBuffer) { + const toolSchema = state.toolSchemas?.get(entry.name); + const argsToEmit = stripEmptyOptionalToolArgs(entry.argsBuffer, entry.name, toolSchema); + pendingNormalized.push({ + index: entry.index, + argsStr: typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit ?? {}), + }); + entry.argsBuffer = ""; + entry.needsNormalization = false; + } + } + } + if (pendingNormalized.length > 0) { state.finishReasonSent = true; state.finishReason = "tool_calls"; const common = { @@ -889,24 +893,21 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { created: state.created, model: state.model || "gpt-4", }; - return [ - { - ...common, - choices: [ - { - index: 0, - delta: { - tool_calls: [{ index: state.toolCallIndex, function: { arguments: argsStr } }], - }, - finish_reason: null, - }, - ], - }, - { - ...common, - choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], - }, - ]; + const chunks: Record[] = pendingNormalized.map(({ index, argsStr }) => ({ + ...common, + choices: [ + { + index: 0, + delta: { tool_calls: [{ index, function: { arguments: argsStr } }] }, + finish_reason: null, + }, + ], + })); + chunks.push({ + ...common, + choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], + }); + return chunks; } // Flush: send final chunk with finish_reason if (!state.finishReasonSent && state.started) { @@ -952,7 +953,23 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { state.chatId = `chatcmpl-${Date.now()}`; state.created = Math.floor(Date.now() / 1000); state.toolCallIndex = 0; + // Kept for computeFinishReason (synthesizeCompletedToolCalls.ts) compatibility — + // that snapshot path mutates it directly and expects it to exist. In a turn with + // multiple parallel calls this only ever reflects the LAST one opened/closed, so + // it must never be used to identify a specific call — only as the "is at least + // one tool call in flight this turn" signal computeFinishReason needs, which + // toolCallIndex > 0 already covers on its own once any call has been added. state.currentToolCallId = null; + // Per-call state keyed by call_id (replaces the old singular + // currentToolCallId/ArgsBuffer/Name/NeedsNormalization/Deferred fields, which + // assumed only one function_call could ever be in flight at a time). + state.toolCallByCallId = new Map(); + // response.function_call_arguments.delta carries `item_id`/`output_index`, not + // `call_id` — resolve either one back to the call_id key used by + // toolCallByCallId (two independent reverse maps, since some upstreams omit + // item_id on delta events but still send output_index). + state.toolCallItemToCallId = new Map(); + state.toolCallOutputIndexToCallId = new Map(); } // Text content delta @@ -983,22 +1000,48 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { // Function call started if (eventType === "response.output_item.added" && data.item?.type === "function_call") { const item = data.item; - state.currentToolCallId = item.call_id || fallbackToolCallId(); - state.currentToolCallArgsBuffer = ""; // reset per-call arg buffer - state.currentToolCallDeferred = false; + const callId = item.call_id || fallbackToolCallId(); + // Kept for computeFinishReason (synthesizeCompletedToolCalls.ts) compatibility. + state.currentToolCallId = callId; + + const toolName = normalizeToolName(item.name); + // Assign this call's index NOW, at .added, not at .done — two calls opened before + // either closes (a genuine parallel dispatch) must never share an index. Deferred + // (still-nameless) calls are the one exception: they don't claim an index until + // .done resolves a real name, so a call that never gets one never burns a slot + // another call could have used. + let index: number | null = null; + if (toolName) { + index = state.toolCallIndex ?? 0; + state.toolCallIndex = index + 1; + } + + if (!(state.toolCallByCallId instanceof Map)) state.toolCallByCallId = new Map(); + state.toolCallByCallId.set(callId, { + index, + name: toolName, + argsBuffer: "", + deferred: !toolName, + needsNormalization: toolName === "Agent", + }); + if (!(state.toolCallItemToCallId instanceof Map)) state.toolCallItemToCallId = new Map(); + if (item.id) state.toolCallItemToCallId.set(item.id, callId); + // `output_index` is a top-level field on every Responses API streamed event + // (response.output_item.added/.done AND function_call_arguments.delta alike) — + // an identifier independent of item_id, for upstreams that omit item_id on delta + // events. + if (!(state.toolCallOutputIndexToCallId instanceof Map)) { + state.toolCallOutputIndexToCallId = new Map(); + } + if (data.output_index != null) state.toolCallOutputIndexToCallId.set(data.output_index, callId); // Track this call_id so response.completed doesn't synthesize a duplicate if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set(); - if (state.currentToolCallId) state.toolCallIdsSeen.add(state.currentToolCallId); + state.toolCallIdsSeen.add(callId); - const toolName = normalizeToolName(item.name); - state.currentToolName = toolName; // track for schema lookup at done time - state.currentToolCallName = toolName; - state.currentToolCallNeedsNormalization = toolName === "Agent"; if (!toolName) { // Some Responses providers briefly emit placeholder/empty tool names. // Defer emission until output_item.done in case the final name is populated there. - state.currentToolCallDeferred = true; return null; } @@ -1013,8 +1056,8 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { delta: { tool_calls: [ { - index: state.toolCallIndex, - id: state.currentToolCallId, + index, + id: callId, type: "function", function: { name: toolName, @@ -1037,11 +1080,26 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { const argsDelta = data.delta || ""; if (!argsDelta) return null; - state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta; - if (state.currentToolCallDeferred || state.currentToolCallNeedsNormalization) return null; + // Resolve which in-flight call this delta belongs to. Try item_id first (the + // field the Responses API documents for this event), then output_index (also a + // top-level field on this event, and independent of item_id — covers upstreams + // that omit item_id on delta events but still send output_index). Only once both + // identifying fields are absent/unresolved do we fall back to guessing (the + // single open call, or the most recently opened one as a last resort). + const map = state.toolCallByCallId instanceof Map ? state.toolCallByCallId : null; + let callId = data.item_id ? state.toolCallItemToCallId?.get(data.item_id) : undefined; + if (!callId && data.output_index != null) { + callId = state.toolCallOutputIndexToCallId?.get(data.output_index); + } + if (!callId && map) { + callId = map.size === 1 ? [...map.keys()][0] : state.currentToolCallId; + } + const entry = callId ? map?.get(callId) : undefined; + if (!entry) return null; // #9168: buffer arguments until output_item.done for schema-aware null normalization // Previously emitted raw null values for optional enum fields (e.g. isolation: null). + entry.argsBuffer = (entry.argsBuffer || "") + argsDelta; return null; } @@ -1061,13 +1119,30 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { // carry the complete arguments only in output_item.done (no preceding delta events). if (eventType === "response.output_item.done" && data.item?.type === "function_call") { const item = data.item; - const buffered = state.currentToolCallArgsBuffer || ""; - const currentIndex = state.toolCallIndex; // capture before increment - const callId = item.call_id || state.currentToolCallId || fallbackToolCallId(); + const map = state.toolCallByCallId instanceof Map ? state.toolCallByCallId : null; + let callId = item.call_id; + if (!callId && item.id) callId = state.toolCallItemToCallId?.get(item.id); + if (!callId) callId = state.currentToolCallId || fallbackToolCallId(); + const trackedEntry = callId ? map?.get(callId) : undefined; + // Some upstreams (e.g. Codex) send the complete payload only in output_item.done, + // with no preceding output_item.added at all — there is no tracked entry to read an + // index from. + const entry = trackedEntry || { index: null, argsBuffer: "", deferred: false }; + + const buffered = entry.argsBuffer || ""; const toolName = normalizeToolName(item.name); + + // Claim (and advance) this call's index now if it wasn't assigned at .added — either + // a deferred call whose name has just now resolved, or a Codex-style done-only + // payload that never had an .added at all. A deferred call whose name is STILL empty + // never claims an index (nothing was ever emitted for it either way). + if (entry.index == null && toolName) { + entry.index = state.toolCallIndex ?? 0; + state.toolCallIndex = entry.index + 1; + } + const currentIndex = entry.index; const toolSchema = state.toolSchemas?.get(toolName); const shouldNormalizeArguments = toolName === "Agent"; - state.currentToolCallNeedsNormalization = shouldNormalizeArguments; if (toolName && state.toolCalls instanceof Map) { const completedArguments = @@ -1077,6 +1152,9 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { toolName, toolSchema ); + // Keyed by index, not insertion order — readers that need call order for + // parallel calls closed out of order should sort by this key rather than + // relying on Map iteration order. state.toolCalls.set(currentIndex, { id: callId, index: currentIndex, @@ -1095,17 +1173,17 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { if (!state.toolCallIdsSeen) state.toolCallIdsSeen = new Set(); if (callId) state.toolCallIdsSeen.add(callId); - if (state.currentToolCallDeferred) { - state.currentToolCallDeferred = false; - state.currentToolCallArgsBuffer = ""; - state.currentToolCallId = null; + // This call is fully closed — remove it from the in-flight map (bounds the map + // to genuinely in-flight calls, and keeps the single-open-call fallback in the + // function_call_arguments.delta handler correct for whichever call opens next). + if (map && callId) map.delete(callId); + if (state.currentToolCallId === callId) state.currentToolCallId = null; + if (entry.deferred) { if (!toolName) { return null; } - state.toolCallIndex++; - const terminalArguments = typeof item.arguments === "string" ? item.arguments.length > 0 @@ -1148,12 +1226,7 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) { }; } - state.toolCallIndex++; - state.currentToolCallArgsBuffer = ""; // reset for next tool call - state.currentToolCallId = null; - const needsNormalization = state.currentToolCallNeedsNormalization === true; - state.currentToolCallNeedsNormalization = false; - state.currentToolCallName = ""; + const needsNormalization = shouldNormalizeArguments; // Nullable omission sentinels must be normalized before any argument bytes reach the client. // Other tool calls retain immediate argument streaming. diff --git a/open-sse/utils/streamPayloadCollector.ts b/open-sse/utils/streamPayloadCollector.ts index ea1b2e658e..31b9e818f4 100644 --- a/open-sse/utils/streamPayloadCollector.ts +++ b/open-sse/utils/streamPayloadCollector.ts @@ -337,6 +337,7 @@ function createOpenAIReducer(fallbackModel?: string | null): SummaryReducer { // same-name tool_calls) into its own separate tool_calls entries. const finalToolCalls: ToolCall[] = []; let nextIndex = 0; + // Normalize tool_call indexes to contiguous 0-based (OpenAI contract). for (const tc of mergedToolCalls) { const splitArgs = splitConcatenatedToolCallArguments(tc.function.arguments); if (!splitArgs) { diff --git a/package-lock.json b/package-lock.json index 57f810910e..bf77bab602 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@huggingface/transformers": "^4.2.0", "@lobehub/icons": "^5.16.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", @@ -61,7 +60,6 @@ "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "omniglyph": "^1.4.0", - "onnxruntime-node": "1.24.3", "open": "^11.0.1", "ora": "^9.4.1", "parse5": "^8.0.1", @@ -156,9 +154,11 @@ }, "optionalDependencies": { "@atjsh/llmlingua-2": "3.0.0", + "@huggingface/transformers": "^4.2.0", "better-sqlite3": "^13.0.2", "js-tiktoken": "^1.0.20", "keytar": "^7.9.0", + "onnxruntime-node": "1.24.3", "sqlite-vec": "^0.1.9", "tls-client-node": "^0.2.0", "wreq-js": "^3.0.0" @@ -4510,6 +4510,7 @@ "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz", "integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==", "license": "MIT", + "optional": true, "engines": { "node": ">=18" } @@ -4518,13 +4519,15 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@huggingface/tokenizers/-/tokenizers-0.1.3.tgz", "integrity": "sha512-8rF/RRT10u+kn7YuUbUg0OF30K8rjTc78aHpxT+qJ1uWSqxT1MHi8+9ltwYfkFYJzT/oS+qw3JVfHtNMGAdqyA==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "optional": true }, "node_modules/@huggingface/transformers": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-4.2.0.tgz", "integrity": "sha512-8BRCoBMH0XsWaEIamuR0LrJGAfftgHAfb2Vrffy0VKlSAE/MnUJ5/h/zTfEP3fDIft+nk7TqB8xXEyABGitBjQ==", "license": "Apache-2.0", + "optional": true, "dependencies": { "@huggingface/jinja": "^0.5.6", "@huggingface/tokenizers": "^0.1.3", @@ -9483,30 +9486,35 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/base64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/codegen": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.1" @@ -9516,24 +9524,28 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/pool": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@protobufjs/utf8": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@radix-ui/number": { @@ -12737,6 +12749,7 @@ "version": "26.2.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -13998,6 +14011,7 @@ "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz", "integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==", "license": "MIT", + "optional": true, "engines": { "node": ">=14.0" } @@ -14971,7 +14985,8 @@ "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/bottleneck": { "version": "2.19.5", @@ -17935,6 +17950,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "devOptional": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -17964,6 +17980,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "devOptional": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -18064,7 +18081,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/detect-node-es": { "version": "1.1.0", @@ -18908,7 +18926,8 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/es6-promisify": { "version": "7.0.0", @@ -20400,7 +20419,8 @@ "version": "25.9.23", "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "optional": true }, "node_modules/flatted": { "version": "3.4.2", @@ -21226,6 +21246,7 @@ "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", "license": "BSD-3-Clause", + "optional": true, "dependencies": { "boolean": "^3.0.1", "es6-error": "^4.1.1", @@ -21243,6 +21264,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", + "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -21291,6 +21313,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "devOptional": true, "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -21645,7 +21668,8 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/hachure-fill": { "version": "0.5.2", @@ -21679,6 +21703,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "devOptional": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -24790,7 +24815,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/json5": { "version": "2.2.3", @@ -26654,6 +26680,7 @@ "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", "license": "MIT", + "optional": true, "dependencies": { "escape-string-regexp": "^4.0.0" }, @@ -29425,6 +29452,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -29632,7 +29660,8 @@ "version": "1.24.3", "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.3.tgz", "integrity": "sha512-GeuPZO6U/LBJXvwdaqHbuUmoXiEdeCjWi/EG7Y1HNnDwJYuk6WUbNXpF6luSUY8yASul3cmUlLGrCCL1ZgVXqA==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/onnxruntime-node": { "version": "1.24.3", @@ -29640,6 +29669,7 @@ "integrity": "sha512-JH7+czbc8ALA819vlTgcV+Q214/+VjGeBHDjX81+ZCD0PCVCIFGFNtT0V4sXG/1JXypKPgScQcB3ij/hk3YnTg==", "hasInstallScript": true, "license": "MIT", + "optional": true, "os": [ "win32", "darwin", @@ -29656,6 +29686,7 @@ "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0-dev.20260416-b7804b056c.tgz", "integrity": "sha512-MD6Ss4GSpQBo6zqoJzyT9LRbKYs7x/JVN23FT24EcEvlqF4VuzPOeH6X38orZPKHQDbprn7K+SBpu0/mj2CQiw==", "license": "MIT", + "optional": true, "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", @@ -29669,13 +29700,15 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "optional": true }, "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { "version": "1.24.0-dev.20251116-b39e144322", "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.24.0-dev.20251116-b39e144322.tgz", "integrity": "sha512-BOoomdHYmNRL5r4iQ4bMvsl2t0/hzVQ3OM3PHD0gxeXu1PmggqBv3puZicEUVOA3AtHHYmqZtjMj9FOfGrATTw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/open": { "version": "11.0.1", @@ -30906,7 +30939,8 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/playwright": { "version": "1.62.1", @@ -31861,6 +31895,7 @@ "version": "7.6.5", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "devOptional": true, "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -31884,6 +31919,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/proxy-addr": { @@ -33280,6 +33316,7 @@ "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", "license": "BSD-3-Clause", + "optional": true, "dependencies": { "boolean": "^3.0.1", "detect-node": "^2.0.4", @@ -33655,7 +33692,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/send": { "version": "1.2.1", @@ -33688,6 +33726,7 @@ "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", "license": "MIT", + "optional": true, "dependencies": { "type-fest": "^0.13.1" }, @@ -33703,6 +33742,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", "license": "(MIT OR CC0-1.0)", + "optional": true, "engines": { "node": ">=10" }, @@ -34474,7 +34514,8 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "optional": true }, "node_modules/sql.js": { "version": "1.14.2", @@ -36187,6 +36228,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "devOptional": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { diff --git a/package.json b/package.json index bf2740f233..55cc574f9e 100644 --- a/package.json +++ b/package.json @@ -265,7 +265,6 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@huggingface/transformers": "^4.2.0", "@lobehub/icons": "^5.16.0", "@modelcontextprotocol/sdk": "^1.29.0", "@monaco-editor/react": "^4.7.0", @@ -308,7 +307,6 @@ "next-themes": "^0.4.6", "node-machine-id": "^1.1.12", "omniglyph": "^1.4.0", - "onnxruntime-node": "1.24.3", "open": "^11.0.1", "ora": "^9.4.1", "parse5": "^8.0.1", @@ -343,9 +341,11 @@ }, "optionalDependencies": { "@atjsh/llmlingua-2": "3.0.0", + "@huggingface/transformers": "^4.2.0", "better-sqlite3": "^13.0.2", "js-tiktoken": "^1.0.20", "keytar": "^7.9.0", + "onnxruntime-node": "1.24.3", "sqlite-vec": "^0.1.9", "tls-client-node": "^0.2.0", "wreq-js": "^3.0.0" diff --git a/skills/omni-webhooks/SKILL.md b/skills/omni-webhooks/SKILL.md index 251b5f5817..c60df46eca 100644 --- a/skills/omni-webhooks/SKILL.md +++ b/skills/omni-webhooks/SKILL.md @@ -1,12 +1,12 @@ --- name: omni-webhooks -description: Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries. +description: Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries. --- ## Overview -Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, provider.error, budget.exceeded, etc.) and manage delivery retries. +Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries. ## Authentication diff --git a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx index 7970c40a8e..8d8c5c5573 100644 --- a/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx +++ b/src/app/(dashboard)/dashboard/analytics/ComboHealthTab.tsx @@ -291,7 +291,9 @@ function ComboAutopilotPanel({ report }: { report: ComboAutopilotReport }) { icon="monitor_heart" label={t("comboHealthIssues")} value={report.summary.issueCount.toLocaleString()} - subValue={t("comboHealthActionable", { count: report.summary.actionableCount })} + subValue={t("comboHealthActionable", { + count: report.summary.suggestionCount ?? report.summary.actionableCount ?? 0, + })} /> typeof model?.id === "string" && /^sonar(-|$)/.test(model.id) + ); +} type ProviderModelsHeaderContext = { authType?: string; providerSpecificData?: unknown; @@ -659,6 +675,17 @@ export const PROVIDER_MODELS_CONFIG: Record = headers: { Accept: "application/json" }, parseResponse: parseClinepassRecommendedModels, }, + // Perplexity's /v1/models lists the Agent API catalog (vendor-prefixed agent + // ids), but chat only accepts the Sonar family on /chat/completions. Import + // must keep Sonar-family ids only (#11060). + perplexity: { + url: "https://api.perplexity.ai/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: parsePerplexitySonarModels, + }, cohere: { url: "https://api.cohere.com/v2/models", method: "GET", diff --git a/src/app/api/providers/[id]/route.ts b/src/app/api/providers/[id]/route.ts index 562dad0744..4f588571b8 100644 --- a/src/app/api/providers/[id]/route.ts +++ b/src/app/api/providers/[id]/route.ts @@ -121,7 +121,17 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: const { id } = await params; const validation = validateBody(updateProviderConnectionSchema, rawBody); if (isValidationFailure(validation)) { - return NextResponse.json({ error: validation.error }, { status: 400 }); + // never drop an operator's intent silently. Surface the rejected + // keys (field paths and unrecognized-key names) alongside the existing + // error envelope so clients and the UI can tell exactly what was refused. + const rejected = [ + ...validation.error.details.map((d) => d.field).filter(Boolean), + ...validation.error.details.flatMap((d) => d.keys ?? []), + ]; + return NextResponse.json( + { error: { ...validation.error, rejected } }, + { status: 400 } + ); } const body = validation.data; const { diff --git a/src/domain/configAudit.ts b/src/domain/configAudit.ts index c2701b2a21..873d3b046a 100644 --- a/src/domain/configAudit.ts +++ b/src/domain/configAudit.ts @@ -13,6 +13,8 @@ * - Optional human notes */ +import { getDbInstance } from "../lib/db/core"; + /** Types of configuration entities that can be audited */ export type AuditTarget = "provider" | "combo" | "policy" | "connection" | "settings"; @@ -72,10 +74,8 @@ export interface ConfigSnapshot { data: Record; } -// ── In-memory store ────────────────────────────────────────────────────────── -// In production, persist to SQLite alongside other domain state. +// ── SQLite-backed store ─────────────────────────────────────────────────────── -let auditLog: ConfigAuditEntry[] = []; let idCounter = 0; function generateId(): string { @@ -85,6 +85,40 @@ function generateId(): string { return `audit-${ts}-${seq}`; } +function db() { + return getDbInstance(); +} + +interface ConfigAuditRow { + id: string; + timestamp: string; + action: string; + target: string; + target_id: string; + target_name: string; + before_json: string | null; + after_json: string | null; + diff_json: string; + source: string; + note: string | null; +} + +function rowToEntry(row: ConfigAuditRow): ConfigAuditEntry { + return { + id: row.id, + timestamp: row.timestamp, + action: row.action as AuditAction, + target: row.target as AuditTarget, + targetId: row.target_id, + targetName: row.target_name, + before: row.before_json === null ? null : (JSON.parse(row.before_json) as Record | null), + after: row.after_json === null ? null : (JSON.parse(row.after_json) as Record | null), + source: row.source as AuditSource, + diff: JSON.parse(row.diff_json) as ConfigDiff, + note: row.note, + }; +} + /** * Compute a structured diff between two configuration states. */ @@ -159,12 +193,24 @@ export function recordChange( note: note ?? null, }; - auditLog.push(entry); - - // Keep log bounded (max 1000 entries in memory) - if (auditLog.length > 1000) { - auditLog = auditLog.slice(-1000); - } + db().prepare( + `INSERT INTO config_audit_log + (id, timestamp, action, target, target_id, target_name, before_json, after_json, diff_json, source, note) + VALUES + (@id, @timestamp, @action, @target, @targetId, @targetName, @beforeJson, @afterJson, @diffJson, @source, @note)` + ).run({ + id: entry.id, + timestamp: entry.timestamp, + action: entry.action, + target: entry.target, + targetId: entry.targetId, + targetName: entry.targetName, + beforeJson: before === null ? null : JSON.stringify(before), + afterJson: after === null ? null : JSON.stringify(after), + diffJson: JSON.stringify(entry.diff), + source: entry.source, + note: entry.note, + }); return entry; } @@ -181,42 +227,57 @@ export function getAuditLog(options?: { limit?: number; offset?: number; }): { entries: ConfigAuditEntry[]; total: number } { - let filtered = auditLog; + const where: string[] = []; + const params: Record = {}; if (options?.target) { - filtered = filtered.filter((e) => e.target === options.target); + where.push("target = @target"); + params.target = options.target; } if (options?.targetId) { - filtered = filtered.filter((e) => e.targetId === options.targetId); + where.push("target_id = @targetId"); + params.targetId = options.targetId; } if (options?.action) { - filtered = filtered.filter((e) => e.action === options.action); + where.push("action = @action"); + params.action = options.action; } if (options?.source) { - filtered = filtered.filter((e) => e.source === options.source); + where.push("source = @source"); + params.source = options.source; } if (options?.since) { - filtered = filtered.filter((e) => e.timestamp >= options.since!); + where.push("timestamp >= @since"); + params.since = options.since; } - const total = filtered.length; + const whereSql = where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""; - // Sort newest first - filtered = [...filtered].sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + const totalRow = db() + .prepare(`SELECT COUNT(*) AS c FROM config_audit_log ${whereSql}`) + .get(params) as { c: number }; + const total = totalRow.c; - // Paginate const offset = options?.offset ?? 0; const limit = options?.limit ?? 50; - filtered = filtered.slice(offset, offset + limit); - return { entries: filtered, total }; + const rows = db() + .prepare( + `SELECT * FROM config_audit_log ${whereSql} ORDER BY datetime(timestamp) DESC, id DESC LIMIT @limit OFFSET @offset` + ) + .all({ ...params, limit, offset }) as ConfigAuditRow[]; + + return { entries: rows.map(rowToEntry), total }; } /** * Get a specific audit entry by ID. */ export function getAuditEntry(id: string): ConfigAuditEntry | null { - return auditLog.find((e) => e.id === id) ?? null; + const row = db() + .prepare("SELECT * FROM config_audit_log WHERE id = @id") + .get({ id }) as ConfigAuditRow | undefined; + return row ? rowToEntry(row) : null; } /** @@ -260,19 +321,23 @@ export function getAuditSummary(): { const byAction: Record = {}; const bySource: Record = {}; - for (const entry of auditLog) { - byTarget[entry.target] = (byTarget[entry.target] || 0) + 1; - byAction[entry.action] = (byAction[entry.action] || 0) + 1; - bySource[entry.source] = (bySource[entry.source] || 0) + 1; + const rows = db() + .prepare("SELECT * FROM config_audit_log ORDER BY datetime(timestamp) DESC, id DESC") + .all() as ConfigAuditRow[]; + + for (const row of rows) { + byTarget[row.target] = (byTarget[row.target] || 0) + 1; + byAction[row.action] = (byAction[row.action] || 0) + 1; + bySource[row.source] = (bySource[row.source] || 0) + 1; } return { - totalEntries: auditLog.length, + totalEntries: rows.length, byTarget, byAction, bySource, - oldestEntry: auditLog.length > 0 ? auditLog[0].timestamp : null, - newestEntry: auditLog.length > 0 ? auditLog[auditLog.length - 1].timestamp : null, + oldestEntry: rows.length > 0 ? rows[rows.length - 1].timestamp : null, + newestEntry: rows.length > 0 ? rows[0].timestamp : null, }; } @@ -280,6 +345,6 @@ export function getAuditSummary(): { * Reset the audit log. Useful for testing. */ export function resetAuditLog(): void { - auditLog = []; + db().prepare("DELETE FROM config_audit_log").run(); idCounter = 0; } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index ec091011e9..ff778753c6 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5692,8 +5692,8 @@ "aggregatorsGateways": "Aggregators Gateways", "enterpriseCloud": "Enterprise & Cloud", "apiFormatLabel": "Api Format Label", - "apiKeyOptionalHint": "Api Key Optional Hint", - "apiKeyOptionalLabel": "Api Key Optional Label", + "apiKeyOptionalHint": "Leave empty if your local setup or provider does not require authentication.", + "apiKeyOptionalLabel": "API Key (optional)", "apiRegionChina": "Api Region China", "apiRegionHint": "Api Region Hint", "apiRegionInternational": "Api Region International", diff --git a/src/i18n/messages/vi.json b/src/i18n/messages/vi.json index ca70aafdef..e04b206661 100644 --- a/src/i18n/messages/vi.json +++ b/src/i18n/messages/vi.json @@ -6271,18 +6271,18 @@ "webSessionGuideStep4": "Dán vào đây và kiểm tra kết nối. Nếu nó ngừng hoạt động, hãy đăng nhập lại và thay thế bằng một giá trị mới.", "harImportButtonLabel": "Nhập tệp .har", "harImportButtonBusy": "Đang nhập…", - "harImportButtonHint": "Xuất từ thẻ Network trong DevTools sau khi gửi ít nhất một tin nhắn trò chuyện.", - "harImportStatusValid": "Đã nhập — còn hiệu lực trong khoảng {minutes} phút.", - "harImportStatusExpiringSoon": "Đã nhập — chỉ còn hiệu lực trong khoảng {minutes} phút nữa.", - "harImportStatusExpired": "Đã nhập, nhưng token này đã hết hạn ({minutes} phút trước) — hãy xuất một tệp HAR mới.", - "harImportStatusUnknownExpiry": "Đã nhập. Không đọc được thời gian hết hạn.", - "harImportErrorNotJson": "Tệp đó không phải JSON hợp lệ — nó có thực sự là tệp xuất .har không?", - "harImportErrorNoEntries": "Tệp HAR này không có bản ghi mạng nào được lưu.", - "harImportErrorNoChathubUrl": "Không tìm thấy kết nối trò chuyện Copilot trong tệp HAR này. Hãy gửi ít nhất một tin nhắn trò chuyện trong m365.cloud.microsoft trước khi xuất.", - "harImportErrorUnparsableUrl": "Đã tìm thấy kết nối trò chuyện, nhưng không đọc được URL của nó.", - "harImportErrorMissingFields": "Đã tìm thấy kết nối trò chuyện, nhưng trong đó thiếu token.", - "harImportErrorReadFailed": "Không thể đọc tệp đó.", - "harImportErrorUnknown": "Không thể trích xuất thông tin xác thực từ tệp HAR đó.", + "harImportButtonHint": "Xuất từ thẻ Network của DevTools sau khi gửi ít nhất một tin nhắn chat.", + "harImportStatusValid": "Đã nhập — hợp lệ trong ~{minutes} phút.", + "harImportStatusExpiringSoon": "Đã nhập — chỉ còn hợp lệ ~{minutes} phút nữa.", + "harImportStatusExpired": "Đã nhập, nhưng token này đã hết hạn ({minutes} phút trước) — hãy xuất một HAR mới.", + "harImportStatusUnknownExpiry": "Đã nhập. Không đọc được thời hạn.", + "harImportErrorNotJson": "Tệp đó không phải JSON hợp lệ — có đúng là bản xuất .har không?", + "harImportErrorNoEntries": "HAR này không có mục network nào được ghi lại.", + "harImportErrorNoChathubUrl": "Không tìm thấy kết nối Copilot chat trong HAR này. Hãy gửi ít nhất một tin nhắn chat trong m365.cloud.microsoft trước khi xuất.", + "harImportErrorUnparsableUrl": "Tìm thấy kết nối chat, nhưng không đọc được URL của nó.", + "harImportErrorMissingFields": "Tìm thấy kết nối chat, nhưng token bị thiếu trong đó.", + "harImportErrorReadFailed": "Không đọc được tệp đó.", + "harImportErrorUnknown": "Không trích xuất được thông tin xác thực từ tệp HAR đó.", "webSessionSecurityHint": "Hãy coi đây như mật khẩu: nó có thể truy cập vào tài khoản web đã đăng nhập của bạn cho đến khi hết hạn hoặc bị thu hồi.", "webNoAuthGuideTitle": "Không yêu cầu thông tin xác thực", "webNoAuthGuideBody": "{provider} không cần khóa API hoặc cookie. Lưu kết nối để sử dụng endpoint web miễn phí của nó.", @@ -12221,7 +12221,7 @@ }, "omni-webhooks": { "name": "Webhook", - "description": "Đăng ký, liệt kê, kiểm tra và xóa các endpoint webhook. Cấu hình đăng ký sự kiện (request.completed, request.failed, quota.exceeded, v.v.) và quản lý việc thử lại khi gửi." + "description": "Đăng ký, liệt kê, kiểm thử và xoá các endpoint webhook. Cấu hình đăng ký sự kiện (request.completed, request.failed, quota.exceeded, v.v.) và quản lý thử lại giao hàng." }, "omni-mcp": { "name": "Máy chủ MCP", diff --git a/src/lib/config/runtimeSettings.ts b/src/lib/config/runtimeSettings.ts index dc5e9d720d..bf615749ba 100644 --- a/src/lib/config/runtimeSettings.ts +++ b/src/lib/config/runtimeSettings.ts @@ -1,5 +1,9 @@ import { clearHealthCheckLogCache } from "@/lib/tokenHealthCheck"; import { setCustomBannedSignals } from "@omniroute/open-sse/services/accountFallback.ts"; +import { + setOperatorProviderErrorRules, + type OperatorProviderErrorRule, +} from "@omniroute/open-sse/config/providerErrorRules.ts"; import { isAutomatedTestProcess } from "@/shared/utils/testProcess"; type JsonRecord = Record; @@ -46,6 +50,7 @@ interface RuntimeSettingsSnapshot { systemTransforms: unknown; authzBypass: AuthzBypassSnapshot; customBannedSignals: string[]; + providerErrorRules: Record | null; } // Default bypass policy: kill-switch on, `/api/mcp/` bypassable. Mirrors the @@ -72,6 +77,7 @@ const DEFAULT_RUNTIME_SETTINGS_SNAPSHOT: RuntimeSettingsSnapshot = { systemTransforms: null, authzBypass: DEFAULT_AUTHZ_BYPASS_SNAPSHOT, customBannedSignals: [], + providerErrorRules: null, }; let lastAppliedSnapshot: RuntimeSettingsSnapshot | null = null; @@ -138,6 +144,34 @@ function normalizeStringArray(value: unknown): string[] { ); } +/** + * Defensive shape-check of operator-declared error rules pulled from settings. + * The settings schema already validates this on write; this guard prevents a + * malformed stored value (or an unexpected shape) from crashing the + * error-classification hot path. Returns null when the value is missing or not + * a record of non-empty rule arrays. + */ +function normalizeOperatorProviderErrorRules( + value: unknown +): Record | null { + if (value === null || typeof value !== "object") return null; + const record = value as Record; + const result: Record = {}; + for (const [provider, list] of Object.entries(record)) { + if (!Array.isArray(list) || list.length === 0) continue; + const rules = list.filter( + (entry): entry is OperatorProviderErrorRule => + !!entry && + typeof entry === "object" && + typeof (entry as OperatorProviderErrorRule).status === "number" && + typeof (entry as OperatorProviderErrorRule).match === "string" && + typeof (entry as OperatorProviderErrorRule).scope === "string" + ); + if (rules.length > 0) result[provider.toLowerCase()] = rules; + } + return Object.keys(result).length > 0 ? result : null; +} + function normalizeStringRecord(value: unknown): Record { const record = toRecord(parseStoredJson(value, "modelAliases")); const entries = Object.entries(record) @@ -244,6 +278,7 @@ export function buildRuntimeSettingsSnapshot( systemTransforms: parseStoredJson(settings.systemTransforms, "systemTransforms"), authzBypass: normalizeAuthzBypass(settings), customBannedSignals: normalizeStringArray(settings.customBannedSignals), + providerErrorRules: normalizeOperatorProviderErrorRules(settings.providerErrorRules), }; } @@ -540,6 +575,13 @@ export async function applyRuntimeSettings( markChanged("bannedSignals"); } + if ( + force || + hasChanged(currentSnapshot.providerErrorRules, previousSnapshot.providerErrorRules) + ) { + setOperatorProviderErrorRules(currentSnapshot.providerErrorRules ?? undefined); + } + lastAppliedSnapshot = currentSnapshot; return changes; } diff --git a/src/lib/db/cleanup.ts b/src/lib/db/cleanup.ts index 617bf4c226..60e288bf4b 100644 --- a/src/lib/db/cleanup.ts +++ b/src/lib/db/cleanup.ts @@ -193,6 +193,31 @@ export async function cleanupMcpAudit(): Promise { return result; } +/** + * Clean up old config_audit_log based on retention settings. + */ +export async function cleanupConfigAudit(retentionDays = getRetentionSettings().configAudit): Promise { + const db = getDbInstance(); + const result: CleanupResult = { deleted: 0, errors: 0 }; + + try { + const stmt = db.prepare( + "DELETE FROM config_audit_log WHERE datetime(timestamp) < datetime('now', '-' || ? || ' days')" + ); + const runResult = stmt.run(String(retentionDays)); + result.deleted = runResult.changes; + + console.log( + `[Cleanup] Deleted ${result.deleted} config_audit_log older than ${retentionDays} days` + ); + } catch (err: unknown) { + console.error("[Cleanup] Error cleaning config_audit_log:", err); + result.errors++; + } + + return result; +} + /** * Clean up old a2a_task_events based on retention settings. */ @@ -420,6 +445,7 @@ export async function runAutoCleanup(): Promise<{ usageHistory: await cleanupUsageHistory(), compressionAnalytics: await cleanupCompressionAnalytics(), mcpAudit: await cleanupMcpAudit(), + configAudit: await cleanupConfigAudit(), a2aEvents: await cleanupA2aEvents(), memoryEntries: await cleanupMemoryEntries(), domainCostHistory: await cleanupDomainCostHistory(), diff --git a/src/lib/db/databaseSettings.ts b/src/lib/db/databaseSettings.ts index e18f2a66bd..0a12729242 100644 --- a/src/lib/db/databaseSettings.ts +++ b/src/lib/db/databaseSettings.ts @@ -46,6 +46,7 @@ const LEGACY_FLAT_KEYS: { quotaSnapshots: ["quotaSnapshots"], compressionAnalytics: ["compressionAnalytics"], mcpAudit: ["mcpAudit"], + configAudit: ["configAudit"], a2aEvents: ["a2aEvents"], callLogs: ["callLogs"], usageHistory: ["usageHistory"], diff --git a/src/lib/db/migrations/046_database_settings.sql b/src/lib/db/migrations/046_database_settings.sql index 57fd15c903..6fb9864390 100644 --- a/src/lib/db/migrations/046_database_settings.sql +++ b/src/lib/db/migrations/046_database_settings.sql @@ -25,6 +25,7 @@ INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSetting INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'quotaSnapshots', '90'); INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'compressionAnalytics', '30'); INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'mcpAudit', '30'); +INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'configAudit', '30'); INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'a2aEvents', '30'); INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'callLogs', '90'); INSERT OR IGNORE INTO key_value (namespace, key, value) VALUES ('databaseSettings', 'usageHistory', '365'); diff --git a/src/lib/db/migrations/161_config_audit_log.sql b/src/lib/db/migrations/161_config_audit_log.sql new file mode 100644 index 0000000000..0aad91ace2 --- /dev/null +++ b/src/lib/db/migrations/161_config_audit_log.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS config_audit_log ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL, + target_id TEXT NOT NULL, + target_name TEXT NOT NULL, + before_json TEXT, + after_json TEXT, + diff_json TEXT NOT NULL, + source TEXT NOT NULL, + note TEXT +); +CREATE INDEX IF NOT EXISTS idx_config_audit_log_target_created ON config_audit_log(target, timestamp); +CREATE INDEX IF NOT EXISTS idx_config_audit_log_created ON config_audit_log(timestamp); diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index 3b02933e11..fbab6817fd 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -627,15 +627,26 @@ export async function createProviderConnection(data: JsonRecord) { // to no-overrides) keeps the field present on the returned object so the // UI can tell "field was read, no overrides" apart from "field absent." if ("quotaWindowThresholds" in connection) { - connection.quotaWindowThresholds = sanitizeQuotaWindowThresholds( - connection.quotaWindowThresholds - ); + const result = sanitizeQuotaWindowThresholds(connection.quotaWindowThresholds); + if (result.rejected.length > 0) { + throw new Error( + `Refusing to persist quotaWindowThresholds with rejected keys: ${result.rejected.join(", ")}` + ); + } + connection.quotaWindowThresholds = result.sanitized; } // Same sanitization for rateLimitOverrides — keep in-memory representation - // in sync with what gets persisted. + // in sync with what gets persisted. Reject (don't silently drop) invalid + // keys/values so a direct DB writer can't lose operator intent. if ("rateLimitOverrides" in connection) { - connection.rateLimitOverrides = sanitizeRateLimitOverrides(connection.rateLimitOverrides); + const result = sanitizeRateLimitOverrides(connection.rateLimitOverrides); + if (result.rejected.length > 0) { + throw new Error( + `Refusing to persist rateLimitOverrides with rejected keys: ${result.rejected.join(", ")}` + ); + } + connection.rateLimitOverrides = result.sanitized; } _insertConnectionRow(db, encryptConnectionFields({ ...connection })); @@ -849,13 +860,24 @@ export async function updateProviderConnection(id: string, data: JsonRecord) { // Mirror the sanitization the create path applies — keep the returned // object in lockstep with what we persist. if ("quotaWindowThresholds" in merged) { - const sanitized = sanitizeQuotaWindowThresholds(merged.quotaWindowThresholds); + const result = sanitizeQuotaWindowThresholds(merged.quotaWindowThresholds); + if (result.rejected.length > 0) { + throw new Error( + `Refusing to persist quotaWindowThresholds with rejected keys: ${result.rejected.join(", ")}` + ); + } // For updates we always carry the key forward (even as null) so the read - // path surfaces the cleared state to callers that just patched it. - merged.quotaWindowThresholds = sanitized; + // path surfaces the cleared state to callers that merged it. + merged.quotaWindowThresholds = result.sanitized; } if ("rateLimitOverrides" in merged) { - merged.rateLimitOverrides = sanitizeRateLimitOverrides(merged.rateLimitOverrides); + const result = sanitizeRateLimitOverrides(merged.rateLimitOverrides); + if (result.rejected.length > 0) { + throw new Error( + `Refusing to persist rateLimitOverrides with rejected keys: ${result.rejected.join(", ")}` + ); + } + merged.rateLimitOverrides = result.sanitized; } const existingRecord = toRecord(existing); diff --git a/src/lib/db/providers/columns.ts b/src/lib/db/providers/columns.ts index b32f653f7c..f06f948cd2 100644 --- a/src/lib/db/providers/columns.ts +++ b/src/lib/db/providers/columns.ts @@ -64,20 +64,37 @@ export function normalizeBooleanColumn(value: unknown, fallback: boolean): boole return fallback; } +// Result of sanitizing a per-connection overrides/threshold map. `sanitized` +// is the cleaned value (or null when it collapses to nothing); `rejected` +// lists every key that was refused so callers can fail loudly +// instead of silently dropping the operator's input. +export type SanitizeResult = { + sanitized: Record | null; + rejected: string[]; +}; + // Sanitize the per-connection rate limit overrides map: keep only known -// fields with valid numeric values. Called once at each write-path boundary. -export function sanitizeRateLimitOverrides(value: unknown): Record | null { - if (value === null || value === undefined) return null; - if (typeof value !== "object" || Array.isArray(value)) return null; +// fields with valid non-negative integer values. Called once at each +// write-path boundary. Unknown keys and invalid values go into `rejected` +// rather than being dropped in silence. +export function sanitizeRateLimitOverrides(value: unknown): SanitizeResult { + if (value === null || value === undefined) return { sanitized: null, rejected: [] }; + if (typeof value !== "object" || Array.isArray(value)) return { sanitized: null, rejected: [] }; const allowedKeys = new Set(["rpm", "tpm", "tpd", "minTime", "maxConcurrent"]); + const rejected: string[] = []; const map: Record = {}; for (const [key, v] of Object.entries(value as Record)) { - if (!allowedKeys.has(key)) continue; + if (!allowedKeys.has(key)) { + rejected.push(key); + continue; + } if (typeof v === "number" && Number.isInteger(v) && v >= 0) { map[key] = v; + } else { + rejected.push(key); } } - return Object.keys(map).length === 0 ? null : map; + return { sanitized: Object.keys(map).length === 0 ? null : map, rejected }; } // Serialize an already-sanitized map for SQLite TEXT storage. @@ -91,20 +108,29 @@ export function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" ? (value as JsonRecord) : {}; } -// Sanitize the per-window threshold map: keep only 0-100 integer values. -// Called once at each write-path boundary (createProviderConnection + -// updateProviderConnection) so both the in-memory return and the persisted -// row share the same shape. Serialization below trusts this output. -export function sanitizeQuotaWindowThresholds(value: unknown): Record | null { - if (value === null || value === undefined) return null; - if (typeof value !== "object" || Array.isArray(value)) return null; +// Sanitize the per-window threshold map: keep only 0-100 integer values with +// keys no longer than 64 chars. Called once at each write-path boundary +// (createProviderConnection + updateProviderConnection) so both the in-memory +// return and the persisted row share the same shape. Serialization below +// trusts this output. Invalid keys/values go into `rejected` rather than being +// dropped in silence. +export function sanitizeQuotaWindowThresholds(value: unknown): SanitizeResult { + if (value === null || value === undefined) return { sanitized: null, rejected: [] }; + if (typeof value !== "object" || Array.isArray(value)) return { sanitized: null, rejected: [] }; + const rejected: string[] = []; const map: Record = {}; for (const [key, v] of Object.entries(value as Record)) { + if (key.length > 64) { + rejected.push(key); + continue; + } if (typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 100) { map[key] = v; + } else { + rejected.push(key); } } - return Object.keys(map).length === 0 ? null : map; + return { sanitized: Object.keys(map).length === 0 ? null : map, rejected }; } export function toStringOrNull(value: unknown): string | null { diff --git a/src/lib/monitoring/comboHealthAutopilot.ts b/src/lib/monitoring/comboHealthAutopilot.ts index 71a1049483..24eba32a63 100644 --- a/src/lib/monitoring/comboHealthAutopilot.ts +++ b/src/lib/monitoring/comboHealthAutopilot.ts @@ -16,6 +16,7 @@ import type { ComboForecastMetrics, ComboForecastResponse, ComboForecastRiskLevel, + ProviderAutopilotReport, ComboHealthMetrics, ComboHealthResponse, ComboRecord, @@ -34,6 +35,7 @@ export interface ComboHealthAutopilotOptions { combos?: ComboRecord[]; healthResponse?: ComboHealthResponse; forecastResponse?: ComboForecastResponse; + providerHealthResponse?: ProviderAutopilotReport; } type ProviderIssueView = { @@ -103,7 +105,12 @@ function actionSet( case "open_combo_editor": return action(type, "Open combo editor", target, "/dashboard/combos"); case "run_combo_test": - return action(type, "Run combo test", target, "/dashboard/combos"); + return action( + type, + "Run combo test", + target, + `/dashboard/combos?test=${encodeURIComponent(target.comboId)}` + ); case "open_provider_health_autopilot": return action(type, "Open provider autopilot", target, "/dashboard/health"); case "review_quota_limits": @@ -447,7 +454,8 @@ export async function buildComboHealthAutopilotReport( now: options.now, combos: combosSnapshot, }), - buildProviderHealthAutopilotReport({ includeHealthy: false, includeActions: false }), + options.providerHealthResponse ?? + buildProviderHealthAutopilotReport({ includeHealthy: false, includeActions: false }), ]); const forecastsByComboId = new Map(forecast.combos.map((entry) => [entry.comboId, entry])); @@ -470,7 +478,7 @@ export async function buildComboHealthAutopilotReport( const degradedCount = allCombos.filter((combo) => combo.state === "degraded").length; const healthyCount = allCombos.filter((combo) => combo.state === "healthy").length; const issueCount = allCombos.reduce((sum, combo) => sum + combo.issues.length, 0); - const actionableCount = allCombos.reduce( + const suggestionCount = allCombos.reduce( (sum, combo) => sum + combo.issues.reduce((issueSum, issue) => issueSum + issue.actions.length, 0), 0 @@ -487,7 +495,8 @@ export async function buildComboHealthAutopilotReport( degradedCount, downCount, issueCount, - actionableCount, + suggestionCount, + actionableCount: suggestionCount, }, combos, }; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 23f9c53b39..6b4fd86a50 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -237,7 +237,6 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean { const EXPLICIT_OPTIONAL_APIKEY_PROVIDER_IDS = new Set([ "searxng-search", "firecrawl", - "pollinations", "copilot-web", "hackclub", "g4f-groq", diff --git a/src/shared/services/opencodeConfig.ts b/src/shared/services/opencodeConfig.ts index a4bbd65591..a91ca5069c 100644 --- a/src/shared/services/opencodeConfig.ts +++ b/src/shared/services/opencodeConfig.ts @@ -84,11 +84,29 @@ export const buildOpenCodeProviderConfig = ({ }; }; +export const buildOpenCodeV2ProviderConfig = ( + input: OpenCodeConfigInput +): Record => { + const v1Config = buildOpenCodeProviderConfig(input); + return { + name: v1Config.name, + package: "@opencode-ai/ai/providers/openai-compatible", + settings: { + baseURL: v1Config.options.baseURL, + apiKey: v1Config.options.apiKey, + }, + models: v1Config.models, + }; +}; + export const buildOpenCodeConfigDocument = (input: OpenCodeConfigInput) => ({ $schema: "https://opencode.ai/config.json", provider: { omniroute: buildOpenCodeProviderConfig(input), }, + providers: { + omniroute: buildOpenCodeV2ProviderConfig(input), + }, }); export const mergeOpenCodeConfig = ( @@ -100,18 +118,18 @@ export const mergeOpenCodeConfig = ( ? existingConfig : {}; - // Same guard as the root above, one level down. Spreading a non-object here - // does not throw, it splays the value into index keys: an existing - // `"provider": ["a", "b"]` merged to `{"0": "a", "1": "b", omniroute: ... }` - // and a string was exploded one character per key. mergeOpenCodeConfigText - // refuses the same input outright, so the two disagreed on what to do with a - // malformed config. const existingProvider = (safeConfig as Record).provider; const safeProvider = existingProvider && typeof existingProvider === "object" && !Array.isArray(existingProvider) ? (existingProvider as Record) : {}; + const existingProviders = (safeConfig as Record).providers; + const safeProviders = + existingProviders && typeof existingProviders === "object" && !Array.isArray(existingProviders) + ? (existingProviders as Record) + : {}; + return { ...safeConfig, $schema: safeConfig.$schema || "https://opencode.ai/config.json", @@ -119,6 +137,10 @@ export const mergeOpenCodeConfig = ( ...safeProvider, omniroute: buildOpenCodeProviderConfig(input), }, + providers: { + ...safeProviders, + omniroute: buildOpenCodeV2ProviderConfig(input), + }, }; }; @@ -127,6 +149,7 @@ export const mergeOpenCodeConfigText = ( input: OpenCodeConfigInput ) => { const providerConfig = buildOpenCodeProviderConfig(input); + const v2ProviderConfig = buildOpenCodeV2ProviderConfig(input); const content = typeof existingText === "string" ? existingText : ""; const trimmedContent = content.trim(); @@ -161,6 +184,11 @@ export const mergeOpenCodeConfigText = ( const providerEdits = modify(nextText, ["provider", "omniroute"], providerConfig, { formattingOptions: { insertSpaces: true, tabSize: 2 }, }); + nextText = applyEdits(nextText, providerEdits); - return applyEdits(nextText, providerEdits); + const v2ProviderEdits = modify(nextText, ["providers", "omniroute"], v2ProviderConfig, { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }); + + return applyEdits(nextText, v2ProviderEdits); }; diff --git a/src/shared/types/utilization.ts b/src/shared/types/utilization.ts index 36f0d7dabc..0a1e9dc900 100644 --- a/src/shared/types/utilization.ts +++ b/src/shared/types/utilization.ts @@ -260,7 +260,9 @@ export interface ComboAutopilotReport { degradedCount: number; downCount: number; issueCount: number; - actionableCount: number; + suggestionCount: number; + /** @deprecated Use suggestionCount instead. Kept as an alias for backward compatibility; remove after 2 releases. */ + actionableCount?: number; }; combos: ComboAutopilotCombo[]; } diff --git a/src/shared/validation/helpers.ts b/src/shared/validation/helpers.ts index 4e486c7287..c811d6d4bf 100644 --- a/src/shared/validation/helpers.ts +++ b/src/shared/validation/helpers.ts @@ -4,6 +4,10 @@ import { z } from "zod"; type ValidationErrorDetail = { field: string; message: string; + // Present for `unrecognized_keys` issues: the unknown key names that were + // refused (e.g. a typo'd override key). Surfaced by callers so clients can + // tell exactly which keys were rejected. + keys?: string[]; }; type ValidationErrorPayload = { @@ -45,6 +49,9 @@ export function validateBody( details: issues.map((e) => ({ field: e.path.join("."), message: e.message, + ...(("keys" in e && (e as { keys?: string[] }).keys) + ? { keys: (e as { keys: string[] }).keys } + : {}), })), }, }; diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index b3272f70fb..4cfe7afff4 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -420,6 +420,25 @@ export const providerNodeValidateSchema = z.object({ modelId: z.string().trim().max(200).optional().or(z.literal("")), }); +// rate-limit override numeric fields must reject operator intent loss. +// `z.coerce.number()` silently turns "" into 0 and "60abc" into NaN, which +// would drop or distort the value instead of rejecting it. Preprocess first so +// an empty/non-numeric string fails validation (surfaced as a 400), while still +// coercing legit numeric strings like "60". +function rateLimitOverrideNumber(max: number) { + return z.preprocess( + (raw) => { + if (typeof raw === "string") { + if (raw.trim() === "") return NaN; + const parsed = Number(raw); + return Number.isNaN(parsed) ? raw : parsed; + } + return raw; + }, + z.coerce.number().int().min(0).max(max) + ); +} + export const updateProviderConnectionSchema = z .object({ name: z.string().max(200).optional(), @@ -468,17 +487,24 @@ export const updateProviderConnectionSchema = z projectId: z.union([z.string(), z.null()]).optional(), // Per-connection rate limit overrides — overrides the global RequestQueueSettings // for this connection. Set to null to clear all overrides. + // Per-connection rate limit overrides — overrides the global + // RequestQueueSettings for this connection. Set to null to clear all + // overrides. `.strict()` rejects unknown keys (e.g. a typo'd `tmp`) with a + // 400 instead of silently stripping them: the operator's intent is + // never dropped without an error. `.nullable()` (rather than a + // `z.union([z.null(), …])`) keeps the `unrecognized_keys` issue at the top + // level so the rejected key name survives into the 400 response. rateLimitOverrides: z - .union([ - z.null(), - z.object({ - rpm: z.coerce.number().int().min(0).max(1_000_000).optional(), - tpm: z.coerce.number().int().min(0).max(100_000_000).optional(), - tpd: z.coerce.number().int().min(0).max(10_000_000_000).optional(), - minTime: z.coerce.number().int().min(0).max(60_000).optional(), - maxConcurrent: z.coerce.number().int().min(0).max(10_000).optional(), - }), - ]) + .object({ + rpm: rateLimitOverrideNumber(1_000_000).optional(), + tpm: rateLimitOverrideNumber(100_000_000).optional(), + tpd: rateLimitOverrideNumber(10_000_000_000).optional(), + minTime: rateLimitOverrideNumber(60_000).optional(), + maxConcurrent: rateLimitOverrideNumber(10_000).optional(), + }) + .partial() + .strict() + .nullable() .optional(), proxyEnabled: z.boolean().optional(), perKeyProxyEnabled: z.boolean().optional(), diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 388e3f73c7..beefb5fc8f 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -259,6 +259,48 @@ export const updateSettingsSchema = z.object({ }) ) .optional(), + /** + * Operator-declared per-provider error rules. Consulted BEFORE the built-in + * `providerRuleRegistry` in open-sse/config/providerErrorRules.ts so an + * operator can add a scope/cooldown/reason override for a provider without + * editing the catalog. Matches are plain case-insensitive SUBSTRINGS of the + * error body (never RegExp) to keep the classification hot path ReDoS-safe. + * Bounded to 50 rules total so a misconfigured setting cannot blow up the + * matcher. + */ + providerErrorRules: z + .record( + z.string().trim().min(1).max(100), + z.array( + z.object({ + status: z.number().int().min(100).max(599), + match: z.string().min(1).max(200), + scope: z.enum(["model", "provider", "connection"]), + reason: z + .enum([ + "auth_error", + "quota_exhausted", + "rate_limit_exceeded", + "model_capacity", + "server_error", + "unknown", + ]) + .optional(), + cooldownMs: z.number().int().min(0).max(86_400_000).optional(), + }) + ) + ) + .optional() + .superRefine((value, ctx) => { + if (!value) return; + const total = Object.values(value).reduce((n, rules) => n + rules.length, 0); + if (total > 50) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `providerErrorRules: at most 50 rules total, got ${total}`, + }); + } + }), // #6168: global session-stickiness opt-out (per-combo config overrides this). disableSessionStickiness: z.boolean().optional(), /** Keep eligible combo targets close to the provider-side prompt cache. */ diff --git a/src/types/databaseSettings.ts b/src/types/databaseSettings.ts index 6bcc210e5f..2b4820e198 100644 --- a/src/types/databaseSettings.ts +++ b/src/types/databaseSettings.ts @@ -44,6 +44,7 @@ export interface DatabaseSettings { quotaSnapshots: number; compressionAnalytics: number; mcpAudit: number; + configAudit: number; a2aEvents: number; callLogs: number; usageHistory: number; @@ -114,6 +115,7 @@ export const DEFAULT_DATABASE_SETTINGS: Omit>(relPath: string): T { return JSON.parse(readFileSync(join(repoRoot, relPath), "utf8")) as T; } -test("@huggingface/transformers is a regular dependency so npm ci never skips it", () => { - // #9962 deliberately moved @huggingface/transformers out of optionalDependencies: - // as an optional dep, npm silently skipped the whole subtree on Node 24/26 (old - // pin dragged onnxruntime-node@1.21.0 whose NAN build no longer compiles), which - // broke `npm ci`/`next build` with "Can't resolve @huggingface/transformers" - // (lazy import in src/lib/memory/embedding/transformersLocal.ts). As a regular - // dep with onnxruntime-node@~1.24.3 (napi prebuilds, no node-gyp) it stays - // installable and the memory embedding path requires() cleanly. +test("ONNX chain (@huggingface/transformers + onnxruntime-node) stays optional so Termux/Android installs succeed", () => { + // #11095: onnxruntime-node declares os ["win32","darwin","linux"], so while + // these lived in `dependencies` every npm install on Android/Termux aborted + // with a fatal EBADPLATFORM. As optionalDependencies npm skips only the + // unsupported-platform subtree (with a warning) and installs normally + // everywhere else. This deliberately reverses the MECHANISM of #9962 while + // keeping its goal: #9962's skip happened because the old onnxruntime-node@ + // 1.21.0 pin built from source (NAN) and failed to compile on Node 24/26; + // the current 1.24.3 pin ships napi prebuilds, so on supported platforms the + // chain always installs and `npm ci`/`next build` keep resolving it. On + // platforms where it IS skipped, both consumers degrade gracefully via lazy/ + // dynamic imports (asserted below). const pkg = readJson<{ dependencies?: Record; optionalDependencies?: Record; + overrides?: Record; }>("package.json"); assert.equal( pkg.dependencies?.["@huggingface/transformers"], + undefined, + "transformers must NOT be a hard dependency (fatal EBADPLATFORM on Android)" + ); + assert.equal( + pkg.optionalDependencies?.["@huggingface/transformers"], "^4.2.0", - "transformers must be a regular dependency (never optional) so npm ci cannot skip it" + "transformers must be an optionalDependency" ); - assert.equal(pkg.optionalDependencies?.["@huggingface/transformers"], undefined); -}); - -test("transformers + onnxruntime-node are regular dependencies (not optional)", () => { - const pkg = readJson<{ - dependencies?: Record; - optionalDependencies?: Record; - }>("package.json"); - assert.equal( pkg.dependencies?.["onnxruntime-node"], - "1.24.3", - "onnxruntime-node is a regular dep (napi prebuilds, installable on Node 24/26)" + undefined, + "onnxruntime-node must NOT be a hard dependency (fatal EBADPLATFORM on Android)" ); - assert.equal(pkg.optionalDependencies?.["onnxruntime-node"], undefined); + assert.equal( + pkg.optionalDependencies?.["onnxruntime-node"], + "1.24.3", + "onnxruntime-node must be an optionalDependency pinned in lockstep with the overrides pin" + ); + assert.equal( + pkg.overrides?.["onnxruntime-node"], + "1.24.3", + "the overrides pin must stay aligned with @huggingface/transformers' own pin (single-copy invariant)" + ); +}); +test("lockfile marks the whole ONNX chain optional", () => { const lock = readJson<{ packages: Record< string, @@ -51,26 +63,61 @@ test("transformers + onnxruntime-node are regular dependencies (not optional)", dependencies?: Record; optionalDependencies?: Record; } - >; + >; }>("package-lock.json"); assert.equal( - lock.packages[""]?.dependencies?.["@huggingface/transformers"], + lock.packages[""]?.optionalDependencies?.["@huggingface/transformers"], "^4.2.0", - "root lock dependencies must hold transformers as a regular (non-optional) dep" + "root lock optionalDependencies must hold transformers" ); - // Optional flag is only written `true` for genuinely optional packages; - // regular deps leave it absent/null. Assert each is NOT optional. - assert.ok( - !lock.packages["node_modules/@huggingface/transformers"]?.optional, - "transformers must not be marked optional in the lockfile" + assert.equal( + lock.packages[""]?.optionalDependencies?.["onnxruntime-node"], + "1.24.3", + "root lock optionalDependencies must hold onnxruntime-node" ); assert.ok( - !lock.packages["node_modules/onnxruntime-node"]?.optional, - "onnxruntime-node must not be marked optional in the lockfile" + lock.packages["node_modules/@huggingface/transformers"]?.optional, + "transformers must be marked optional in the lockfile" ); assert.ok( - !lock.packages["node_modules/onnxruntime-common"]?.optional, - "onnxruntime-common must not be marked optional in the lockfile" + lock.packages["node_modules/onnxruntime-node"]?.optional, + "onnxruntime-node must be marked optional in the lockfile" + ); + assert.ok( + lock.packages["node_modules/onnxruntime-common"]?.optional, + "onnxruntime-common must be marked optional in the lockfile" + ); +}); + +test("every @huggingface/transformers consumer loads it lazily so absent installs degrade gracefully", () => { + // If any module ever switches to a STATIC import of the optional chain, + // startup crashes on platforms where npm skipped it (Android/Termux). + // transformersLocal.ts must keep its lazy await import() (D8/D25); + // onnxWorker.ts must keep its runtime-variable dynamicImport indirection. + + const embeddingSrc = readFileSync( + join(repoRoot, "src/lib/memory/embedding/transformersLocal.ts"), + "utf8" + ); + assert.doesNotMatch( + embeddingSrc, + /^\s*import\s+(?:[^'"]*?\s+from\s+)?["']@huggingface\/transformers["']/m, + "transformersLocal.ts must not statically import @huggingface/transformers" + ); + assert.match( + embeddingSrc, + /await import\(["']@huggingface\/transformers["']\)/, + "transformersLocal.ts must load @huggingface/transformers via await import()" + ); + + const workerSrc = readFileSync( + join(repoRoot, "open-sse/services/compression/engines/llmlingua/onnxWorker.ts"), + "utf8" + ); + assert.doesNotMatch( + workerSrc, + /^\s*import\s+(?:[^'"]*?\s+from\s+)?["']@huggingface\/transformers["']/m, + "onnxWorker.ts must not statically import @huggingface/transformers" ); }); diff --git a/tests/unit/cline-model-format-11099.test.ts b/tests/unit/cline-model-format-11099.test.ts new file mode 100644 index 0000000000..3e5a87de43 --- /dev/null +++ b/tests/unit/cline-model-format-11099.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getModelsByProviderId } from "../../open-sse/config/providerModels.ts"; +import { parseClineRecommendedModels } from "../../open-sse/services/clinepassModels.ts"; + +test("#11099: Cline provider catalog model IDs use valid modelType/model format", () => { + const models = getModelsByProviderId("cline"); + assert.ok(models.length > 0, "cline provider must expose models"); + + for (const model of models) { + assert.match( + model.id, + /^[a-z0-9-]+-?[a-z0-9-]*\/[a-z0-9._:-]+$/i, + `Model ID '${model.id}' must follow provider/model format` + ); + assert.notEqual( + model.id.split("/")[0], + "zai", + "Model ID must use 'z-ai' instead of invalid 'zai'" + ); + } +}); + +test("#11099: parseClineRecommendedModels correctly extracts recommended/free models", () => { + const mockPayload = { + recommended: [ + { id: "moonshotai/kimi-k3", name: "kimi-k3" }, + { id: "x-ai/grok-4.5", name: "grok-4.5" }, + ], + free: [{ id: "deepseek/deepseek-v4-flash", name: "deepseek-v4-flash" }], + }; + + const parsed = parseClineRecommendedModels(mockPayload); + assert.equal(parsed.length, 3); + assert.equal(parsed[0].id, "moonshotai/kimi-k3"); + assert.equal(parsed[1].id, "x-ai/grok-4.5"); + assert.equal(parsed[2].id, "deepseek/deepseek-v4-flash"); +}); diff --git a/tests/unit/columns-validation.test.ts b/tests/unit/columns-validation.test.ts new file mode 100644 index 0000000000..41f70cddeb --- /dev/null +++ b/tests/unit/columns-validation.test.ts @@ -0,0 +1,24 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + sanitizeRateLimitOverrides, + sanitizeQuotaWindowThresholds, +} from "@/lib/db/providers/columns"; + +test("sanitizeRateLimitOverrides surfaces rejected keys (blocking, not silent)", () => { + const r = sanitizeRateLimitOverrides({ rpm: 10, foo: 1, tpm: -1 }); + assert.deepEqual(r.sanitized, { rpm: 10 }); + assert.deepEqual(r.rejected.sort(), ["foo", "tpm"]); +}); + +test("sanitizeQuotaWindowThresholds surfaces key-too-long and out-of-range", () => { + const r = sanitizeQuotaWindowThresholds({ ["a".repeat(65)]: 50, win: 101 }); + assert.ok(r.rejected.length >= 1); + assert.ok(r.rejected.includes("win")); +}); + +test("valid input yields no rejected keys", () => { + const r = sanitizeRateLimitOverrides({ rpm: 10, tpm: 20 }); + assert.deepEqual(r.rejected, []); + assert.deepEqual(r.sanitized, { rpm: 10, tpm: 20 }); +}); diff --git a/tests/unit/combo-health-autopilot-counter.test.ts b/tests/unit/combo-health-autopilot-counter.test.ts new file mode 100644 index 0000000000..1bd8330753 --- /dev/null +++ b/tests/unit/combo-health-autopilot-counter.test.ts @@ -0,0 +1,108 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import type { + ComboForecastResponse, + ComboHealthResponse, + ProviderAutopilotReport, +} from "../../src/shared/types/utilization.ts"; +import { buildComboHealthAutopilotReport } from "../../src/lib/monitoring/comboHealthAutopilot.ts"; + +function healthResponse(): ComboHealthResponse { + return { + timeRange: "24h", + combos: [ + { + comboId: "c1", + comboName: "my-combo", + strategy: "fallback", + models: [], + targetHealth: [ + { + executionKey: "e1", + stepId: "s1", + model: "m", + provider: "p", + connectionId: null, + label: null, + requests: 5, + successRate: 90, + avgLatencyMs: 100, + lastStatus: "error", + lastUsedAt: null, + quotaRemainingPct: 50, + quotaIsExhausted: false, + quotaTrend: "stable", + quotaScope: "provider", + }, + ], + quotaHealth: { providers: [], worstRemainingPct: 100 }, + usageSkew: { modelDistribution: [], giniCoefficient: 0 }, + performance: { avgLatencyMs: 100, successRate: 1.0, totalRequests: 10 }, + }, + ], + }; +} + +function forecastResponse(): ComboForecastResponse { + return { + timeRange: "24h", + horizon: "30d", + asOf: new Date(0).toISOString(), + method: "linear_history", + combos: [], + }; +} + +function providerHealthResponse(): ProviderAutopilotReport { + return { providers: [] } as unknown as ProviderAutopilotReport; +} + +function buildOptions() { + return { + range: "24h" as const, + horizon: "30d" as const, + healthResponse: healthResponse(), + forecastResponse: forecastResponse(), + providerHealthResponse: providerHealthResponse(), + }; +} + +describe("combo health autopilot counter", () => { + it("exposes suggestionCount and keeps actionableCount alias", async () => { + const report = await buildComboHealthAutopilotReport(buildOptions()); + assert.equal(typeof report.summary.suggestionCount, "number"); + assert.equal(report.summary.actionableCount, report.summary.suggestionCount); + const expected = report.combos.reduce( + (sum, combo) => + sum + combo.issues.reduce((issueSum, issue) => issueSum + issue.actions.length, 0), + 0 + ); + assert.equal(report.summary.suggestionCount, expected); + }); + + it("run_combo_test action links the dashboard with the combo id", async () => { + const report = await buildComboHealthAutopilotReport(buildOptions()); + const actions = report.combos.flatMap((combo) => combo.issues.flatMap((i) => i.actions)); + const runTest = actions.find((a) => a.type === "run_combo_test"); + assert.ok(runTest, "run_combo_test action should exist"); + assert.equal(typeof runTest.href, "string"); + assert.ok(runTest.href?.includes("c1"), "href must carry the combo id"); + assert.equal( + runTest.href?.includes("/api/combos/test?comboId="), + false, + "href must not target the GET-only API route (405)" + ); + }); + + it("keeps every action in manual mode", async () => { + const report = await buildComboHealthAutopilotReport(buildOptions()); + for (const combo of report.combos) { + for (const issue of combo.issues) { + for (const action of issue.actions) { + assert.equal(action.mode, "manual"); + } + } + } + }); +}); diff --git a/tests/unit/config-audit-persistence.test.ts b/tests/unit/config-audit-persistence.test.ts new file mode 100644 index 0000000000..4bad88bc5d --- /dev/null +++ b/tests/unit/config-audit-persistence.test.ts @@ -0,0 +1,124 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-config-audit-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const cleanup = await import("../../src/lib/db/cleanup.ts"); +const audit = await import("../../src/domain/configAudit.ts"); + +type CountRow = { c: number }; + +function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +function countRows(): number { + const db = core.getDbInstance(); + const row = db.prepare("SELECT COUNT(*) AS c FROM config_audit_log").get() as CountRow; + return row.c; +} + +function insertOldRow(id: string, daysAgo: number) { + const db = core.getDbInstance(); + const old = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString(); + db.prepare( + `INSERT INTO config_audit_log + (id, timestamp, action, target, target_id, target_name, before_json, after_json, diff_json, source, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + id, + old, + "update", + "provider", + "p1", + "P1", + null, + null, + JSON.stringify({ added: [], removed: [], changed: [], isEmpty: true }), + "api", + null + ); +} + +test.beforeEach(() => { + resetStorage(); +}); + +test.after(() => { + resetStorage(); +}); + +test("recordChange persists to SQLite, not memory", () => { + const db = core.getDbInstance(); + const tableRow = db + .prepare("SELECT count(*) as c FROM sqlite_master WHERE type='table' AND name='config_audit_log'") + .get() as CountRow; + assert.equal(tableRow.c, 1); + + const e = audit.recordChange("update", "provider", "p1", "My Provider", { a: 1 }, { a: 2 }, "api", null); + assert.equal(countRows(), 1); + + const { entries, total } = audit.getAuditLog({ target: "provider" }); + assert.equal(total, 1); + assert.equal(entries[0].id, e.id); + assert.deepEqual(entries[0].diff.changed, [{ key: "a", from: 1, to: 2 }]); +}); + +test("pagination + filters read from SQLite", () => { + audit.recordChange("create", "combo", "c1", "C1", null, { models: ["m1"] }, "dashboard"); + audit.recordChange("update", "combo", "c1", "C1", { models: ["m1"] }, { models: ["m1", "m2"] }, "api"); + + const { entries, total } = audit.getAuditLog({ target: "combo", limit: 1, offset: 0 }); + assert.equal(total, 2); + assert.equal(entries.length, 1); +}); + +test("getRollbackState returns the before snapshot", () => { + const e = audit.recordChange("update", "policy", "pol1", "Pol", { x: 1 }, { x: 2 }, "api"); + assert.deepEqual(audit.getRollbackState(e.id), { x: 1 }); +}); + +test("computeDiff stays pure", () => { + const d = audit.computeDiff({ a: 1 }, { a: 2, b: 3 }); + assert.deepEqual(d.added, ["b"]); + assert.deepEqual(d.changed, [{ key: "a", from: 1, to: 2 }]); +}); + +test("resetAuditLog clears persisted rows", () => { + audit.recordChange("update", "provider", "p1", "P1", { a: 1 }, { a: 2 }, "api"); + assert.equal(countRows(), 1); + audit.resetAuditLog(); + assert.equal(countRows(), 0); +}); + +test("cleanupConfigAudit prunes rows beyond retentionDays", async () => { + insertOldRow("audit-old", 40); + const r = await cleanup.cleanupConfigAudit(30); + assert.equal(r.deleted, 1); + assert.equal(countRows(), 0); +}); + +test("cleanupConfigAudit keeps recent rows within retention", async () => { + insertOldRow("audit-recent", 5); + const r = await cleanup.cleanupConfigAudit(30); + assert.equal(r.deleted, 0); + assert.equal(countRows(), 1); +}); + +test("runAutoCleanup includes a configAudit result", async () => { + insertOldRow("audit-old-2", 40); + const result = await cleanup.runAutoCleanup(); + assert.ok(result.results.configAudit); + assert.equal(typeof result.results.configAudit.deleted, "number"); + assert.equal(typeof result.results.configAudit.errors, "number"); + assert.equal(result.results.configAudit.deleted, 1); + assert.equal(countRows(), 0); +}); diff --git a/tests/unit/db-providers-split.test.ts b/tests/unit/db-providers-split.test.ts index 220544ba2d..284a5193c8 100644 --- a/tests/unit/db-providers-split.test.ts +++ b/tests/unit/db-providers-split.test.ts @@ -52,26 +52,38 @@ describe("providers/columns — normalizeBooleanColumn", () => { }); describe("providers/columns — sanitizeRateLimitOverrides", () => { - it("returns null for nullish / non-object / array input", () => { - assert.equal(sanitizeRateLimitOverrides(null), null); - assert.equal(sanitizeRateLimitOverrides(undefined), null); - assert.equal(sanitizeRateLimitOverrides("x"), null); - assert.equal(sanitizeRateLimitOverrides([1, 2]), null); + it("returns {sanitized:null,rejected:[]} for nullish / non-object / array input", () => { + assert.deepEqual(sanitizeRateLimitOverrides(null), { sanitized: null, rejected: [] }); + assert.deepEqual(sanitizeRateLimitOverrides(undefined), { sanitized: null, rejected: [] }); + assert.deepEqual(sanitizeRateLimitOverrides("x"), { sanitized: null, rejected: [] }); + assert.deepEqual(sanitizeRateLimitOverrides([1, 2]), { sanitized: null, rejected: [] }); }); - it("keeps only allowed keys with non-negative integers", () => { - assert.deepEqual(sanitizeRateLimitOverrides({ rpm: 10, bogus: 5, tpm: -1 }), { rpm: 10 }); + it("keeps only allowed keys with non-negative integers, reports the rest as rejected", () => { + assert.deepEqual(sanitizeRateLimitOverrides({ rpm: 10, bogus: 5, tpm: -1 }), { + sanitized: { rpm: 10 }, + rejected: ["bogus", "tpm"], + }); }); - it("returns null when nothing valid remains", () => { - assert.equal(sanitizeRateLimitOverrides({ rpm: 1.5, nope: 3 }), null); + it("returns {sanitized:null} when nothing valid remains, with rejected keys", () => { + assert.deepEqual(sanitizeRateLimitOverrides({ rpm: 1.5, nope: 3 }), { + sanitized: null, + rejected: ["rpm", "nope"], + }); }); }); describe("providers/columns — sanitizeQuotaWindowThresholds", () => { - it("keeps only 0-100 integers", () => { - assert.deepEqual(sanitizeQuotaWindowThresholds({ a: 50, b: 120, c: 0 }), { a: 50, c: 0 }); + it("keeps only 0-100 integers, reports the rest as rejected", () => { + assert.deepEqual(sanitizeQuotaWindowThresholds({ a: 50, b: 120, c: 0 }), { + sanitized: { a: 50, c: 0 }, + rejected: ["b"], + }); }); - it("returns null when empty", () => { - assert.equal(sanitizeQuotaWindowThresholds({ a: 200 }), null); + it("returns {sanitized:null} when nothing valid remains, with rejected keys", () => { + assert.deepEqual(sanitizeQuotaWindowThresholds({ a: 200 }), { + sanitized: null, + rejected: ["a"], + }); }); }); diff --git a/tests/unit/http-status-unprocessable-entity.test.ts b/tests/unit/http-status-unprocessable-entity.test.ts new file mode 100644 index 0000000000..636e0ebe37 --- /dev/null +++ b/tests/unit/http-status-unprocessable-entity.test.ts @@ -0,0 +1,7 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { HTTP_STATUS } from "../../open-sse/config/constants.ts"; + +test("HTTP_STATUS declares UNPROCESSABLE_ENTITY as 422", () => { + assert.equal(HTTP_STATUS.UNPROCESSABLE_ENTITY, 422); +}); diff --git a/tests/unit/is-local-provider-11091.test.ts b/tests/unit/is-local-provider-11091.test.ts new file mode 100644 index 0000000000..e5adc57b15 --- /dev/null +++ b/tests/unit/is-local-provider-11091.test.ts @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { isLocalProvider } from "../../open-sse/config/providerRegistry.ts"; + +test("isLocalProvider detects RFC1918, CGNAT/Tailscale, and mDNS private hosts", () => { + // Local / loopback + assert.equal(isLocalProvider("http://localhost:11434/v1"), true); + assert.equal(isLocalProvider("http://127.0.0.1:11434/v1"), true); + + // Docker 172.16/12 + assert.equal(isLocalProvider("http://172.18.0.2:11434/v1"), true); + + // RFC1918 LAN hosts (Issue #11091) + assert.equal(isLocalProvider("http://192.168.1.50:11434/v1"), true); + assert.equal(isLocalProvider("http://10.0.0.5:11434/v1"), true); + + // Tailscale / CGNAT (100.64/10) + assert.equal(isLocalProvider("http://100.64.1.2:11434/v1"), true); + + // Link-local (169.254/16) + assert.equal(isLocalProvider("http://169.254.1.1:11434/v1"), true); + + // mDNS / private suffixes + assert.equal(isLocalProvider("http://studio.local:11434/v1"), true); + assert.equal(isLocalProvider("http://mybox.internal:11434/v1"), true); + + // Public hosts (should be false) + assert.equal(isLocalProvider("https://api.openai.com/v1"), false); + assert.equal(isLocalProvider("https://api.anthropic.com/v1"), false); + assert.equal(isLocalProvider("http://8.8.8.8:8080/v1"), false); + + // Fails open on missing or unparseable input (Issue #11091 review finding) + assert.equal(isLocalProvider(null), false); + assert.equal(isLocalProvider(undefined), false); + assert.equal(isLocalProvider(""), false); + assert.equal(isLocalProvider("not a url"), false); + assert.equal(isLocalProvider("file:///models"), false); +}); diff --git a/tests/unit/learned-reasoning-effort-caps.test.ts b/tests/unit/learned-reasoning-effort-caps.test.ts new file mode 100644 index 0000000000..5d69a342c3 --- /dev/null +++ b/tests/unit/learned-reasoning-effort-caps.test.ts @@ -0,0 +1,126 @@ +import { test, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { + REASONING_EFFORT_ORDER, + parseReasoningEffortEnum, + recordLearnedReasoningEffort, + getLearnedReasoningEffort, + __test_resetLearnedReasoningEffortCaps, +} from "../../open-sse/services/learnedReasoningEffortCaps.ts"; + +beforeEach(() => { + __test_resetLearnedReasoningEffortCaps(); +}); + +after(() => { + __test_resetLearnedReasoningEffortCaps(); +}); + +// ── REASONING_EFFORT_ORDER ────────────────────────────────────────────────── + +test("REASONING_EFFORT_ORDER is none < minimal < low < medium < high < xhigh < max", () => { + assert.deepEqual(REASONING_EFFORT_ORDER, [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + ]); +}); + +// ── parseReasoningEffortEnum ──────────────────────────────────────────────── + +test("parseReasoningEffortEnum extracts the real OVH 422 enum (backtick-quoted)", () => { + const err = + "Failed to deserialize the JSON body into the target type: reasoning_effort: " + + "unknown variant `xhigh`, expected one of `none`, `high`, `medium`, `low`, `minimal`"; + assert.deepEqual(parseReasoningEffortEnum(err), ["none", "high", "medium", "low", "minimal"]); +}); + +test("parseReasoningEffortEnum extracts a bare comma/and-joined enum with annotations", () => { + const err = + "Unexpected reasoning effort high. Supported types are xhigh (default), medium, and low."; + assert.deepEqual(parseReasoningEffortEnum(err), ["xhigh", "medium", "low"]); +}); + +test("parseReasoningEffortEnum drops unrecognized tokens", () => { + const err = "expected one of `none`, `turbo`, `high`"; + assert.deepEqual(parseReasoningEffortEnum(err), ["none", "high"]); +}); + +test("parseReasoningEffortEnum returns null for unrelated error text", () => { + assert.equal(parseReasoningEffortEnum("connection refused"), null); + assert.equal(parseReasoningEffortEnum(""), null); + assert.equal(parseReasoningEffortEnum(null), null); + assert.equal(parseReasoningEffortEnum(undefined), null); +}); + +test("parseReasoningEffortEnum returns null when the list has no recognized token", () => { + assert.equal(parseReasoningEffortEnum("expected one of `foo`, `bar`"), null); +}); + +// ── recordLearnedReasoningEffort / getLearnedReasoningEffort ─────────────── + +test("records the highest recognized value from the accepted list", () => { + const learned = recordLearnedReasoningEffort("ovh", "qwen3-coder-30b-a3b-instruct", [ + "none", + "high", + "medium", + "low", + "minimal", + ]); + assert.equal(learned, "high"); + assert.equal(getLearnedReasoningEffort("ovh", "qwen3-coder-30b-a3b-instruct"), "high"); +}); + +test("returns null and stores nothing when acceptedValues has no recognized token", () => { + const learned = recordLearnedReasoningEffort("acme", "model-x", ["foo", "bar"]); + assert.equal(learned, null); + assert.equal(getLearnedReasoningEffort("acme", "model-x"), null); +}); + +test("monotonic decrease: a later, higher accepted-list never ratchets the cap back up", () => { + recordLearnedReasoningEffort("acme", "model-x", ["none", "low", "medium"]); + const learned = recordLearnedReasoningEffort("acme", "model-x", [ + "none", + "low", + "medium", + "high", + "xhigh", + ]); + assert.equal(learned, "medium"); + assert.equal(getLearnedReasoningEffort("acme", "model-x"), "medium"); +}); + +test("a later, lower accepted-list does ratchet the cap down", () => { + recordLearnedReasoningEffort("acme", "model-x", ["none", "low", "medium", "high"]); + const learned = recordLearnedReasoningEffort("acme", "model-x", ["none", "low"]); + assert.equal(learned, "low"); + assert.equal(getLearnedReasoningEffort("acme", "model-x"), "low"); +}); + +test("getLearnedReasoningEffort returns null for unknown provider+model", () => { + assert.equal(getLearnedReasoningEffort("acme", "unknown-model"), null); +}); + +test("getLearnedReasoningEffort is keyed case-insensitively on provider+model", () => { + recordLearnedReasoningEffort("OVH", "Qwen3-Coder-30B", ["none", "high"]); + assert.equal(getLearnedReasoningEffort("ovh", "qwen3-coder-30b"), "high"); + assert.equal(getLearnedReasoningEffort("OVH", "QWEN3-CODER-30B"), "high"); +}); + +test("different providers for the same model id have independent caps", () => { + recordLearnedReasoningEffort("ovh", "shared-model", ["none", "high"]); + assert.equal(getLearnedReasoningEffort("openrouter", "shared-model"), null); +}); + +test("handles empty/null provider or model gracefully", () => { + assert.equal(getLearnedReasoningEffort("", "m"), null); + assert.equal(getLearnedReasoningEffort("p", ""), null); + assert.equal(getLearnedReasoningEffort(null, "m"), null); + assert.equal(getLearnedReasoningEffort("p", null), null); + assert.equal(recordLearnedReasoningEffort("", "m", ["high"]), null); + assert.equal(recordLearnedReasoningEffort("p", "", ["high"]), null); +}); diff --git a/tests/unit/ollama-404-model-lockout-11071.test.ts b/tests/unit/ollama-404-model-lockout-11071.test.ts new file mode 100644 index 0000000000..f58c29c07b --- /dev/null +++ b/tests/unit/ollama-404-model-lockout-11071.test.ts @@ -0,0 +1,66 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ollama-404-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const auth = await import("../../src/sse/services/auth.ts"); +const { hasPerModelQuota, isModelLocked } = await import("../../open-sse/services/accountFallback.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("hasPerModelQuota returns true for ollama-local and ollama providers", () => { + assert.equal(hasPerModelQuota("ollama-local"), true); + assert.equal(hasPerModelQuota("ollama"), true); +}); + +test("markAccountUnavailable locks only the missing model on a 404 from ollama-local", async () => { + await resetStorage(); + + const connection = await providersDb.createProviderConnection({ + provider: "ollama-local", + authType: "none", + baseUrl: "http://127.0.0.1:11434/v1", + isActive: true, + }); + + const result = await auth.markAccountUnavailable( + connection.id, + 404, + "model 'model-b' not found", + "ollama-local", + "model-b" + ); + + assert.equal(result.shouldFallback, true); + + // The missing model must be locked + assert.equal(isModelLocked("ollama-local", connection.id, "model-b"), true); + + // The connection in DB must remain active / not marked unavailable for sibling models + const connInDb = await providersDb.getProviderConnectionById(connection.id); + assert.notEqual(connInDb?.testStatus, "unavailable", "connection should not be marked unavailable connection-wide on a 404 model-not-found error"); + + // getProviderCredentials must still serve sibling models + const selectedForSibling = await auth.getProviderCredentials( + "ollama-local", + null, + null, + "model-a" + ); + assert.ok(selectedForSibling && !("allExpired" in selectedForSibling), "sibling model-a must still be selected on the same connection"); +}); diff --git a/tests/unit/opencode-v2-config-11070.test.ts b/tests/unit/opencode-v2-config-11070.test.ts new file mode 100644 index 0000000000..71bd8a9537 --- /dev/null +++ b/tests/unit/opencode-v2-config-11070.test.ts @@ -0,0 +1,40 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const opencodeConfig = await import("../../src/shared/services/opencodeConfig.ts"); + +test("buildOpenCodeConfigDocument includes both V1 (provider) and V2 (providers) definitions", () => { + const doc = opencodeConfig.buildOpenCodeConfigDocument({ + baseUrl: "http://localhost:20128/v1", + apiKey: "{env:OMNIROUTE_API_KEY}", + models: ["auto/best-coding"], + }); + + assert.ok(doc.provider?.omniroute, "V1 provider.omniroute must be present"); + assert.equal(doc.provider.omniroute.npm, "@ai-sdk/openai-compatible"); + assert.equal(doc.provider.omniroute.options.baseURL, "http://localhost:20128/v1"); + + assert.ok(doc.providers?.omniroute, "V2 providers.omniroute must be present"); + assert.equal(doc.providers.omniroute.package, "@opencode-ai/ai/providers/openai-compatible"); + assert.equal(doc.providers.omniroute.settings.baseURL, "http://localhost:20128/v1"); + assert.equal(doc.providers.omniroute.settings.apiKey, "{env:OMNIROUTE_API_KEY}"); + assert.ok(doc.providers.omniroute.models["auto/best-coding"].limit, "V2 model limit must be present"); +}); + +test("mergeOpenCodeConfig preserves existing properties and updates both provider and providers", () => { + const existing = { + $schema: "https://opencode.ai/config.json", + customField: "keep-me", + }; + + const merged = opencodeConfig.mergeOpenCodeConfig(existing, { + baseUrl: "http://localhost:20128/v1", + apiKey: "sk_test_key", + models: ["auto/best-coding"], + }); + + assert.equal(merged.customField, "keep-me"); + assert.ok(merged.provider?.omniroute); + assert.ok(merged.providers?.omniroute); + assert.equal(merged.providers.omniroute.settings.apiKey, "sk_test_key"); +}); diff --git a/tests/unit/opencode-zen-go-shared-models.test.ts b/tests/unit/opencode-zen-go-shared-models.test.ts index 8c3079ad15..c1fb49e97d 100644 --- a/tests/unit/opencode-zen-go-shared-models.test.ts +++ b/tests/unit/opencode-zen-go-shared-models.test.ts @@ -24,3 +24,16 @@ test("every OPENCODE_ZEN_GO_SHARED_MODELS entry is present, unmodified, exactly test("OPENCODE_ZEN_GO_SHARED_MODELS is frozen (no accidental cross-registry mutation)", () => { assert.ok(Object.isFrozen(OPENCODE_ZEN_GO_SHARED_MODELS)); }); + +test("referenced non-shared model ids remain present", () => { + const goIds = new Set(opencode_goProvider.models.map((m) => m.id)); + const zenIds = new Set(opencode_zenProvider.models.map((m) => m.id)); + for (const id of ["minimax-m3", "glm-5.1"]) { + assert.ok(goIds.has(id) || zenIds.has(id), `expected ${id} in go or zen`); + } +}); + +test("models[0] is the intended dashboard default", () => { + assert.equal(opencode_goProvider.models[0].id, "glm-5.2"); + assert.equal(opencode_zenProvider.models[0].id, "big-pickle"); +}); diff --git a/tests/unit/perplexity-discovery-filter.test.ts b/tests/unit/perplexity-discovery-filter.test.ts new file mode 100644 index 0000000000..8533646746 --- /dev/null +++ b/tests/unit/perplexity-discovery-filter.test.ts @@ -0,0 +1,63 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { PROVIDER_MODELS_CONFIG } from "../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts"; + +// Regression guard for #11060 — Perplexity's /v1/models endpoint lists the +// Agent API catalog (vendor-prefixed ids like "anthropic/claude-fable-5"), but +// chat requests always go to the classic /chat/completions endpoint, which only +// accepts the Sonar family. Without a PROVIDER_MODELS_CONFIG entry, generic +// model import pulled those agent-style ids into the connection's chat model +// list and every routed request failed with 400 "Invalid model". The discovery +// entry must exist and its parseResponse must keep only Sonar-family ids. + +test("perplexity has a discovery entry in PROVIDER_MODELS_CONFIG", () => { + const cfg = PROVIDER_MODELS_CONFIG.perplexity; + assert.ok(cfg, "expected a perplexity entry in PROVIDER_MODELS_CONFIG"); + assert.equal(cfg.method, "GET"); + assert.equal(cfg.url, "https://api.perplexity.ai/v1/models"); + assert.equal(typeof cfg.parseResponse, "function"); +}); + +test("perplexity parseResponse keeps only the Sonar family (#11060)", () => { + const cfg = PROVIDER_MODELS_CONFIG.perplexity; + const models = cfg.parseResponse({ + object: "list", + data: [ + { id: "anthropic/claude-fable-5", object: "model", owned_by: "anthropic" }, + { id: "sonar-pro", object: "model", owned_by: "perplexity" }, + { id: "sonar", object: "model", owned_by: "perplexity" }, + ], + }) as Array<{ id: string }>; + + assert.deepEqual( + models.map((model) => model.id), + ["sonar-pro", "sonar"] + ); +}); + +test("perplexity parseResponse keeps every Sonar variant and drops non-Sonar ids", () => { + const cfg = PROVIDER_MODELS_CONFIG.perplexity; + const models = cfg.parseResponse({ + data: [ + { id: "sonar-deep-research" }, + { id: "sonar-reasoning-pro" }, + { id: "sonar-pro" }, + { id: "sonar" }, + { id: "openai/gpt-5" }, + { id: "sonarish" }, + ], + }) as Array<{ id: string }>; + + assert.deepEqual( + models.map((model) => model.id), + ["sonar-deep-research", "sonar-reasoning-pro", "sonar-pro", "sonar"] + ); +}); + +test("perplexity parseResponse tolerates empty and malformed payloads", () => { + const cfg = PROVIDER_MODELS_CONFIG.perplexity; + assert.deepEqual(cfg.parseResponse({ data: [] }), []); + assert.deepEqual(cfg.parseResponse(undefined), []); + assert.deepEqual(cfg.parseResponse({}), []); +}); diff --git a/tests/unit/pollinations-api-key-required-11096.test.ts b/tests/unit/pollinations-api-key-required-11096.test.ts new file mode 100644 index 0000000000..8b69fb8331 --- /dev/null +++ b/tests/unit/pollinations-api-key-required-11096.test.ts @@ -0,0 +1,11 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { providerAllowsOptionalApiKey } from "../../src/shared/constants/providers.js"; + +test("pollinations provider requires an API key and does not allow optional API key", () => { + assert.equal( + providerAllowsOptionalApiKey("pollinations"), + false, + "pollinations must require an API key because anonymous completions are no longer supported" + ); +}); diff --git a/tests/unit/provider-connections-quota-threshold.test.ts b/tests/unit/provider-connections-quota-threshold.test.ts index 5caaeea369..e7812557e9 100644 --- a/tests/unit/provider-connections-quota-threshold.test.ts +++ b/tests/unit/provider-connections-quota-threshold.test.ts @@ -106,18 +106,36 @@ test("updateProviderConnection with explicit null clears the column entirely", a assert.ok(reread.quotaWindowThresholds === null || reread.quotaWindowThresholds === undefined); }); -test("DB serializer drops out-of-range values silently", async () => { - // The DB module sanitizes the map on the way in; values outside 0-100 or - // non-integers are pruned. This is a defense in depth — the Zod schema - // already rejects them at the API boundary, but the DB shouldn't trust. - const created = await providersDb.createProviderConnection({ - provider: "codex", - authType: "apikey", - name: "Codex Sanitize", - apiKey: "sk-san", - quotaWindowThresholds: { window5h: 95, bogus: 999, fractional: 1.5 }, - }); - assert.deepEqual(created.quotaWindowThresholds, { window5h: 95 }); +test("DB serializer refuses out-of-range / invalid values instead of dropping silently", async () => { + // the DB module must refuse the write (throw) rather than + // silently prune invalid keys/values on the way in, so operator intent is + // never lost without an error. The Zod schema already rejects at the API + // boundary; this is defense in depth for direct DB writers (seed/scripts). + await assert.rejects( + () => + providersDb.createProviderConnection({ + provider: "codex", + authType: "apikey", + name: "Codex Sanitize", + apiKey: "sk-san", + quotaWindowThresholds: { window5h: 95, bogus: 999, fractional: 1.5 }, + }), + /rejected keys/ + ); +}); + +test("DB serializer refuses unknown rate-limit override keys instead of dropping silently", async () => { + await assert.rejects( + () => + providersDb.createProviderConnection({ + provider: "codex", + authType: "apikey", + name: "Codex Sanitize RLO", + apiKey: "sk-san-2", + rateLimitOverrides: { rpm: 10, bogus: 999, tpm: -1 }, + }), + /rejected keys/ + ); }); test("updateProviderConnectionSchema accepts a valid window map", () => { diff --git a/tests/unit/provider-error-rules-operator.test.ts b/tests/unit/provider-error-rules-operator.test.ts new file mode 100644 index 0000000000..6f8c9e52ef --- /dev/null +++ b/tests/unit/provider-error-rules-operator.test.ts @@ -0,0 +1,135 @@ +import { describe, it, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { + getProviderErrorRuleMatch, + setOperatorProviderErrorRules, + resolveRuleMatchBody, + honorsRuleLockScope, + type OperatorProviderErrorRule, +} from "../../open-sse/config/providerErrorRules.ts"; + +describe("operator error rules", () => { + beforeEach(() => { + // Isolate each test from the settings-backed cache. + setOperatorProviderErrorRules(undefined); + }); + + it("operator rule overrides the catalog registry for a provider", () => { + const op: Record = { + nvidia: [{ status: 404, match: "Not found for account", scope: "model", cooldownMs: 1000 }], + }; + const m = getProviderErrorRuleMatch("nvidia", 404, null, "Not found for account id 123", op); + assert.ok(m, "operator rule should match"); + assert.equal(m.scope, "model"); + assert.equal(m.cooldownMs, 1000); + }); + + it("operator rule wins even when a catalog rule would also match", () => { + const op: Record = { + openrouter: [{ status: 402, match: "credits exhausted", scope: "model" }], + }; + const m = getProviderErrorRuleMatch("openrouter", 402, null, "credits exhausted on key", op); + assert.ok(m); + // Catalog rule for openrouter/402 uses scope "connection"; the operator + // override must take precedence. + assert.equal(m.scope, "model"); + }); + + it("operator can reclassify a 401 before the global permanent rule", () => { + const op: Record = { + acme: [ + { status: 401, match: "transient quota", scope: "connection", reason: "quota_exhausted" }, + ], + }; + const m = getProviderErrorRuleMatch("acme", 401, null, "transient quota — retry shortly", op); + assert.ok(m); + assert.equal(m.scope, "connection"); + assert.equal(m.reason, "quota_exhausted"); + }); + + it("unknown provider with no operator rule returns null (no throw)", () => { + const m = getProviderErrorRuleMatch("unknown-provider", 402, null, "anything"); + assert.equal(m, null); + }); + + it("substring match is case-insensitive", () => { + const op: Record = { + nvidia: [{ status: 404, match: "NOT FOUND", scope: "model" }], + }; + const m = getProviderErrorRuleMatch("nvidia", 404, null, "Body says Not Found Here", op); + assert.ok(m); + assert.equal(m.scope, "model"); + }); + + it("status must match before the substring is considered", () => { + const op: Record = { + nvidia: [{ status: 404, match: "not found", scope: "model" }], + }; + // 500 with the same body text must NOT match a 404 rule. + const m = getProviderErrorRuleMatch("nvidia", 500, null, "not found for account", op); + assert.equal(m, null); + }); + + it("without an operator override the catalog registry is intact", () => { + const m = getProviderErrorRuleMatch("openrouter", 402, null, "credits exhausted on key"); + assert.ok(m); + assert.equal(m.scope, "connection"); + assert.equal(m.cooldownMs, 2 * 60 * 1000); + }); + + it("reads the settings-backed cache via setOperatorProviderErrorRules", () => { + setOperatorProviderErrorRules({ + nvidia: [{ status: 404, match: "Not found", scope: "model" }], + }); + const m = getProviderErrorRuleMatch("nvidia", 404, null, "Not found for account"); + assert.ok(m); + assert.equal(m.scope, "model"); + // Provider key lookup is case-insensitive. + const m2 = getProviderErrorRuleMatch("NVIDIA", 404, null, "Not found here"); + assert.ok(m2); + assert.equal(m2.scope, "model"); + }); + + // Regression coverage for #11104's original gap: an operator rule for any + // provider outside the built-in FULL_TEXT_RULE_PROVIDERS/ + // HONORS_RULE_LOCK_SCOPE_PROVIDERS allowlists was silently text-blind (only + // {code,type} reached the matcher) and had its declared scope dropped by the + // persistence layer. Declaring an operator rule for a provider must be + // sufficient by itself — no separate allowlist entry required. + describe("operator rule bypasses the built-in allowlists", () => { + it("resolveRuleMatchBody hands the full error text once an operator rule exists for the provider", () => { + setOperatorProviderErrorRules({ + acme: [{ status: 404, match: "model withdrawn", scope: "model" }], + }); + const body = resolveRuleMatchBody("acme", { code: "not_found" }, "Model withdrawn upstream"); + assert.equal(body, "Model withdrawn upstream"); + }); + + it("resolveRuleMatchBody keeps returning the structured error for a provider with no operator rule", () => { + const body = resolveRuleMatchBody("acme", { code: "not_found" }, "Model withdrawn upstream"); + assert.deepEqual(body, { code: "not_found" }); + }); + + it("honorsRuleLockScope is true once an operator rule exists for the provider", () => { + assert.equal(honorsRuleLockScope("acme"), false); + setOperatorProviderErrorRules({ + acme: [{ status: 404, match: "model withdrawn", scope: "model" }], + }); + assert.equal(honorsRuleLockScope("acme"), true); + }); + + it("an operator rule for a non-allowlisted provider matches on raw body text end to end", () => { + setOperatorProviderErrorRules({ + acme: [{ status: 404, match: "model withdrawn", scope: "model" }], + }); + const body = resolveRuleMatchBody( + "acme", + { code: "not_found" }, + "Error: model withdrawn upstream" + ); + const m = getProviderErrorRuleMatch("acme", 404, null, body); + assert.ok(m, "operator rule should match once resolveRuleMatchBody hands it the raw text"); + assert.equal(m.scope, "model"); + }); + }); +}); diff --git a/tests/unit/provider-route-schemas.test.ts b/tests/unit/provider-route-schemas.test.ts index ca61c4f28d..30b5d4bd8c 100644 --- a/tests/unit/provider-route-schemas.test.ts +++ b/tests/unit/provider-route-schemas.test.ts @@ -5,17 +5,19 @@ const { createProviderSchema, providersBatchTestSchema } = await import("../../src/shared/validation/schemas.ts"); const { providerAllowsOptionalApiKey } = await import("../../src/shared/constants/providers.ts"); -test("Pollinations is treated as a keyless-capable provider", () => { - assert.equal(providerAllowsOptionalApiKey("pollinations"), true); +// #11117: Pollinations no longer serves anonymous requests (401 without a key), +// so it left EXPLICIT_OPTIONAL_APIKEY_PROVIDER_IDS — key is now required. +test("Pollinations requires an API key", () => { + assert.equal(providerAllowsOptionalApiKey("pollinations"), false); }); -test("createProviderSchema allows Pollinations without apiKey", () => { +test("createProviderSchema rejects Pollinations without apiKey", () => { const result = createProviderSchema.safeParse({ provider: "pollinations", name: "Pollinations", }); - assert.equal(result.success, true); + assert.equal(result.success, false); }); test("providersBatchTestSchema accepts cloud-agent batch mode", () => { diff --git a/tests/unit/providers-patch-400.test.ts b/tests/unit/providers-patch-400.test.ts new file mode 100644 index 0000000000..8ec1cbdf3b --- /dev/null +++ b/tests/unit/providers-patch-400.test.ts @@ -0,0 +1,42 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { updateProviderConnectionSchema } from "@/shared/validation/schemas/provider"; + +test("PATCH rateLimitOverrides {rpm:\"60\"} coerces to a valid number", () => { + const r = updateProviderConnectionSchema.safeParse({ rateLimitOverrides: { rpm: "60" } }); + assert.equal(r.success, true); +}); + +test("unknown key in rateLimitOverrides is rejected (no silent drop)", () => { + const r = updateProviderConnectionSchema.safeParse({ rateLimitOverrides: { rpm: 10, foo: 1 } }); + assert.equal(r.success, false); + const flaggedFoo = r.error!.issues.some( + (i) => i.path.includes("foo") || (i as { keys?: string[] }).keys?.includes("foo") || i.message.includes("foo") + ); + assert.ok( + flaggedFoo, + `expected an issue flagging "foo", got: ${JSON.stringify(r.error!.issues)}` + ); +}); + +test("quotaWindowThresholds key longer than 64 chars is rejected", () => { + const r = updateProviderConnectionSchema.safeParse({ + quotaWindowThresholds: { ["a".repeat(65)]: 50 }, + }); + assert.equal(r.success, false); +}); + +test("empty string rate limit value is rejected (coerce \"\"→0 trap)", () => { + const r = updateProviderConnectionSchema.safeParse({ rateLimitOverrides: { rpm: "" } }); + assert.equal(r.success, false); +}); + +test("non-numeric rate limit value is rejected", () => { + const r = updateProviderConnectionSchema.safeParse({ rateLimitOverrides: { rpm: "60abc" } }); + assert.equal(r.success, false); +}); + +test("quotaWindowThresholds value outside 0-100 is rejected", () => { + const r = updateProviderConnectionSchema.safeParse({ quotaWindowThresholds: { win: 101 } }); + assert.equal(r.success, false); +}); diff --git a/tests/unit/reasoning-cache.test.ts b/tests/unit/reasoning-cache.test.ts index e13dfec7a9..aded469ca8 100644 --- a/tests/unit/reasoning-cache.test.ts +++ b/tests/unit/reasoning-cache.test.ts @@ -664,6 +664,7 @@ describe("Reasoning Replay Cache — Translator Replay", () => { { type: "reasoning", content: [{ type: "reasoning_text", text: "Cached Chat continuation reasoning" }], + summary: [], } ); }); diff --git a/tests/unit/reasoning-effort-clamp-and-retry.test.ts b/tests/unit/reasoning-effort-clamp-and-retry.test.ts new file mode 100644 index 0000000000..a97ac16df0 --- /dev/null +++ b/tests/unit/reasoning-effort-clamp-and-retry.test.ts @@ -0,0 +1,110 @@ +import { test, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { BaseExecutor } from "../../open-sse/executors/base.ts"; +import { + getLearnedReasoningEffort, + recordLearnedReasoningEffort, + __test_resetLearnedReasoningEffortCaps, +} from "../../open-sse/services/learnedReasoningEffortCaps.ts"; + +const OVH_422_BODY = JSON.stringify({ + error: { + message: + "Failed to deserialize the JSON body into the target type: reasoning_effort: " + + "unknown variant `xhigh`, expected one of `none`, `high`, `medium`, `low`, `minimal`", + }, +}); + +// Passthrough executor: returns the body unchanged so we assert on exactly what +// base.ts sends upstream. +class SimpleExecutor extends BaseExecutor { + constructor() { + super("openai-compatible-chat-eaff6869", { + baseUrls: ["https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/chat/completions"], + }); + } + async transformRequest(_model: string, body: Record) { + return { ...body }; + } +} + +beforeEach(() => { + __test_resetLearnedReasoningEffortCaps(); +}); + +after(() => { + __test_resetLearnedReasoningEffortCaps(); +}); + +test("422 'unknown variant xhigh, expected one of ...' clamps reasoning_effort and retries once", async () => { + const executor = new SimpleExecutor(); + const originalFetch = globalThis.fetch; + const capturedBodies: Record[] = []; + + globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => { + const body = JSON.parse(String(init.body)); + capturedBodies.push(body); + if (capturedBodies.length === 1) { + return new Response(OVH_422_BODY, { + status: 422, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + const result = await executor.execute({ + model: "qwen3-coder-30b-a3b-instruct", + body: { reasoning_effort: "xhigh" }, + stream: false, + credentials: {}, + }); + assert.equal(capturedBodies.length, 2); + assert.equal(capturedBodies[0].reasoning_effort, "xhigh"); + assert.equal(capturedBodies[1].reasoning_effort, "high"); + assert.equal( + getLearnedReasoningEffort("openai-compatible-chat-eaff6869", "qwen3-coder-30b-a3b-instruct"), + "high" + ); + assert.equal(result.response.status, 200); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("a second request for the same provider+model sends the learned value on the first try", async () => { + const executor = new SimpleExecutor(); + const originalFetch = globalThis.fetch; + const capturedBodies: Record[] = []; + + globalThis.fetch = async (_url: string | URL | Request, init: RequestInit = {}) => { + const body = JSON.parse(String(init.body)); + capturedBodies.push(body); + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + try { + recordLearnedReasoningEffort( + "openai-compatible-chat-eaff6869", + "qwen3-coder-30b-a3b-instruct", + ["none", "high", "medium", "low", "minimal"] + ); + await executor.execute({ + model: "qwen3-coder-30b-a3b-instruct", + body: { reasoning_effort: "xhigh" }, + stream: false, + credentials: {}, + }); + assert.equal(capturedBodies.length, 1); + assert.equal(capturedBodies[0].reasoning_effort, "high"); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/reasoning-effort-learned-capability.test.ts b/tests/unit/reasoning-effort-learned-capability.test.ts new file mode 100644 index 0000000000..210a451341 --- /dev/null +++ b/tests/unit/reasoning-effort-learned-capability.test.ts @@ -0,0 +1,91 @@ +import { test, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +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("unregistered/custom provider+model: no learned cap yet sends xhigh unchanged", () => { + const body = { reasoning_effort: "xhigh" }; + const result = sanitizeReasoningEffortForProvider( + body, + "openai-compatible-chat-eaff6869", + "qwen3-coder-30b-a3b-instruct" + ) as { reasoning_effort: string }; + assert.equal(result.reasoning_effort, "xhigh"); +}); + +test("unregistered/custom provider+model: a learned cap clamps xhigh down to it", () => { + recordLearnedReasoningEffort("openai-compatible-chat-eaff6869", "qwen3-coder-30b-a3b-instruct", [ + "none", + "high", + "medium", + "low", + "minimal", + ]); + const body = { reasoning_effort: "xhigh" }; + const result = sanitizeReasoningEffortForProvider( + body, + "openai-compatible-chat-eaff6869", + "qwen3-coder-30b-a3b-instruct" + ) as { reasoning_effort: string }; + assert.equal(result.reasoning_effort, "high"); +}); + +test("learned cap only clamps when the requested effort is above it", () => { + recordLearnedReasoningEffort("acme", "model-x", ["none", "low", "medium"]); + const body = { reasoning_effort: "low" }; + const result = sanitizeReasoningEffortForProvider(body, "acme", "model-x") as { + reasoning_effort: string; + }; + assert.equal(result.reasoning_effort, "low"); +}); + +test("registry says supportsXHighEffort:false (and no supportsMax path) with a learned cap below 'high': uses the learned cap, not the hardcoded 'high'", () => { + // claude-haiku-4-5 is registered with supportsXHighEffort:false + // (open-sse/config/providers/registry/claude/index.ts) and its family is + // excluded from supportsClaudeMaxEffort (CLAUDE_MAX_EFFORT_UNSUPPORTED_FAMILY_PATTERNS + // in providerModels.ts), so it reaches the hardcoded-"high" line today — + // a real registry-covered case. Teach a lower cap and confirm it wins. + recordLearnedReasoningEffort("claude", "claude-haiku-4-5-20251001", ["none", "low", "medium"]); + const body = { reasoning_effort: "xhigh" }; + const result = sanitizeReasoningEffortForProvider( + body, + "claude", + "claude-haiku-4-5-20251001" + ) as { + reasoning_effort: string; + }; + assert.equal(result.reasoning_effort, "medium"); +}); + +test("registry says supportsXHighEffort:false with no learned cap: falls back to hardcoded 'high' (unchanged behavior)", () => { + const body = { reasoning_effort: "xhigh" }; + const result = sanitizeReasoningEffortForProvider( + body, + "claude", + "claude-haiku-4-5-20251001" + ) as { + reasoning_effort: string; + }; + assert.equal(result.reasoning_effort, "high"); +}); + +test("deepseek's non-ordinal max<->xhigh translation is untouched by the learned-cap catch-all", () => { + recordLearnedReasoningEffort("deepseek", "deepseek-v4", ["none", "low"]); + const body = { reasoning_effort: "xhigh" }; + const result = sanitizeReasoningEffortForProvider(body, "deepseek", "deepseek-v4") as { + reasoning_effort: string; + }; + // deepseek's special case returns early — xhigh -> max, never reaches the catch-all. + assert.equal(result.reasoning_effort, "max"); +}); diff --git a/tests/unit/reasoning-input-policy-summary-11108.test.ts b/tests/unit/reasoning-input-policy-summary-11108.test.ts new file mode 100644 index 0000000000..4317225dfb --- /dev/null +++ b/tests/unit/reasoning-input-policy-summary-11108.test.ts @@ -0,0 +1,145 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { applyReasoningInputPolicy } = + await import("../../open-sse/services/reasoningInputPolicy.ts"); + +test("#11108 applyReasoningInputPolicy defaults summary on a kept opaque reasoning item", () => { + const body: Record = { + input: [ + { + type: "reasoning", + id: "rs_example", + encrypted_content: "opaque-blob", + }, + ], + }; + + applyReasoningInputPolicy(body, "responses", { + provider: "opencode", + preserveEncryptedReasoning: true, + }); + + const input = body.input as Record[]; + assert.equal(input.length, 1); + assert.deepEqual(input[0].summary, []); +}); + +test("#11108 applyReasoningInputPolicy preserves an existing summary on a kept reasoning item", () => { + const body: Record = { + input: [ + { + type: "reasoning", + id: "rs_example", + encrypted_content: "opaque-blob", + summary: [{ type: "summary_text", text: "Planning." }], + }, + ], + }; + + applyReasoningInputPolicy(body, "responses", { + provider: "opencode", + preserveEncryptedReasoning: true, + }); + + const input = body.input as Record[]; + assert.deepEqual(input[0].summary, [{ type: "summary_text", text: "Planning." }]); +}); + +test("#11108 applyReasoningInputPolicy defaults summary on an opaque item surviving incompatible-drop", () => { + // Mixed item (plaintext + opaque) on an opaque-only transport is incompatible; + // dropIncompatibleResponsesReasoning() strips the plaintext content but keeps + // the opaque item alive — it must still get a default `summary`. + const body: Record = { + input: [ + { + type: "reasoning", + id: "rs_mixed", + content: [{ type: "reasoning_text", text: "inspect first" }], + encrypted_content: "opaque-blob", + }, + ], + }; + + const result = applyReasoningInputPolicy(body, "responses", { + provider: "codex", + onIncompatibleReasoning: "drop", + }); + + assert.equal(result.incompatibleReasoning, false); + const input = body.input as Record[]; + assert.equal(input.length, 1); + assert.equal(input[0].content, undefined); + assert.equal(input[0].encrypted_content, "opaque-blob"); + assert.deepEqual(input[0].summary, []); +}); + +test("#11108 applyReasoningInputPolicy strips a non-string id on a kept opaque reasoning item", () => { + // Same gap class as the summary fix above: opencode/zen also omits `id` + // entirely (surfaced by the client as `id: null`) on opaque-only reasoning + // items instead of a `rs_...` string. Replaying that shape verbatim trips + // strict Responses-API validators with "Expected 'id' to be a string." + const body: Record = { + input: [ + { + type: "reasoning", + id: null, + encrypted_content: "opaque-blob", + }, + ], + }; + + applyReasoningInputPolicy(body, "responses", { + provider: "opencode", + preserveEncryptedReasoning: true, + }); + + const input = body.input as Record[]; + assert.equal(input.length, 1); + assert.equal("id" in input[0], false); +}); + +test("#11108 applyReasoningInputPolicy strips a non-string id on a non-reasoning item (function_call)", () => { + // Same gap class, generic branch: any non-"reasoning" input item (function_call, + // message, ...) only stripped `id` when it was already a valid string, so a + // malformed `id` (e.g. `null`, mirroring the opencode/zen omission pattern) + // on a function_call item survived replay untouched. + const body: Record = { + input: [ + { + type: "function_call", + id: null, + call_id: "call_abc", + name: "bash", + arguments: "{}", + }, + ], + }; + + applyReasoningInputPolicy(body, "responses", { provider: "opencode" }); + + const input = body.input as Record[]; + assert.equal(input.length, 1); + assert.equal("id" in input[0], false); + assert.equal(input[0].call_id, "call_abc"); +}); + +test("#11108 applyReasoningInputPolicy preserves a valid string id on a kept opaque reasoning item", () => { + const body: Record = { + input: [ + { + type: "reasoning", + id: "rs_example", + encrypted_content: "opaque-blob", + }, + ], + }; + + applyReasoningInputPolicy(body, "responses", { + provider: "opencode", + preserveEncryptedReasoning: true, + }); + + const input = body.input as Record[]; + assert.equal(input[0].id, "rs_example"); +}); diff --git a/tests/unit/responses-input-sanitizer-name.test.ts b/tests/unit/responses-input-sanitizer-name.test.ts index 7ac09604c7..4eec2d6c10 100644 --- a/tests/unit/responses-input-sanitizer-name.test.ts +++ b/tests/unit/responses-input-sanitizer-name.test.ts @@ -73,6 +73,23 @@ test("keeps valid server reasoning item ids", () => { assert.equal(result[0].id, "rs_123"); }); +test("strips a non-string reasoning item id instead of passing it through (#11108)", () => { + // Same gap class fixed in reasoningInputPolicy.ts: some upstreams (e.g. + // opencode/zen) send `id: null` instead of omitting it. The previous + // `typeof record.id !== "string"` guard returned the record unchanged in + // that case, letting a malformed id reach a strict Responses-API upstream. + const items = [ + { + id: null, + type: "reasoning", + summary: [{ type: "summary_text", text: "cached reasoning" }], + }, + ]; + const result = sanitizeResponsesInputItems(items) as Array>; + assert.equal("id" in result[0], false); + assert.equal(result[0].type, "reasoning"); +}); + test("normalizes user image_url content parts to input_image", () => { const items = [ { diff --git a/tests/unit/responses-parallel-tool-calls-index.test.ts b/tests/unit/responses-parallel-tool-calls-index.test.ts new file mode 100644 index 0000000000..f825f8700b --- /dev/null +++ b/tests/unit/responses-parallel-tool-calls-index.test.ts @@ -0,0 +1,294 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIResponse } = + await import("../../open-sse/translator/response/openai-responses.ts"); + +// Issue: 2+ `function_call` items opened (response.output_item.added) before any of +// them closes (response.output_item.done) — a genuine parallel tool-call dispatch — +// causes `state.toolCallIndex` (only incremented in the `.done` handler) to stay at 0 +// for every "added" header chunk. Clients that key their tool-call accumulator by +// `delta.tool_calls[].index` (e.g. opencode's github-copilot chat-language-model +// stream parser) then see the *first* `.done` argument chunk at index 1/2 with no +// prior header and no `id`, and throw "Expected 'id' to be a string." +test("Responses -> OpenAI: parallel function_call items get distinct index+id on the added header", () => { + const state = {}; + + const added0 = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_0", name: "task" }, + }, + state + ); + const added1 = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_1", name: "task" }, + }, + state + ); + const added2 = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_2", name: "task" }, + }, + state + ); + + const headers = [added0, added1, added2].map((r) => r.choices[0].delta.tool_calls[0]); + + assert.deepEqual( + headers.map((h) => h.index), + [0, 1, 2], + "each parallel tool call must get its own header index, not all 0" + ); + assert.deepEqual( + headers.map((h) => h.id), + ["call_0", "call_1", "call_2"] + ); + + const done0 = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_0", name: "task", arguments: '{"i":0}' }, + }, + state + ); + const done1 = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_1", name: "task", arguments: '{"i":1}' }, + }, + state + ); + const done2 = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_2", name: "task", arguments: '{"i":2}' }, + }, + state + ); + + assert.deepEqual( + [done0, done1, done2].map((r) => r.choices[0].delta.tool_calls[0].index), + [0, 1, 2], + "argument chunks must reuse the SAME index assigned at .added time for each call_id" + ); +}); + +test("Responses -> OpenAI: parallel calls closed out of order keep their own index", () => { + const state = {}; + + for (const callId of ["call_a", "call_b", "call_c"]) { + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: callId, name: "task" }, + }, + state + ); + } + + // Close in reverse order: c, then a, then b. + const doneC = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_c", name: "task", arguments: "{}" }, + }, + state + ); + const doneA = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_a", name: "task", arguments: "{}" }, + }, + state + ); + const doneB = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_b", name: "task", arguments: "{}" }, + }, + state + ); + + assert.equal(doneC.choices[0].delta.tool_calls[0].index, 2); + assert.equal(doneA.choices[0].delta.tool_calls[0].index, 0); + assert.equal(doneB.choices[0].delta.tool_calls[0].index, 1); +}); + +test("Responses -> OpenAI: argument deltas interleaved across 2 parallel calls do not get glued together", () => { + const state = {}; + + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_x", name: "Read", id: "fc_call_x" }, + }, + state + ); + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_y", name: "Read", id: "fc_call_y" }, + }, + state + ); + + // Interleave argument deltas by item_id — x, y, x, y — before either closes. + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", item_id: "fc_call_x", delta: '{"filePath"' }, + state + ); + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", item_id: "fc_call_y", delta: '{"filePath"' }, + state + ); + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", item_id: "fc_call_x", delta: ':"/a.txt"}' }, + state + ); + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", item_id: "fc_call_y", delta: ':"/b.txt"}' }, + state + ); + + const doneX = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_x", name: "Read" }, + }, + state + ); + const doneY = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_y", name: "Read" }, + }, + state + ); + + assert.equal(doneX.choices[0].delta.tool_calls[0].function.arguments, '{"filePath":"/a.txt"}'); + assert.equal(doneY.choices[0].delta.tool_calls[0].function.arguments, '{"filePath":"/b.txt"}'); +}); + +test("Responses -> OpenAI: a deferred (nameless) call that never resolves a name never consumes an index", () => { + const state = {}; + + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_deferred", name: "" }, + }, + state + ); + const done = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_deferred", name: " " }, + }, + state + ); + + assert.equal(done, null); + assert.equal(state.toolCallIndex, 0); +}); + +test("Responses -> OpenAI: argument deltas interleaved across 2 parallel calls resolve by output_index when the upstream omits item_id", () => { + const state = {}; + + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + output_index: 0, + item: { type: "function_call", call_id: "call_p", name: "Read" }, + }, + state + ); + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + output_index: 1, + item: { type: "function_call", call_id: "call_q", name: "Read" }, + }, + state + ); + + // No item_id on any of these deltas — only output_index, which the Responses API + // guarantees on every streamed event regardless of whether item_id is also sent. + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", output_index: 0, delta: '{"filePath"' }, + state + ); + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", output_index: 1, delta: '{"filePath"' }, + state + ); + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", output_index: 0, delta: ':"/p.txt"}' }, + state + ); + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", output_index: 1, delta: ':"/q.txt"}' }, + state + ); + + const doneP = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_p", name: "Read" }, + }, + state + ); + const doneQ = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.done", + item: { type: "function_call", call_id: "call_q", name: "Read" }, + }, + state + ); + + assert.equal(doneP.choices[0].delta.tool_calls[0].function.arguments, '{"filePath":"/p.txt"}'); + assert.equal(doneQ.choices[0].delta.tool_calls[0].function.arguments, '{"filePath":"/q.txt"}'); +}); + +test("Responses -> OpenAI: 2 parallel Agent calls still open at stream end each get their own flush chunk", () => { + const state = {}; + + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_agent0", name: "Agent" }, + }, + state + ); + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", output_index: 0, delta: '{"task":"a"}' }, + state + ); + openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { type: "function_call", call_id: "call_agent1", name: "Agent" }, + }, + state + ); + openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", output_index: 1, delta: '{"task":"b"}' }, + state + ); + + // Stream ends (chunk === null) before either call's output_item.done arrives. + const flushed = openaiResponsesToOpenAIResponse(null, state); + + assert.ok(Array.isArray(flushed)); + const argChunks = flushed.filter((c) => c.choices[0].delta.tool_calls); + assert.deepEqual( + argChunks.map((c) => c.choices[0].delta.tool_calls[0].index).sort(), + [0, 1], + "each still-open parallel call must get its own flush chunk, at its own index" + ); + const finalChunk = flushed[flushed.length - 1]; + assert.equal(finalChunk.choices[0].finish_reason, "tool_calls"); +}); diff --git a/tests/unit/stream-payload-collector.test.ts b/tests/unit/stream-payload-collector.test.ts index e8869bb8bf..63b96c5eaf 100644 --- a/tests/unit/stream-payload-collector.test.ts +++ b/tests/unit/stream-payload-collector.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; const collector = await import("../../open-sse/utils/streamPayloadCollector.ts"); +import { splitConcatenatedToolCallArguments } from "../../open-sse/utils/streamPayloadCollector.ts"; test("compactStructuredStreamPayload returns null for null input", () => { assert.equal(collector.compactStructuredStreamPayload(null), null); @@ -413,3 +414,33 @@ test("#9315: getSummary() returns undefined when no format was configured (unaff c.push({ choices: [{ index: 0, delta: { content: "hi" } }] }); assert.equal(c.getSummary(), undefined); }); + +test("splitConcatenatedToolCallArguments — two back-to-back JSON objects", () => { + const a = JSON.stringify({ tool: "x", args: "1" }); + const b = JSON.stringify({ tool: "y", args: "2" }); + const out = splitConcatenatedToolCallArguments(a + b); + assert.deepEqual(out, [a, b]); // >=2 valid values -> split (array of parts) +}); + +test("splitConcatenatedToolCallArguments — nested object + escaped quotes stay single JSON", () => { + const a = JSON.stringify({ a: 'he said "hi"', b: { c: 1 } }); + const single = a; // a is ONE valid JSON object -> no split + const out = splitConcatenatedToolCallArguments(single); + assert.equal(out, null); // single valid JSON -> untouched (null) +}); + +test("splitConcatenatedToolCallArguments — braces/quotes inside strings exercise escaped scanner", () => { + // Two valid JSON values whose string bodies contain braces and escaped quotes. + // Concatenated they reach the inString/escaped state machine (not the JSON.parse + // fast path), so this covers the case the owner asked about. + const a = JSON.stringify({ cmd: 'echo "}{" ; x' }); + const b = JSON.stringify({ cmd: "{[not json]}" }); + const out = splitConcatenatedToolCallArguments(a + b); + assert.deepEqual(out, [a, b]); // >=2 valid values -> split into parts +}); + +test("splitConcatenatedToolCallArguments — top-level array is single value", () => { + const arr = JSON.stringify([{ tool: "x" }, { tool: "y" }]); + const out = splitConcatenatedToolCallArguments(arr); + assert.equal(out, null); // one value boundary (array) -> not split +}); diff --git a/tests/unit/stream-recovery-toolcall.test.ts b/tests/unit/stream-recovery-toolcall.test.ts new file mode 100644 index 0000000000..7f3a765343 --- /dev/null +++ b/tests/unit/stream-recovery-toolcall.test.ts @@ -0,0 +1,178 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + createRecoverableStream, + TruncatedStreamError, + scanOpenAiSseText, +} from "../../open-sse/services/streamRecovery.ts"; + +const enc = new TextEncoder(); + +// Deliver the SSE chunk on the first read, then error on the second read so the +// holdback window has committed (post-commit truncation) before the cut. +function makeStream(sse: string): ReadableStream { + let n = 0; + return new ReadableStream({ + pull(c) { + n += 1; + if (n === 1) { + c.enqueue(enc.encode(sse)); + return; + } + c.error(new TruncatedStreamError()); + }, + }); +} + +// A clock that jumps past HOLDBACK_MS on the second read so the very first pushed +// chunk commits the holdback window immediately (post-commit truncation path). +function jumpingClock(): () => number { + let t = 0; + return () => (t += 1000); +} + +describe("scanOpenAiSseText: terminal vs in-flight tool call", () => { + it("tool_calls without finish_reason → inFlight true, terminal false", () => { + const sse = + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","function":{"name":"lookup"}}]}}]}\n\n'; + const r = scanOpenAiSseText(sse); + assert.equal(r.sawToolCall, true); + assert.equal(r.sawToolCallInFlight, true); + assert.equal(r.terminal, false); + }); + + it("complete tool_calls + finish_reason + [DONE] → terminal true, inFlight false", () => { + const sse = + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","function":{"name":"lookup","arguments":"{}"}}]}}]}\n' + + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n' + + "data: [DONE]\n\n"; + const r = scanOpenAiSseText(sse); + assert.equal(r.sawToolCall, true); + assert.equal(r.terminal, true); + assert.equal(r.sawToolCallInFlight, false); + }); + + it("plain text → no tool call", () => { + const sse = 'data: {"choices":[{"index":0,"delta":{"content":"hello"}}]}\n\n'; + const r = scanOpenAiSseText(sse); + assert.equal(r.sawToolCall, false); + assert.equal(r.sawToolCallInFlight, false); + assert.equal(r.terminal, false); + }); + + it("complete tool_calls WITHOUT [DONE] → terminal false, inFlight false (the actual fix)", () => { + // This is the case the original plan promised to unblock: the tool call itself is + // done (finish_reason: "tool_calls"), but the overall stream/turn has not sent its + // own terminal marker yet — a truncation right here is recoverable. + const sse = + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","function":{"name":"lookup","arguments":"{}"}}]}}]}\n' + + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n'; + const r = scanOpenAiSseText(sse); + assert.equal(r.sawToolCall, true); + assert.equal(r.sawToolCallInFlight, false); + assert.equal(r.terminal, false); + }); +}); + +describe("stream recovery does not duplicate an in-flight tool call", () => { + it("truncation with an in-flight tool call → no continuation", async () => { + let continued = false; + const sse = + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"c1","function":{"name":"f"}}]}}]}\n\n'; + const wrapped = createRecoverableStream(makeStream(sse), async () => null, { + finalize: () => {}, + now: jumpingClock(), + continueStream: async () => { + continued = true; + return null; + }, + }); + const reader = wrapped.getReader(); + try { + for (;;) { + const r = await reader.read(); + if (r.done) break; + } + } catch { + // the in-flight tool call makes the stream close without continuing + } + assert.equal(continued, false); + }); + + it("truncation right after a completed tool call → continuation attempted (the real 91% gain)", async () => { + // Text was emitted, THEN the tool call completed (finish_reason: "tool_calls"), THEN + // the connection drops before a [DONE]/other terminal marker. Before this fix, the + // blunt `emittedToolCall` guard blocked recovery here even though the call itself is + // done and only trailing prose was lost — this is the exact case the plan promised + // to unblock and the pre-fix table proved was a no-op. + let continued = false; + const sse = + 'data: {"choices":[{"index":0,"delta":{"content":"Let me check that. "}}]}\n' + + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"c1","function":{"name":"f","arguments":"{}"}}]}}]}\n' + + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n'; + const wrapped = createRecoverableStream(makeStream(sse), async () => null, { + finalize: () => {}, + now: jumpingClock(), + continueStream: async () => { + continued = true; + return null; + }, + }); + const reader = wrapped.getReader(); + try { + for (;;) { + const r = await reader.read(); + if (r.done) break; + } + } catch { + // no-op + } + assert.equal(continued, true); + }); + + it("truncation of plain text → continuation attempted", async () => { + let continued = false; + const sse = 'data: {"choices":[{"index":0,"delta":{"content":"hello "}}]}\n\n'; + const wrapped = createRecoverableStream(makeStream(sse), async () => null, { + finalize: () => {}, + now: jumpingClock(), + continueStream: async () => { + continued = true; + return null; + }, + }); + const reader = wrapped.getReader(); + try { + for (;;) { + const r = await reader.read(); + if (r.done) break; + } + } catch { + // no-op + } + assert.equal(continued, true); + }); + + it("naive removal of the tool-call guard would duplicate a partial tool call", () => { + // The blunt `sawToolCall` flag is true for BOTH a complete tool call and a + // partial (in-flight) one. The new `sawToolCallInFlight` flag is the only + // signal that tells them apart: a naive guard keyed on `sawToolCall` would + // block the complete call AND let the partial one through to the + // continuation, where trimContinuationOverlap (text-only) cannot de-duplicate + // the replayed tool_calls arguments. + const ssePartial = + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","function":{"name":"lookup","arguments":"{\\"q\\""}}]}}]}\n\n'; + const sseFull = + 'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"id":"call_1","function":{"name":"lookup","arguments":"{\\"q\\":\\"x\\"}"}}]}}]}\n' + + 'data: {"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n'; + const scanPartial = scanOpenAiSseText(ssePartial); + const scanFull = scanOpenAiSseText(sseFull); + // The blunt flag cannot distinguish them. + assert.equal(scanPartial.sawToolCall, true); + assert.equal(scanFull.sawToolCall, true); + // The in-flight flag can — and that is what keeps canContinue false only for + // the partial tool call, so the continuation never replays it. + assert.equal(scanPartial.sawToolCallInFlight, true); + assert.equal(scanFull.sawToolCallInFlight, false); + }); +}); diff --git a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts index cab5e9c37f..25eae7a839 100644 --- a/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts +++ b/tests/unit/strip-reasoning-blobs-agentic-context-1599.test.ts @@ -274,7 +274,11 @@ test("explicit custom target opt-in remains an opaque transport override", () => const result = applyReasoningInputPolicy(body, "responses", { preserveEncryptedReasoning: true }); assert.equal(result.incompatibleReasoning, false); - assert.deepEqual(body.input, [{ type: "reasoning", encrypted_content: "encrypted-blob" }]); + // #11108: a kept opaque item defaults `summary` when the source omitted it — + // some upstreams reject `input[]` reasoning items missing the field entirely. + assert.deepEqual(body.input, [ + { type: "reasoning", encrypted_content: "encrypted-blob", summary: [] }, + ]); }); test("preserved opaque reasoning remains redacted from log copies", () => { diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index e0d0a51773..da87b26116 100644 --- a/tests/unit/translator-openai-responses-req.test.ts +++ b/tests/unit/translator-openai-responses-req.test.ts @@ -475,6 +475,7 @@ test("Chat -> Responses defaults unannotated targets to plaintext reasoning", () { type: "reasoning", content: [{ type: "reasoning_text", text: "Inspect the repository first" }], + summary: [], }, { type: "function_call", @@ -513,6 +514,7 @@ test("Chat -> DeepSeek Responses accepts the plaintext reasoning alias", () => { assert.deepEqual(result.input[0], { type: "reasoning", content: [{ type: "reasoning_text", text: "Alias plaintext reasoning" }], + summary: [], }); }); diff --git a/tests/unit/webhook-discord-dispatcher.test.ts b/tests/unit/webhook-discord-dispatcher.test.ts index 4a49c3ba2e..605f4aa86b 100644 --- a/tests/unit/webhook-discord-dispatcher.test.ts +++ b/tests/unit/webhook-discord-dispatcher.test.ts @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { WEBHOOK_EVENT_VALUES } from "../../src/lib/webhooks/eventDescriptions.ts"; const { buildDiscordPayload } = await import("../../src/lib/webhooks/integrations/discord.ts"); @@ -14,15 +15,7 @@ test("buildDiscordPayload — request.failed produces embed with model", () => { }); test("buildDiscordPayload — all WEBHOOK_EVENTS return object with content or embeds", () => { - const events = [ - "request.completed", - "request.failed", - "provider.error", - "provider.recovered", - "quota.exceeded", - "combo.switched", - "test.ping", - ] as const; + const events = WEBHOOK_EVENT_VALUES; for (const event of events) { const payload = buildDiscordPayload(event, {}); assert.ok( @@ -33,7 +26,7 @@ test("buildDiscordPayload — all WEBHOOK_EVENTS return object with content or e }); test("buildDiscordPayload — embeds have title and color fields", () => { - const payload = buildDiscordPayload("provider.error", { provider: "openai" }); + const payload = buildDiscordPayload("request.failed", { provider: "openai" }); assert.ok(Array.isArray(payload.embeds) && payload.embeds.length > 0, "should have embeds"); const embed = payload.embeds![0]; assert.ok(typeof embed.title === "string" && embed.title.length > 0, "embed must have title"); diff --git a/tests/unit/webhook-slack-dispatcher.test.ts b/tests/unit/webhook-slack-dispatcher.test.ts index 44b34626cf..ad91a774e3 100644 --- a/tests/unit/webhook-slack-dispatcher.test.ts +++ b/tests/unit/webhook-slack-dispatcher.test.ts @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { WEBHOOK_EVENT_VALUES } from "../../src/lib/webhooks/eventDescriptions.ts"; const { buildSlackPayload } = await import("../../src/lib/webhooks/integrations/slack.ts"); @@ -30,8 +31,8 @@ test("buildSlackPayload — test.ping produces a ping/test message", () => { ); }); -test("buildSlackPayload — provider.error includes provider context", () => { - const payload = buildSlackPayload("provider.error", { provider: "openai", model: "gpt-4" }); +test("buildSlackPayload — request.failed includes provider context", () => { + const payload = buildSlackPayload("request.failed", { provider: "openai" }); const combined = JSON.stringify(payload); assert.ok( combined.includes("Provider") || @@ -43,15 +44,7 @@ test("buildSlackPayload — provider.error includes provider context", () => { }); test("buildSlackPayload — all WEBHOOK_EVENTS produce valid payloads with text field", () => { - const events = [ - "request.completed", - "request.failed", - "provider.error", - "provider.recovered", - "quota.exceeded", - "combo.switched", - "test.ping", - ] as const; + const events = WEBHOOK_EVENT_VALUES; for (const event of events) { const payload = buildSlackPayload(event, {}); assert.ok( diff --git a/tests/unit/webhook-telegram-dispatcher.test.ts b/tests/unit/webhook-telegram-dispatcher.test.ts index eeb5ee2c92..29bf2f586c 100644 --- a/tests/unit/webhook-telegram-dispatcher.test.ts +++ b/tests/unit/webhook-telegram-dispatcher.test.ts @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { WEBHOOK_EVENT_VALUES } from "../../src/lib/webhooks/eventDescriptions.ts"; const { buildTelegramPayload, buildTelegramUrl } = await import("../../src/lib/webhooks/integrations/telegram.ts"); @@ -77,15 +78,7 @@ test("buildTelegramPayload — chat_id matches provided value for groups", () => }); test("buildTelegramPayload — all WEBHOOK_EVENTS produce valid payloads with chat_id", () => { - const events = [ - "request.completed", - "request.failed", - "provider.error", - "provider.recovered", - "quota.exceeded", - "combo.switched", - "test.ping", - ] as const; + const events = WEBHOOK_EVENT_VALUES; for (const event of events) { const payload = buildTelegramPayload(event, {}, "99999"); assert.equal(payload.chat_id, "99999"); diff --git a/tests/unit/webhooks-ghost-events.test.ts b/tests/unit/webhooks-ghost-events.test.ts index 427b0467a7..a5d26e6019 100644 --- a/tests/unit/webhooks-ghost-events.test.ts +++ b/tests/unit/webhooks-ghost-events.test.ts @@ -30,4 +30,16 @@ describe("webhook catalogue", () => { const { notifyWebhookEvent } = await import("../../src/lib/webhookDispatcher.ts"); assert.equal(typeof notifyWebhookEvent, "function"); }); + + it("every builder accepts every value in WEBHOOK_EVENT_VALUES without throwing", async () => { + const { buildDiscordPayload } = await import("../../src/lib/webhooks/integrations/discord.ts"); + const { buildSlackPayload } = await import("../../src/lib/webhooks/integrations/slack.ts"); + const { buildTelegramPayload } = + await import("../../src/lib/webhooks/integrations/telegram.ts"); + for (const event of WEBHOOK_EVENT_VALUES) { + assert.doesNotThrow(() => buildDiscordPayload(event, {})); + assert.doesNotThrow(() => buildSlackPayload(event, {})); + assert.doesNotThrow(() => buildTelegramPayload(event, {}, "99999")); + } + }); });