mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 15:42:12 +03:00
merge: resolve DIRTY against updated release/v3.8.50 (vi.json already translated upstream)
This commit is contained in:
1
changelog.d/features/11104-operator-error-rules.md
Normal file
1
changelog.d/features/11104-operator-error-rules.md
Normal file
@@ -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))
|
||||
1
changelog.d/fixes/11060-perplexity-filter.md
Normal file
1
changelog.d/fixes/11060-perplexity-filter.md
Normal file
@@ -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)
|
||||
1
changelog.d/fixes/11095-termux-onnx.md
Normal file
1
changelog.d/fixes/11095-termux-onnx.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(install): make the ONNX dependency chain optional so Termux/Android installs succeed again (#11095)
|
||||
1
changelog.d/fixes/11101-reject-silent-validation.md
Normal file
1
changelog.d/fixes/11101-reject-silent-validation.md
Normal file
@@ -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))
|
||||
1
changelog.d/fixes/11102-combo-suggestion-count.md
Normal file
1
changelog.d/fixes/11102-combo-suggestion-count.md
Normal file
@@ -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=<comboId>`) 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)).
|
||||
1
changelog.d/fixes/11103-persist-config-audit-log.md
Normal file
1
changelog.d/fixes/11103-persist-config-audit-log.md
Normal file
@@ -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)).
|
||||
1
changelog.d/fixes/11109-stream-recovery-toolcall.md
Normal file
1
changelog.d/fixes/11109-stream-recovery-toolcall.md
Normal file
@@ -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))
|
||||
@@ -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
|
||||
@@ -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))
|
||||
1
changelog.d/maintenance/vi-harimport-parity.md
Normal file
1
changelog.d/maintenance/vi-harimport-parity.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(i18n): translate the 14 `providers.harImport*` keys into Vietnamese (parity gap left by #11069)
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, OperatorProviderErrorRule[]> = {};
|
||||
|
||||
/**
|
||||
* 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<string, OperatorProviderErrorRule[]> | 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<string, ProviderErrorRule[]>([
|
||||
* 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<string, string> | null | undefined,
|
||||
body?: unknown
|
||||
body?: unknown,
|
||||
operatorRules?: Record<string, OperatorProviderErrorRule[]>
|
||||
): 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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
126
open-sse/services/learnedReasoningEffortCaps.ts
Normal file
126
open-sse/services/learnedReasoningEffortCaps.ts
Normal file
@@ -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<string, string>();
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isValidResponsesItemId } from "./responsesItemId.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
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];
|
||||
|
||||
7
open-sse/services/responsesItemId.ts
Normal file
7
open-sse/services/responsesItemId.ts
Normal file
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>[] = 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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
72
package-lock.json
generated
72
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
---
|
||||
<!-- generated by src/lib/agentSkills/generator.ts; manual edits will be overwritten -->
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
/>
|
||||
<MetricBlock
|
||||
icon="error"
|
||||
|
||||
@@ -87,6 +87,22 @@ export function parseAlibabaModelStudioModelsForConnection(
|
||||
export function parseQwenCloudTextModels(data: any): any[] {
|
||||
return parseCuratedDashscopeModels(data, QWEN_CLOUD_TEXT_MODELS, QWEN_CLOUD_TEXT_MODEL_IDS);
|
||||
}
|
||||
|
||||
// Perplexity's /v1/models 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. Filter
|
||||
// discovery to Sonar-family ids so agent-style ids never surface as routable
|
||||
// chat models (#11060). Bounded pattern — no ReDoS-prone quantifiers.
|
||||
export function parsePerplexitySonarModels(data: any): any[] {
|
||||
const models = Array.isArray(data?.data)
|
||||
? data.data
|
||||
: Array.isArray(data?.models)
|
||||
? data.models
|
||||
: [];
|
||||
return models.filter(
|
||||
(model: any) => typeof model?.id === "string" && /^sonar(-|$)/.test(model.id)
|
||||
);
|
||||
}
|
||||
type ProviderModelsHeaderContext = {
|
||||
authType?: string;
|
||||
providerSpecificData?: unknown;
|
||||
@@ -659,6 +675,17 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
|
||||
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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
// ── 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<string, unknown> | null),
|
||||
after: row.after_json === null ? null : (JSON.parse(row.after_json) as Record<string, unknown> | 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<string, unknown> = {};
|
||||
|
||||
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<string, number> = {};
|
||||
const bySource: Record<string, number> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
@@ -46,6 +50,7 @@ interface RuntimeSettingsSnapshot {
|
||||
systemTransforms: unknown;
|
||||
authzBypass: AuthzBypassSnapshot;
|
||||
customBannedSignals: string[];
|
||||
providerErrorRules: Record<string, OperatorProviderErrorRule[]> | 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<string, OperatorProviderErrorRule[]> | null {
|
||||
if (value === null || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const result: Record<string, OperatorProviderErrorRule[]> = {};
|
||||
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<string, string> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -193,6 +193,31 @@ export async function cleanupMcpAudit(): Promise<CleanupResult> {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old config_audit_log based on retention settings.
|
||||
*/
|
||||
export async function cleanupConfigAudit(retentionDays = getRetentionSettings().configAudit): Promise<CleanupResult> {
|
||||
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(),
|
||||
|
||||
@@ -46,6 +46,7 @@ const LEGACY_FLAT_KEYS: {
|
||||
quotaSnapshots: ["quotaSnapshots"],
|
||||
compressionAnalytics: ["compressionAnalytics"],
|
||||
mcpAudit: ["mcpAudit"],
|
||||
configAudit: ["configAudit"],
|
||||
a2aEvents: ["a2aEvents"],
|
||||
callLogs: ["callLogs"],
|
||||
usageHistory: ["usageHistory"],
|
||||
|
||||
@@ -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');
|
||||
|
||||
15
src/lib/db/migrations/161_config_audit_log.sql
Normal file
15
src/lib/db/migrations/161_config_audit_log.sql
Normal file
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string, number> | 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<string, number> | 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<string, number> = {};
|
||||
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
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<string, number> | 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<string, number> = {};
|
||||
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -84,11 +84,29 @@ export const buildOpenCodeProviderConfig = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const buildOpenCodeV2ProviderConfig = (
|
||||
input: OpenCodeConfigInput
|
||||
): Record<string, any> => {
|
||||
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<string, unknown>).provider;
|
||||
const safeProvider =
|
||||
existingProvider && typeof existingProvider === "object" && !Array.isArray(existingProvider)
|
||||
? (existingProvider as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const existingProviders = (safeConfig as Record<string, unknown>).providers;
|
||||
const safeProviders =
|
||||
existingProviders && typeof existingProviders === "object" && !Array.isArray(existingProviders)
|
||||
? (existingProviders as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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<TSchema extends z.ZodTypeAny>(
|
||||
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 }
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<DatabaseSettings, "location" | "sta
|
||||
quotaSnapshots: 7,
|
||||
compressionAnalytics: 30,
|
||||
mcpAudit: 30,
|
||||
configAudit: 30,
|
||||
a2aEvents: 30,
|
||||
callLogs: 30,
|
||||
usageHistory: 30,
|
||||
|
||||
@@ -272,6 +272,7 @@
|
||||
"tests/unit/model-lockout-max-cooldown.test.ts",
|
||||
"tests/unit/no-memory-header.test.ts",
|
||||
"tests/unit/noauth-autocombo-lockout-7623.test.ts",
|
||||
"tests/unit/ollama-404-model-lockout-11071.test.ts",
|
||||
"tests/unit/non-streaming-sse-terminal-typescan-4459.test.ts",
|
||||
"tests/unit/nvidia-410-model-scope.test.ts",
|
||||
"tests/unit/nvidia-passthrough-models-6773.test.ts",
|
||||
@@ -283,6 +284,7 @@
|
||||
"tests/unit/ollama-cloud-weekly-quota-cooldown-3709.test.ts",
|
||||
"tests/unit/openapi-security-tiers.test.ts",
|
||||
"tests/unit/opencode-autocombo-search-pair.test.ts",
|
||||
"tests/unit/opencode-v2-config-11070.test.ts",
|
||||
"tests/unit/openrouter-free-model-credits-exhausted.test.ts",
|
||||
"tests/unit/openrouter-passthrough-models.test.ts",
|
||||
"tests/unit/openrouter-quota-6842.test.ts",
|
||||
|
||||
@@ -9,40 +9,52 @@ function readJson<T = Record<string, unknown>>(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<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
overrides?: Record<string, string>;
|
||||
}>("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<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
}>("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<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
}
|
||||
>;
|
||||
>;
|
||||
}>("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"
|
||||
);
|
||||
});
|
||||
|
||||
39
tests/unit/cline-model-format-11099.test.ts
Normal file
39
tests/unit/cline-model-format-11099.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
24
tests/unit/columns-validation.test.ts
Normal file
24
tests/unit/columns-validation.test.ts
Normal file
@@ -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 });
|
||||
});
|
||||
108
tests/unit/combo-health-autopilot-counter.test.ts
Normal file
108
tests/unit/combo-health-autopilot-counter.test.ts
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
124
tests/unit/config-audit-persistence.test.ts
Normal file
124
tests/unit/config-audit-persistence.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
7
tests/unit/http-status-unprocessable-entity.test.ts
Normal file
7
tests/unit/http-status-unprocessable-entity.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
38
tests/unit/is-local-provider-11091.test.ts
Normal file
38
tests/unit/is-local-provider-11091.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
126
tests/unit/learned-reasoning-effort-caps.test.ts
Normal file
126
tests/unit/learned-reasoning-effort-caps.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
66
tests/unit/ollama-404-model-lockout-11071.test.ts
Normal file
66
tests/unit/ollama-404-model-lockout-11071.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
40
tests/unit/opencode-v2-config-11070.test.ts
Normal file
40
tests/unit/opencode-v2-config-11070.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
63
tests/unit/perplexity-discovery-filter.test.ts
Normal file
63
tests/unit/perplexity-discovery-filter.test.ts
Normal file
@@ -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({}), []);
|
||||
});
|
||||
11
tests/unit/pollinations-api-key-required-11096.test.ts
Normal file
11
tests/unit/pollinations-api-key-required-11096.test.ts
Normal file
@@ -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"
|
||||
);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
135
tests/unit/provider-error-rules-operator.test.ts
Normal file
135
tests/unit/provider-error-rules-operator.test.ts
Normal file
@@ -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<string, OperatorProviderErrorRule[]> = {
|
||||
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<string, OperatorProviderErrorRule[]> = {
|
||||
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<string, OperatorProviderErrorRule[]> = {
|
||||
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<string, OperatorProviderErrorRule[]> = {
|
||||
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<string, OperatorProviderErrorRule[]> = {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
42
tests/unit/providers-patch-400.test.ts
Normal file
42
tests/unit/providers-patch-400.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
@@ -664,6 +664,7 @@ describe("Reasoning Replay Cache — Translator Replay", () => {
|
||||
{
|
||||
type: "reasoning",
|
||||
content: [{ type: "reasoning_text", text: "Cached Chat continuation reasoning" }],
|
||||
summary: [],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
110
tests/unit/reasoning-effort-clamp-and-retry.test.ts
Normal file
110
tests/unit/reasoning-effort-clamp-and-retry.test.ts
Normal file
@@ -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<string, unknown>) {
|
||||
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<string, unknown>[] = [];
|
||||
|
||||
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<string, unknown>[] = [];
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
91
tests/unit/reasoning-effort-learned-capability.test.ts
Normal file
91
tests/unit/reasoning-effort-learned-capability.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
145
tests/unit/reasoning-input-policy-summary-11108.test.ts
Normal file
145
tests/unit/reasoning-input-policy-summary-11108.test.ts
Normal file
@@ -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<string, unknown> = {
|
||||
input: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_example",
|
||||
encrypted_content: "opaque-blob",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
applyReasoningInputPolicy(body, "responses", {
|
||||
provider: "opencode",
|
||||
preserveEncryptedReasoning: true,
|
||||
});
|
||||
|
||||
const input = body.input as Record<string, unknown>[];
|
||||
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<string, unknown> = {
|
||||
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<string, unknown>[];
|
||||
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<string, unknown> = {
|
||||
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<string, unknown>[];
|
||||
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<string, unknown> = {
|
||||
input: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: null,
|
||||
encrypted_content: "opaque-blob",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
applyReasoningInputPolicy(body, "responses", {
|
||||
provider: "opencode",
|
||||
preserveEncryptedReasoning: true,
|
||||
});
|
||||
|
||||
const input = body.input as Record<string, unknown>[];
|
||||
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<string, unknown> = {
|
||||
input: [
|
||||
{
|
||||
type: "function_call",
|
||||
id: null,
|
||||
call_id: "call_abc",
|
||||
name: "bash",
|
||||
arguments: "{}",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
applyReasoningInputPolicy(body, "responses", { provider: "opencode" });
|
||||
|
||||
const input = body.input as Record<string, unknown>[];
|
||||
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<string, unknown> = {
|
||||
input: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_example",
|
||||
encrypted_content: "opaque-blob",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
applyReasoningInputPolicy(body, "responses", {
|
||||
provider: "opencode",
|
||||
preserveEncryptedReasoning: true,
|
||||
});
|
||||
|
||||
const input = body.input as Record<string, unknown>[];
|
||||
assert.equal(input[0].id, "rs_example");
|
||||
});
|
||||
@@ -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<Record<string, unknown>>;
|
||||
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 = [
|
||||
{
|
||||
|
||||
294
tests/unit/responses-parallel-tool-calls-index.test.ts
Normal file
294
tests/unit/responses-parallel-tool-calls-index.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
|
||||
178
tests/unit/stream-recovery-toolcall.test.ts
Normal file
178
tests/unit/stream-recovery-toolcall.test.ts
Normal file
@@ -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<Uint8Array> {
|
||||
let n = 0;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user