mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-23 07:32:20 +03:00
Compare commits
27 Commits
fix/10265-
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84b9167a6a | ||
|
|
4220c810ee | ||
|
|
50fc0d7299 | ||
|
|
9a67185297 | ||
|
|
3157e8a7ad | ||
|
|
a51b8ba563 | ||
|
|
533a6c0ce4 | ||
|
|
66f97968c4 | ||
|
|
1bd29962a5 | ||
|
|
dae3a72e82 | ||
|
|
5631e91f0d | ||
|
|
1dd0173468 | ||
|
|
c89fc6ba2a | ||
|
|
84c9dfdd2c | ||
|
|
f3b190ba3e | ||
|
|
d021423af3 | ||
|
|
78b4082361 | ||
|
|
efc7134167 | ||
|
|
b44f22a949 | ||
|
|
02a078e95f | ||
|
|
d9b3ce266f | ||
|
|
da490a759f | ||
|
|
d91238b720 | ||
|
|
9629693a3c | ||
|
|
742ccb98a0 | ||
|
|
6cd4d38e21 | ||
|
|
b6412c6fed |
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/fixes/release-v3850-basereds-tests-i18n.md
Normal file
1
changelog.d/fixes/release-v3850-basereds-tests-i18n.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(i18n): complete Vietnamese translations for recently added UI strings (#9985)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(quality): register `tests/unit/authz/oauth-autoimport-local-only.test.ts` in stryker `tap.testFiles` (residual of #11053)
|
||||
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)
|
||||
@@ -443,7 +443,7 @@
|
||||
"src/shared/components/ModelSelectModal.tsx": 1138,
|
||||
"src/shared/constants/providers/apikey/gateways.ts": 1250
|
||||
},
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1073,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 1080,
|
||||
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": 1051,
|
||||
"src/shared/components/ModelSelectModal.tsx": 1138,
|
||||
"src/shared/constants/providers/apikey/gateways.ts": 1298,
|
||||
@@ -459,7 +459,8 @@
|
||||
"open-sse/executors/commandCode.ts": 1059,
|
||||
"_rebaseline_2026_08_21_10859_vision_bridge_catalog": "#10859 own growth (Vision Bridge fixes #10808/#10809): src/lib/modelCapabilities.ts 1006->1016 (+10, cmd/gpt-5.3-codex* text-only capability resolution) and open-sse/executors/commandCode.ts 988->1023 (+35, Command Code wire-model normalization for bare ids + reasoning field fallback for opencode-routed gateways). Cohesive bug fixes at the existing capability-resolution / executor chokepoints; not extractable mid-fix. Covered by tests/unit/model-capabilities-command-code-codex-textonly-10703.test.ts, tests/unit/command-code-vision.test.ts, tests/unit/opencode-mimo-reasoning-details-nonstream.test.ts. Pushed directly to release (own-session miss: the original rebaseline was made in a throwaway validation worktree and never landed on the PR branch or the release before merge).",
|
||||
"_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.",
|
||||
"_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming)."
|
||||
"_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming).",
|
||||
"_rebaseline_2026_08_21_11069_m365_har_import": "#11069 own growth: AddApiKeyModal.tsx 1073->1080 (+7 = Import .har file button for the copilot-m365-web credential modal — M365 is the only provider whose credential (access_token+chathubPath) must be extracted from a DevTools HAR WebSocket URL, added as a new modal affordance). Cohesive UI at the existing modal chokepoint; not extractable. Covered by tests/unit/m365-har-import*.test.ts."
|
||||
},
|
||||
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
|
||||
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -30,17 +30,45 @@ export const HANDSHAKE_REQUEST = { protocol: "json", version: 1 } as const;
|
||||
export const KEEPALIVE_PING = { type: 6 } as const;
|
||||
|
||||
/**
|
||||
* Allowed message types observed in the 2026-08 recapture of the working
|
||||
* `m365.cloud.microsoft/chat` client (#10718). The old 11-entry list is no longer
|
||||
* seen on the wire — the stale shape gets closed immediately after the type:4.
|
||||
* Allowed message types observed in a 2026-08-21 live capture of a working
|
||||
* `m365.cloud.microsoft/chat` session (issue: "Stream ended before producing a
|
||||
* non-ping SSE event" on every individual/consumer M365 Copilot call). The
|
||||
* #10718 6-entry shape above no longer produces a `type:1 target:"update"`
|
||||
* frame at all — the socket only replies with SignalR keepalive pings and then
|
||||
* closes, which is exactly what surfaces client-side as that generic stream
|
||||
* error. 30 entries, up from 6.
|
||||
*/
|
||||
export const ALLOWED_MESSAGE_TYPES = [
|
||||
"Chat",
|
||||
"Suggestion",
|
||||
"InternalSearchQuery",
|
||||
"Disengaged",
|
||||
"Progress",
|
||||
"EndOfRequest",
|
||||
"InternalLoaderMessage",
|
||||
"Progress",
|
||||
"GeneratedCode",
|
||||
"RenderCardRequest",
|
||||
"AdsQuery",
|
||||
"SemanticSerp",
|
||||
"GenerateContentQuery",
|
||||
"GenerateGraphicArt",
|
||||
"SearchQuery",
|
||||
"ConfirmationCard",
|
||||
"AuthError",
|
||||
"DeveloperLogs",
|
||||
"TriggerPlugin",
|
||||
"HintInvocation",
|
||||
"MemoryUpdate",
|
||||
"EndOfRequest",
|
||||
"TriggerConfirmation",
|
||||
"ResumeInvokeAction",
|
||||
"ResumeUserInputRequest",
|
||||
"TriggerUserInputRequest",
|
||||
"EscapeHatch",
|
||||
"TriggerPluginAuth",
|
||||
"ResumePluginAuth",
|
||||
"SideBySide",
|
||||
"ReferencesListComplete",
|
||||
"SwitchRespondingEndpoint",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -78,19 +106,26 @@ export const M365_ENTERPRISE_EXTRA_MESSAGE_TYPES = [
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Individual / EDU option sets from the 2026-08 recapture (#10718) — 14 entries.
|
||||
* The previous 25-entry consumer/MSA set (enable_msa_user, pdnascan, cwc_code_*,
|
||||
* …) is no longer observed on the wire and belongs to the shape the substrate
|
||||
* now drops silently.
|
||||
* Individual / EDU option sets from a 2026-08-21 live capture — 34 entries, up
|
||||
* from the #10718 14-entry shape (which itself superseded an earlier 25-entry
|
||||
* shape). Each recapture so far has been additive/reshuffled rather than a
|
||||
* wholesale replacement — treat this as the protocol continuing to drift, not
|
||||
* a one-time fix; a future capture may again need to update this list.
|
||||
*/
|
||||
export const M365_DEFAULT_OPTION_SETS = [
|
||||
"search_result_progress_messages_with_search_queries",
|
||||
"update_textdoc_response_after_streaming",
|
||||
"deepleo_networking_timeout_10minutes_canmore",
|
||||
"cwc_flux_image",
|
||||
"cwc_code_interpreter",
|
||||
"cwc_code_interpreter_amsfix",
|
||||
"cwcfluxgptv",
|
||||
"flux_v3_gptv_enable_upload_multi_image_in_turn_wo_ch",
|
||||
"gptvnorm2048",
|
||||
"cwc_code_interpreter_citation_fix",
|
||||
"code_interpreter_interactive_charts",
|
||||
"cwc_code_interpreter_interactive_charts_inline_image",
|
||||
"code_interpreter_matplotlib_patching",
|
||||
"cwc_fileupload_odb",
|
||||
"update_memory_plugin",
|
||||
"add_custom_instructions",
|
||||
@@ -98,6 +133,20 @@ export const M365_DEFAULT_OPTION_SETS = [
|
||||
"flux_v3_progress_messages",
|
||||
"enable_batch_token_processing",
|
||||
"enable_gg_gpt",
|
||||
"async_client_interaction",
|
||||
"flux_v3_references",
|
||||
"flux_v3_references_entities",
|
||||
"flux_v3_references_ci",
|
||||
"add_filestore_filetype",
|
||||
"cwc_code_interpreter_citation_sourceannotations",
|
||||
"cdxcwc_code_interpreter_hallucinated_url_filter",
|
||||
"flux_v3_image_gen_enable_dimensions",
|
||||
"flux_v3_image_gen_enable_non_watermarked_storage",
|
||||
"flux_v3_image_gen_enable_icon_dimensions",
|
||||
"flux_v3_image_gen_enable_system_text_with_params",
|
||||
"flux_v3_image_gen_enable_designer_dimensions_meta_prompting_in_system_prompts",
|
||||
"flux_v3_image_gen_enable_story",
|
||||
"rich_responses",
|
||||
] as const;
|
||||
|
||||
/** Append the record separator to a JSON-serializable frame. */
|
||||
@@ -433,12 +482,14 @@ export function resolveChatInvocationOverrides(tier: string | undefined): {
|
||||
}
|
||||
return {
|
||||
optionsSets: [...M365_DEFAULT_OPTION_SETS],
|
||||
// #10718 — the 2026-08 recapture sends tone:"magic" (lowercase) on the
|
||||
// individual/EDU surface; the old "" default is part of the dropped shape.
|
||||
tone: "magic",
|
||||
// 2026-08-21 capture — the individual/consumer surface now sends "Magic"
|
||||
// (capitalized), matching the enterprise tone literal. The #10718
|
||||
// lowercase "magic" is part of the shape that gets silently dropped.
|
||||
tone: "Magic",
|
||||
allowedMessageTypes: ALLOWED_MESSAGE_TYPES,
|
||||
// Omitted entirely on the individual/EDU wire (see ChatInvocationOptions).
|
||||
disconnectBehavior: undefined,
|
||||
// 2026-08-21 capture — disconnectBehavior:"continue" is now present on the
|
||||
// individual/consumer wire too, not just enterprise (see ChatInvocationOptions).
|
||||
disconnectBehavior: "continue",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -467,16 +518,33 @@ export function resolveToneForModel(model: string | undefined): string | undefin
|
||||
|
||||
/**
|
||||
* Build the `type:4` chat invocation frame body (not yet `\x1e`-terminated).
|
||||
* Mirrors the argument shape recaptured from a working `m365.cloud.microsoft/chat`
|
||||
* client in 2026-08 (#10718). Notable differences from the pre-#10718 shape: a
|
||||
* populated `clientInfo` + `productThreadType:"Office"`, a `conversationId`
|
||||
* matching the WS URL query, a rich `message` object, and no
|
||||
* `spokenTextMode` / `extraExtensionParameters` / `isSbsSupported` /
|
||||
* `renderReferencesBehindEOS` / `disconnectBehavior` — none of those are still
|
||||
* observed on the wire, and the stale shape gets closed immediately after the
|
||||
* invocation.
|
||||
* Base shape from the #10718 recapture (populated `clientInfo` +
|
||||
* `productThreadType:"Office"`, a `conversationId` matching the WS URL query, a
|
||||
* rich `message` object), extended per a 2026-08-21 live capture that found the
|
||||
* #10718 shape alone no longer produces a `type:1 target:"update"` frame — the
|
||||
* socket only replies with keepalive pings and closes. The additions below
|
||||
* (richer `clientInfo`, non-empty `plugins`, `extraExtensionParameters`,
|
||||
* `isSbsSupported`, `renderReferencesBehindEOS`,
|
||||
* `message.connectedFederatedConnections`, and `disconnectBehavior` on every
|
||||
* tier) are exactly the fields the 2026-08-21 capture had that this shape was
|
||||
* missing; the #10718 fields (`conversationId`, `productThreadType`,
|
||||
* `toolChoice`, `message.attachments`) are kept as-is since removing them was
|
||||
* not verified against a live socket.
|
||||
*/
|
||||
export function buildChatInvocation(opts: ChatInvocationOptions): Record<string, unknown> {
|
||||
const clientInfo = {
|
||||
clientAppName: "Office",
|
||||
clientPlatform: "mcmcopilot-web",
|
||||
clientEntrypoint: "mcmcopilot-officeweb",
|
||||
clientSessionId: opts.sessionId,
|
||||
ProductCategory: "Chat",
|
||||
clientAppType: "Web",
|
||||
productEntryPoint: "ChatPanel",
|
||||
deviceOS: "Windows",
|
||||
deviceType: "Desktop",
|
||||
clientPlatformVersion: "10",
|
||||
};
|
||||
|
||||
return {
|
||||
type: 4,
|
||||
target: "chat",
|
||||
@@ -487,17 +555,17 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
|
||||
? [...opts.allowedMessageTypes]
|
||||
: [...ALLOWED_MESSAGE_TYPES],
|
||||
clientCorrelationId: opts.clientCorrelationId ?? opts.traceId,
|
||||
clientInfo: {
|
||||
clientAppName: "Office",
|
||||
clientPlatform: "mcmcopilot-web",
|
||||
},
|
||||
clientInfo,
|
||||
conversationId: opts.conversationId,
|
||||
extraExtensionParameters: {},
|
||||
isStartOfSession: opts.isStartOfSession ?? true,
|
||||
message: {
|
||||
adaptiveCards: [],
|
||||
attachments: null,
|
||||
author: "user",
|
||||
clientInfo,
|
||||
clientPreferences: {},
|
||||
connectedFederatedConnections: ["dummyId"],
|
||||
entityAnnotationTypes: ["People", "File", "Event", "Email", "TeamsMessage"],
|
||||
experienceType: "Default",
|
||||
inputMethod: "Keyboard",
|
||||
@@ -510,22 +578,27 @@ export function buildChatInvocation(opts: ChatInvocationOptions): Record<string,
|
||||
requestId: opts.requestId,
|
||||
text: opts.text,
|
||||
},
|
||||
isSbsSupported: true,
|
||||
options: {},
|
||||
optionsSets: opts.optionsSets ?? [...M365_DEFAULT_OPTION_SETS],
|
||||
plugins: opts.plugins ?? [],
|
||||
// 2026-08-21 capture (#11069): BingWebSearch is now the universal
|
||||
// BuiltIn plugin on individual/consumer tier; keep an opt-out override.
|
||||
plugins: opts.plugins ?? [{ Id: "BingWebSearch", Source: "BuiltIn" }],
|
||||
...(opts.customInstructions ? { customInstructions: opts.customInstructions } : {}),
|
||||
productThreadType: "Office",
|
||||
renderReferencesBehindEOS: true,
|
||||
sessionId: opts.sessionId,
|
||||
sliceIds: [],
|
||||
source: "officeweb",
|
||||
streamingMode: "ConciseWithPadding",
|
||||
threadLevelGptId: {},
|
||||
tone: opts.tone ?? "magic",
|
||||
// 2026-08-21 capture (#11069): tone is now capitalized "Magic" on both tiers.
|
||||
tone: opts.tone ?? "Magic",
|
||||
toolChoice: opts.toolChoice ?? null,
|
||||
traceId: opts.traceId,
|
||||
// #8971 keeps "continue" for the enterprise tier; the individual/EDU wire
|
||||
// omits the key, so only include it when actually set (#10718).
|
||||
...(opts.disconnectBehavior ? { disconnectBehavior: opts.disconnectBehavior } : {}),
|
||||
// 2026-08-21 capture — disconnectBehavior:"continue" is sent on every
|
||||
// tier now, not gated to enterprise as the #8971 comment described.
|
||||
disconnectBehavior: opts.disconnectBehavior ?? "continue",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -12,8 +12,13 @@ import {
|
||||
} from "@/lib/combos/intelligentRouting";
|
||||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
function getI18nOrFallback(t: any, key: string, fallback: string) {
|
||||
if (typeof t?.has === "function" && t.has(key)) return t(key);
|
||||
function getI18nOrFallback(
|
||||
t: any,
|
||||
key: string,
|
||||
fallback: string,
|
||||
values?: Record<string, unknown>
|
||||
) {
|
||||
if (typeof t?.has === "function" && t.has(key)) return t(key, values);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -94,10 +99,9 @@ export default function IntelligentComboPanel({
|
||||
const updatedCombo = await response.json();
|
||||
onComboUpdated?.(updatedCombo);
|
||||
notify.success(
|
||||
getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.").replace(
|
||||
"{pack}",
|
||||
modePackId
|
||||
)
|
||||
getI18nOrFallback(t, "modePackUpdated", "Mode pack updated to {pack}.", {
|
||||
pack: modePackId,
|
||||
}).replace("{pack}", modePackId)
|
||||
);
|
||||
} catch (error: any) {
|
||||
notify.error(error?.message || "Failed to update mode pack.");
|
||||
@@ -184,10 +188,9 @@ export default function IntelligentComboPanel({
|
||||
</div>
|
||||
{savingModePack && (
|
||||
<span className="text-[11px] text-text-muted">
|
||||
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…").replace(
|
||||
"{pack}",
|
||||
savingModePack
|
||||
)}
|
||||
{getI18nOrFallback(t, "savingModePack", "Saving {pack}…", {
|
||||
pack: savingModePack,
|
||||
}).replace("{pack}", savingModePack)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -533,9 +533,9 @@ function getStrategyBadgeClass(strategy) {
|
||||
return "bg-blue-500/15 text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
function getI18nOrFallback(t, key, fallback) {
|
||||
function getI18nOrFallback(t, key, fallback, values) {
|
||||
try {
|
||||
if (typeof t.has === "function" && t.has(key)) return t(key);
|
||||
if (typeof t.has === "function" && t.has(key)) return t(key, values);
|
||||
} catch {}
|
||||
return fallback;
|
||||
}
|
||||
@@ -1565,7 +1565,8 @@ function StrategyRecommendationsPanel({ strategy, onApply, showNudge }) {
|
||||
{getI18nOrFallback(
|
||||
t,
|
||||
"recommendationsUpdated",
|
||||
"Recommendations updated for {strategy}."
|
||||
"Recommendations updated for {strategy}.",
|
||||
{ strategy: strategyLabel }
|
||||
).replace("{strategy}", strategyLabel)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
extractM365CredentialFromHar,
|
||||
describeHarImportExpiry,
|
||||
type M365HarImportResult,
|
||||
} from "@/shared/utils/m365HarImport";
|
||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
type HarImporter = (text: string) => M365HarImportResult;
|
||||
|
||||
// One entry per web-session provider that can offer HAR import. Add a new
|
||||
// key here (and its own extractor in src/shared/utils/) to support another
|
||||
// provider — the button renders nothing for any provider not listed.
|
||||
const HAR_IMPORTERS: Record<string, HarImporter> = {
|
||||
"copilot-m365-web": extractM365CredentialFromHar,
|
||||
};
|
||||
|
||||
const ERROR_MESSAGE_KEYS: Record<string, [string, string]> = {
|
||||
notJson: ["harImportErrorNotJson", "That file isn't valid JSON — is it really a .har export?"],
|
||||
noEntries: ["harImportErrorNoEntries", "This HAR has no network entries recorded."],
|
||||
noChathubUrl: [
|
||||
"harImportErrorNoChathubUrl",
|
||||
"No Copilot chat connection found in this HAR. Send at least one chat message in m365.cloud.microsoft before exporting.",
|
||||
],
|
||||
unparsableUrl: [
|
||||
"harImportErrorUnparsableUrl",
|
||||
"Found the chat connection, but couldn't read its URL.",
|
||||
],
|
||||
missingFields: [
|
||||
"harImportErrorMissingFields",
|
||||
"Found the chat connection, but the token was missing from it.",
|
||||
],
|
||||
};
|
||||
|
||||
export interface HarImportButtonProps {
|
||||
provider: string;
|
||||
onImport: (apiKey: string) => void;
|
||||
}
|
||||
|
||||
export default function HarImportButton({ provider, onImport }: HarImportButtonProps) {
|
||||
const t = useTranslations("providers") as ProviderMessageTranslator;
|
||||
const importer = HAR_IMPORTERS[provider];
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [state, setState] = useState<
|
||||
| { phase: "idle" }
|
||||
| { phase: "reading" }
|
||||
| { phase: "error"; message: string }
|
||||
| { phase: "success"; expiresAt: number | null }
|
||||
>({ phase: "idle" });
|
||||
|
||||
if (!importer) return null;
|
||||
|
||||
async function handleFile(file: File | undefined) {
|
||||
if (!file) return;
|
||||
setState({ phase: "reading" });
|
||||
let text: string;
|
||||
try {
|
||||
text = await file.text();
|
||||
} catch {
|
||||
setState({
|
||||
phase: "error",
|
||||
message: providerText(t, "harImportErrorReadFailed", "Couldn't read that file."),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = importer(text);
|
||||
if (!result.ok) {
|
||||
const [key, fallback] = ERROR_MESSAGE_KEYS[result.error] ?? [
|
||||
"harImportErrorUnknown",
|
||||
"Couldn't extract a credential from that HAR file.",
|
||||
];
|
||||
setState({ phase: "error", message: providerText(t, key, fallback) });
|
||||
return;
|
||||
}
|
||||
|
||||
onImport(result.apiKey);
|
||||
setState({ phase: "success", expiresAt: result.expiresAt });
|
||||
}
|
||||
|
||||
const expiry = state.phase === "success" ? describeHarImportExpiry(state.expiresAt) : null;
|
||||
const expiryText =
|
||||
expiry?.tone === "unknown"
|
||||
? providerText(t, "harImportStatusUnknownExpiry", "Imported. Couldn't read its expiry.")
|
||||
: expiry?.tone === "bad"
|
||||
? providerText(
|
||||
t,
|
||||
"harImportStatusExpired",
|
||||
"Imported, but this token already expired ({minutes}m ago) — export a fresh HAR.",
|
||||
{ minutes: Math.abs(expiry.minutesRemaining ?? 0) }
|
||||
)
|
||||
: expiry?.tone === "warn"
|
||||
? providerText(
|
||||
t,
|
||||
"harImportStatusExpiringSoon",
|
||||
"Imported — valid for only ~{minutes}m more.",
|
||||
{ minutes: expiry.minutesRemaining ?? 0 }
|
||||
)
|
||||
: expiry?.tone === "ok"
|
||||
? providerText(t, "harImportStatusValid", "Imported — valid for ~{minutes}m.", {
|
||||
minutes: expiry.minutesRemaining ?? 0,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={state.phase === "reading"}
|
||||
data-testid="har-import-button"
|
||||
className="inline-flex items-center gap-1.5 rounded border border-border px-2.5 py-1.5 text-xs font-medium text-text-main hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
|
||||
upload_file
|
||||
</span>
|
||||
{state.phase === "reading"
|
||||
? providerText(t, "harImportButtonBusy", "Importing…")
|
||||
: providerText(t, "harImportButtonLabel", "Import .har file")}
|
||||
</button>
|
||||
<span className="text-xs text-text-muted">
|
||||
{providerText(
|
||||
t,
|
||||
"harImportButtonHint",
|
||||
"Export from DevTools Network tab after sending at least one chat message."
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".har,application/json"
|
||||
data-testid="har-import-input"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void handleFile(event.target.files?.[0]);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{state.phase === "error" && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400" data-testid="har-import-error">
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
{state.phase === "success" && expiryText && (
|
||||
<p
|
||||
className={
|
||||
expiry?.tone === "bad"
|
||||
? "text-xs text-red-600 dark:text-red-400"
|
||||
: expiry?.tone === "warn"
|
||||
? "text-xs text-amber-700 dark:text-amber-300"
|
||||
: "text-xs text-emerald-700 dark:text-emerald-300"
|
||||
}
|
||||
data-testid="har-import-status"
|
||||
>
|
||||
{expiryText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
|
||||
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
|
||||
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
|
||||
import HarImportButton from "../HarImportButton";
|
||||
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
|
||||
import { buildAddProviderSpecificData } from "./connectionProviderSpecificData";
|
||||
import { getCommandCodeAuthPhaseLabel } from "./commandCodeAuthPhase";
|
||||
@@ -209,13 +210,13 @@ export default function AddApiKeyModal({
|
||||
? "Freebuff uses an authentic CLI auth token obtained via codebuff CLI login or automated harvester."
|
||||
: isWebSessionCredential
|
||||
? getWebSessionCredentialHint(t, webSessionCredential, providerDisplayName, false)
|
||||
: isLocalSelfHostedProvider
|
||||
? t("localProviderApiKeyOptionalHint", {
|
||||
provider: localProviderMetadata?.name || providerName || provider || "",
|
||||
})
|
||||
: apiKeyOptional
|
||||
? t("apiKeyOptionalHint")
|
||||
: undefined;
|
||||
: isLocalSelfHostedProvider
|
||||
? t("localProviderApiKeyOptionalHint", {
|
||||
provider: localProviderMetadata?.name || providerName || provider || "",
|
||||
})
|
||||
: apiKeyOptional
|
||||
? t("apiKeyOptionalHint")
|
||||
: undefined;
|
||||
const credentialValidationFailedMessage = isWebSessionCredential
|
||||
? providerText(
|
||||
t,
|
||||
@@ -750,6 +751,12 @@ export default function AddApiKeyModal({
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{provider && (
|
||||
<HarImportButton
|
||||
provider={provider}
|
||||
onImport={(apiKey) => setFormData({ ...formData, apiKey })}
|
||||
/>
|
||||
)}
|
||||
{!isNoAuthWebSessionCredential && (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
import { getWebSessionCredentialRequirement } from "../../webSessionCredentials";
|
||||
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
|
||||
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
|
||||
import HarImportButton from "../HarImportButton";
|
||||
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
|
||||
import { CodexConnectionFields } from "./CodexFingerprintFields";
|
||||
import { assignEditApiKeyProviderSpecificData } from "./connectionProviderSpecificData";
|
||||
@@ -909,6 +910,12 @@ export default function EditConnectionModal({
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{provider && (
|
||||
<HarImportButton
|
||||
provider={provider}
|
||||
onImport={(apiKey) => setFormData({ ...formData, apiKey })}
|
||||
/>
|
||||
)}
|
||||
{!isNoAuthWebSessionCredential && (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
|
||||
@@ -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",
|
||||
@@ -6269,6 +6269,20 @@
|
||||
"webSessionGuideStep3": "Copy the required credential from the provider's own domain. For cookies, copy only the Cookie header value and omit Cookie:.",
|
||||
"webSessionGuideStep3Manual": "Manual path: open the browser developer tools (F12 → Network), refresh the page, open an authenticated request, and copy the Cookie header value from Request Headers — omit the Cookie: prefix.",
|
||||
"webSessionGuideStep4": "Paste it here and check the connection. If it stops working, sign in again and replace it with a fresh value.",
|
||||
"harImportButtonLabel": "Import .har file",
|
||||
"harImportButtonBusy": "Importing…",
|
||||
"harImportButtonHint": "Export from DevTools Network tab after sending at least one chat message.",
|
||||
"harImportStatusValid": "Imported — valid for ~{minutes}m.",
|
||||
"harImportStatusExpiringSoon": "Imported — valid for only ~{minutes}m more.",
|
||||
"harImportStatusExpired": "Imported, but this token already expired ({minutes}m ago) — export a fresh HAR.",
|
||||
"harImportStatusUnknownExpiry": "Imported. Couldn't read its expiry.",
|
||||
"harImportErrorNotJson": "That file isn't valid JSON — is it really a .har export?",
|
||||
"harImportErrorNoEntries": "This HAR has no network entries recorded.",
|
||||
"harImportErrorNoChathubUrl": "No Copilot chat connection found in this HAR. Send at least one chat message in m365.cloud.microsoft before exporting.",
|
||||
"harImportErrorUnparsableUrl": "Found the chat connection, but couldn't read its URL.",
|
||||
"harImportErrorMissingFields": "Found the chat connection, but the token was missing from it.",
|
||||
"harImportErrorReadFailed": "Couldn't read that file.",
|
||||
"harImportErrorUnknown": "Couldn't extract a credential from that HAR file.",
|
||||
"webSessionSecurityHint": "Treat this like a password: it may access your signed-in web account until it expires or is revoked.",
|
||||
"webNoAuthGuideTitle": "No credential required",
|
||||
"webNoAuthGuideBody": "{provider} does not need an API key or cookie. Save the connection to use its free web endpoint.",
|
||||
|
||||
@@ -6269,6 +6269,20 @@
|
||||
"webSessionGuideStep3": "Sao chép thông tin xác thực được yêu cầu từ tên miền riêng của nhà cung cấp. Đối với cookie, chỉ sao chép giá trị tiêu đề Cookie và bỏ qua Cookie:.",
|
||||
"webSessionGuideStep3Manual": "Cách thủ công: mở công cụ dành cho nhà phát triển của trình duyệt (F12 → Network), tải lại trang, mở một yêu cầu đã xác thực và sao chép giá trị tiêu đề Cookie trong Request Headers — bỏ tiền tố Cookie:.",
|
||||
"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 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ó.",
|
||||
@@ -12207,7 +12221,7 @@
|
||||
},
|
||||
"omni-webhooks": {
|
||||
"name": "Webhook",
|
||||
"description": "__MISSING__:Register, list, test, and remove webhook endpoints. Configure event subscriptions (request.completed, request.failed, quota.exceeded, etc.) and manage delivery retries."
|
||||
"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[];
|
||||
}
|
||||
|
||||
116
src/shared/utils/m365HarImport.ts
Normal file
116
src/shared/utils/m365HarImport.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Pure, transport-free helpers that extract a `copilot-m365-web` credential
|
||||
* (`access_token=...; chathubPath=...`) directly from a DevTools HAR export,
|
||||
* so the "Add connection" / "Edit connection" dialogs can offer a one-click
|
||||
* "Import .har file" button instead of the user hand-extracting the token
|
||||
* from the WebSocket URL (see `HarImportButton.tsx`).
|
||||
*
|
||||
* The token rides in the query string of the BizChat ChatHub WebSocket URL
|
||||
* (`wss://substrate.office.com/m365Copilot/Chathub/<oid>@<tenant>?...
|
||||
* access_token=...`) — the same URL the executor itself connects to (see
|
||||
* `open-sse/executors/copilot-m365-connection.ts`). No network calls here;
|
||||
* everything operates on the HAR text already in the browser.
|
||||
*/
|
||||
|
||||
/** Prefix identifying the BizChat ChatHub WebSocket request in a HAR entry. */
|
||||
export const M365_CHATHUB_WS_PREFIX = "wss://substrate.office.com/m365Copilot/Chathub/";
|
||||
|
||||
export type M365HarImportResult =
|
||||
| { ok: true; apiKey: string; chathubPath: string; expiresAt: number | null }
|
||||
| { ok: false; error: string };
|
||||
|
||||
interface HarEntryLike {
|
||||
request?: { url?: unknown };
|
||||
}
|
||||
|
||||
interface HarLike {
|
||||
log?: { entries?: unknown };
|
||||
}
|
||||
|
||||
/** Decode a JWT payload WITHOUT verification — exp is a display hint only. */
|
||||
function decodeJwtExpiry(token: string): number | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
try {
|
||||
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
|
||||
const json = JSON.parse(atob(padded)) as { exp?: unknown };
|
||||
return typeof json.exp === "number" ? json.exp * 1000 : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the `copilot-m365-web` credential from raw HAR file text.
|
||||
*
|
||||
* Scans every request in `log.entries` for the ChatHub WebSocket URL and
|
||||
* uses the LAST match (a HAR may contain several turns of the same session;
|
||||
* the most recent one carries the freshest token). Returns a structured
|
||||
* error — never throws — so callers can render it directly.
|
||||
*/
|
||||
export function extractM365CredentialFromHar(text: string): M365HarImportResult {
|
||||
let har: HarLike;
|
||||
try {
|
||||
har = JSON.parse(text) as HarLike;
|
||||
} catch {
|
||||
return { ok: false, error: "notJson" };
|
||||
}
|
||||
|
||||
const entries = har.log?.entries;
|
||||
if (!Array.isArray(entries)) {
|
||||
return { ok: false, error: "noEntries" };
|
||||
}
|
||||
|
||||
let matchedUrl: string | null = null;
|
||||
for (const entry of entries as HarEntryLike[]) {
|
||||
const url = entry?.request?.url;
|
||||
if (typeof url === "string" && url.startsWith(M365_CHATHUB_WS_PREFIX)) {
|
||||
matchedUrl = url;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedUrl) {
|
||||
return { ok: false, error: "noChathubUrl" };
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(matchedUrl);
|
||||
} catch {
|
||||
return { ok: false, error: "unparsableUrl" };
|
||||
}
|
||||
|
||||
const token = parsed.searchParams.get("access_token");
|
||||
const chathubPath = decodeURIComponent(parsed.pathname.split("/m365Copilot/Chathub/")[1] || "");
|
||||
|
||||
if (!token || !chathubPath) {
|
||||
return { ok: false, error: "missingFields" };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
apiKey: `access_token=${token}; chathubPath=${chathubPath}`,
|
||||
chathubPath,
|
||||
expiresAt: decodeJwtExpiry(token),
|
||||
};
|
||||
}
|
||||
|
||||
export type HarImportExpiryTone = "ok" | "warn" | "bad" | "unknown";
|
||||
|
||||
export interface HarImportExpiryStatus {
|
||||
tone: HarImportExpiryTone;
|
||||
minutesRemaining: number | null;
|
||||
}
|
||||
|
||||
/** Classify a decoded token expiry for the inline status hint. */
|
||||
export function describeHarImportExpiry(
|
||||
expiresAt: number | null,
|
||||
now: number = Date.now()
|
||||
): HarImportExpiryStatus {
|
||||
if (expiresAt === null) return { tone: "unknown", minutesRemaining: null };
|
||||
const minutesRemaining = Math.round((expiresAt - now) / 60000);
|
||||
if (minutesRemaining <= 0) return { tone: "bad", minutesRemaining };
|
||||
if (minutesRemaining < 15) return { tone: "warn", minutesRemaining };
|
||||
return { tone: "ok", minutesRemaining };
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
"tests/unit/auth-opencode-zen-noauth-fallback.test.ts",
|
||||
"tests/unit/auth-terminal-status.test.ts",
|
||||
"tests/unit/authz/discovery-routes-local-only.test.ts",
|
||||
"tests/unit/authz/oauth-autoimport-local-only.test.ts",
|
||||
"tests/unit/authz/route-guard-local-prefix.test.ts",
|
||||
"tests/unit/authz/route-guard-skills-collect.test.ts",
|
||||
"tests/unit/authz/route-guard-version-get-exemption.test.ts",
|
||||
@@ -271,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",
|
||||
@@ -282,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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6,12 +6,13 @@ import path from "node:path";
|
||||
|
||||
// Shared across all tests — the module caches DATA_DIR / SQLITE_FILE at load time,
|
||||
// so we must create the temp dir and import exactly once.
|
||||
type CoreModule = typeof import("../../src/lib/db/core.ts");
|
||||
let tempDir: string;
|
||||
let originalDataDir: string | undefined;
|
||||
let getDbInstance: any;
|
||||
let resetDbInstance: any;
|
||||
let ensureDbInitialized: any;
|
||||
let closeDbInstance: any;
|
||||
let getDbInstance: CoreModule["getDbInstance"];
|
||||
let resetDbInstance: CoreModule["resetDbInstance"];
|
||||
let ensureDbInitialized: CoreModule["ensureDbInitialized"];
|
||||
let closeDbInstance: CoreModule["closeDbInstance"];
|
||||
|
||||
before(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-test-"));
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -75,10 +75,15 @@ for (const modelId of HYPERAGENT_FALLBACK_MODEL_IDS) {
|
||||
}
|
||||
|
||||
test("getTokenLimit: does not force 1M onto non-hyperagent providers serving the same model ids", () => {
|
||||
// windsurf declares an explicit per-model contextLength of 200000 for this exact id —
|
||||
// a provider-unscoped substring match on "claude-opus-4" would have clobbered it to 1M.
|
||||
assert.equal(getTokenLimit("windsurf", "claude-opus-4.7-max"), 200000);
|
||||
// bluesminds likewise pins its own claude-opus-4-5 entry to 200000.
|
||||
// windsurf used to pin this exact id at 200000, but its built-in provider entry was
|
||||
// retired (#8228 — replaced by devin-desktop). With no per-provider source left,
|
||||
// #11034 resolves the effort-suffixed variant via its BASE model (`claude-opus-4.7-max`
|
||||
// → `claude-opus-4.7` → canonical `claude-opus-4-7`), whose real catalog window IS 1M.
|
||||
// This is a legitimate base-model resolution, not the forbidden hyperagent default leak:
|
||||
// it comes from the shared model catalog, never from the hyperagent registry scope.
|
||||
assert.equal(getTokenLimit("windsurf", "claude-opus-4.7-max"), 1_000_000);
|
||||
// bluesminds still pins its own claude-opus-4-5 entry to 200000, and that pin must win
|
||||
// over both the name heuristic and any 1M window from sibling providers/catalogs.
|
||||
assert.equal(getTokenLimit("bluesminds", "claude-opus-4-5"), 200000);
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ class MockM365WebSocket {
|
||||
closed = false;
|
||||
listeners = new Map<string, Listener[]>();
|
||||
|
||||
constructor(public url: string, public options: unknown) {
|
||||
constructor(
|
||||
public url: string,
|
||||
public options: unknown
|
||||
) {
|
||||
MockM365WebSocket.instances.push(this);
|
||||
queueMicrotask(() => this.emit("open"));
|
||||
}
|
||||
@@ -144,30 +147,55 @@ test("#7870: enterprise-tier chat invocation defaults tone to Magic", async () =
|
||||
assert.equal(invocationArgs.tone, "Magic");
|
||||
});
|
||||
|
||||
test("#10718: individual (no tier) chat invocation carries the recaptured 2026-08 shape", async () => {
|
||||
test("2026-08-21: individual (no tier) chat invocation carries the recaptured shape", async () => {
|
||||
const invocationArgs = await sendChatInvocation(undefined);
|
||||
const optionsSets = invocationArgs.optionsSets as string[];
|
||||
// The 25-entry consumer/MSA set (enable_msa_user, pdnascan, …) is gone from
|
||||
// the wire — the stale set was part of the silently-dropped shape.
|
||||
// The pre-#10718 25-entry consumer/MSA set (enable_msa_user, pdnascan, …) is
|
||||
// still gone from the wire — only the 2026-08-21 34-entry set is current.
|
||||
assert.ok(!optionsSets.includes("enable_msa_user"));
|
||||
assert.ok(optionsSets.includes("enable_gg_gpt"));
|
||||
// The browser sends tone:"magic" (lowercase) on the individual/EDU surface.
|
||||
assert.equal(invocationArgs.tone, "magic");
|
||||
// The individual/consumer surface now sends tone:"Magic" (capitalized) —
|
||||
// the #10718 lowercase "magic" is stale.
|
||||
assert.equal(invocationArgs.tone, "Magic");
|
||||
assert.deepEqual(invocationArgs.allowedMessageTypes, [
|
||||
"Chat",
|
||||
"Suggestion",
|
||||
"InternalSearchQuery",
|
||||
"Disengaged",
|
||||
"Progress",
|
||||
"EndOfRequest",
|
||||
"InternalLoaderMessage",
|
||||
"Progress",
|
||||
"GeneratedCode",
|
||||
"RenderCardRequest",
|
||||
"AdsQuery",
|
||||
"SemanticSerp",
|
||||
"GenerateContentQuery",
|
||||
"GenerateGraphicArt",
|
||||
"SearchQuery",
|
||||
"ConfirmationCard",
|
||||
"AuthError",
|
||||
"DeveloperLogs",
|
||||
"TriggerPlugin",
|
||||
"HintInvocation",
|
||||
"MemoryUpdate",
|
||||
"EndOfRequest",
|
||||
"TriggerConfirmation",
|
||||
"ResumeInvokeAction",
|
||||
"ResumeUserInputRequest",
|
||||
"TriggerUserInputRequest",
|
||||
"EscapeHatch",
|
||||
"TriggerPluginAuth",
|
||||
"ResumePluginAuth",
|
||||
"SideBySide",
|
||||
"ReferencesListComplete",
|
||||
"SwitchRespondingEndpoint",
|
||||
]);
|
||||
});
|
||||
|
||||
test("#10718: EDU-tier chat invocation carries the same recaptured shape", async () => {
|
||||
test("2026-08-21: EDU-tier chat invocation carries the same recaptured shape", async () => {
|
||||
const invocationArgs = await sendChatInvocation("edu");
|
||||
const optionsSets = invocationArgs.optionsSets as string[];
|
||||
assert.ok(!optionsSets.includes("enable_msa_user"));
|
||||
assert.equal(invocationArgs.tone, "magic");
|
||||
assert.equal(invocationArgs.tone, "Magic");
|
||||
});
|
||||
|
||||
test("#8971: enterprise-tier chat invocation must send disconnectBehavior=continue", async () => {
|
||||
@@ -179,11 +207,11 @@ test("#8971: enterprise-tier chat invocation must send disconnectBehavior=contin
|
||||
);
|
||||
});
|
||||
|
||||
test("#8971/#10718: individual (no tier) chat invocation omits disconnectBehavior (not on the 2026-08 wire)", async () => {
|
||||
test("2026-08-21: individual (no tier) chat invocation also sends disconnectBehavior=continue (now on every tier)", async () => {
|
||||
const invocationArgs = await sendChatInvocation(undefined);
|
||||
assert.equal(
|
||||
invocationArgs.disconnectBehavior,
|
||||
undefined,
|
||||
`individual-tier invocation must omit disconnectBehavior; got ${JSON.stringify(invocationArgs.disconnectBehavior)}`
|
||||
"continue",
|
||||
`individual-tier invocation must carry disconnectBehavior="continue"; got ${JSON.stringify(invocationArgs.disconnectBehavior)}`
|
||||
);
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ test("#10718: metricsFrame emits the exact bytes observed in the browser capture
|
||||
|
||||
// ── Recaptured invocation shape ────────────────────────────────────────────
|
||||
|
||||
test("#10718: buildChatInvocation matches the recaptured arguments[0] key set", () => {
|
||||
test("2026-08-21: buildChatInvocation matches the recaptured arguments[0] key set", () => {
|
||||
const arg = buildChatInvocation({
|
||||
text: "Say OK in one word.",
|
||||
traceId: "11111111-1111-1111-1111-111111111111",
|
||||
@@ -50,19 +50,25 @@ test("#10718: buildChatInvocation matches the recaptured arguments[0] key set",
|
||||
conversationId: "44444444-4444-4444-4444-444444444444",
|
||||
}).arguments[0] as Record<string, unknown>;
|
||||
|
||||
// Exact key set from the capture — additions AND omissions are both pinned,
|
||||
// because the stale keys are exactly what got the shape dropped.
|
||||
// Exact key set from the 2026-08-21 capture (issue: individual/consumer M365
|
||||
// Copilot calls got only SignalR keepalive pings and no type:1 update at all
|
||||
// — "Stream ended before producing a non-ping SSE event" client-side). The
|
||||
// #10718 shape below is missing exactly the keys this capture added.
|
||||
assert.deepEqual(Object.keys(arg).sort(), [
|
||||
"allowedMessageTypes",
|
||||
"clientCorrelationId",
|
||||
"clientInfo",
|
||||
"conversationId",
|
||||
"disconnectBehavior",
|
||||
"extraExtensionParameters",
|
||||
"isSbsSupported",
|
||||
"isStartOfSession",
|
||||
"message",
|
||||
"options",
|
||||
"optionsSets",
|
||||
"plugins",
|
||||
"productThreadType",
|
||||
"renderReferencesBehindEOS",
|
||||
"sessionId",
|
||||
"sliceIds",
|
||||
"source",
|
||||
@@ -73,13 +79,32 @@ test("#10718: buildChatInvocation matches the recaptured arguments[0] key set",
|
||||
"traceId",
|
||||
]);
|
||||
assert.equal(arg.productThreadType, "Office");
|
||||
assert.deepEqual(arg.clientInfo, { clientAppName: "Office", clientPlatform: "mcmcopilot-web" });
|
||||
assert.deepEqual(arg.clientInfo, {
|
||||
clientAppName: "Office",
|
||||
clientPlatform: "mcmcopilot-web",
|
||||
clientEntrypoint: "mcmcopilot-officeweb",
|
||||
clientSessionId: "22222222-2222-2222-2222-222222222222",
|
||||
ProductCategory: "Chat",
|
||||
clientAppType: "Web",
|
||||
productEntryPoint: "ChatPanel",
|
||||
deviceOS: "Windows",
|
||||
deviceType: "Desktop",
|
||||
clientPlatformVersion: "10",
|
||||
});
|
||||
assert.equal(arg.conversationId, "44444444-4444-4444-4444-444444444444");
|
||||
assert.equal(arg.toolChoice, null);
|
||||
assert.equal(arg.tone, "magic");
|
||||
// The individual/consumer surface now sends "Magic" (capitalized), matching
|
||||
// the enterprise tone literal — the #10718 lowercase "magic" is stale.
|
||||
assert.equal(arg.tone, "Magic");
|
||||
assert.equal(arg.isSbsSupported, true);
|
||||
assert.equal(arg.renderReferencesBehindEOS, true);
|
||||
assert.deepEqual(arg.extraExtensionParameters, {});
|
||||
assert.deepEqual(arg.plugins, [{ Id: "BingWebSearch", Source: "BuiltIn" }]);
|
||||
// Sent on every tier now, not gated to enterprise as #8971 described.
|
||||
assert.equal(arg.disconnectBehavior, "continue");
|
||||
});
|
||||
|
||||
test("#10718: the message object carries the recaptured rich shape", () => {
|
||||
test("2026-08-21: the message object carries the recaptured rich shape", () => {
|
||||
const arg = buildChatInvocation({
|
||||
text: "Say OK in one word.",
|
||||
traceId: "t",
|
||||
@@ -93,7 +118,9 @@ test("#10718: the message object carries the recaptured rich shape", () => {
|
||||
"adaptiveCards",
|
||||
"attachments",
|
||||
"author",
|
||||
"clientInfo",
|
||||
"clientPreferences",
|
||||
"connectedFederatedConnections",
|
||||
"entityAnnotationTypes",
|
||||
"experienceType",
|
||||
"inputMethod",
|
||||
@@ -107,26 +134,41 @@ test("#10718: the message object carries the recaptured rich shape", () => {
|
||||
assert.equal(message.messageType, "Chat");
|
||||
assert.equal(message.requestId, "r");
|
||||
assert.equal(message.experienceType, "Default");
|
||||
assert.deepEqual(message.entityAnnotationTypes, ["People", "File", "Event", "Email", "TeamsMessage"]);
|
||||
assert.deepEqual(message.entityAnnotationTypes, [
|
||||
"People",
|
||||
"File",
|
||||
"Event",
|
||||
"Email",
|
||||
"TeamsMessage",
|
||||
]);
|
||||
assert.equal(message.attachments, null);
|
||||
assert.deepEqual(message.locationInfo, { timeZone: "UTC", timeZoneOffset: 0 });
|
||||
assert.deepEqual(message.connectedFederatedConnections, ["dummyId"]);
|
||||
// Same clientInfo object echoed inside message, per the capture.
|
||||
assert.deepEqual(message.clientInfo, arg.clientInfo);
|
||||
});
|
||||
|
||||
test("#10718: default tier lists are the recaptured 14-entry optionsSets / 6-entry allowedMessageTypes", () => {
|
||||
test("2026-08-21: default tier lists are the recaptured 34-entry optionsSets / 30-entry allowedMessageTypes", () => {
|
||||
const overrides = resolveChatInvocationOverrides(undefined);
|
||||
assert.equal(overrides.optionsSets.length, 14);
|
||||
assert.equal(overrides.allowedMessageTypes.length, 6);
|
||||
assert.equal(overrides.tone, "magic");
|
||||
// The pre-#10718 consumer/MSA flags are gone from the wire.
|
||||
assert.equal(overrides.optionsSets.length, 34);
|
||||
assert.equal(overrides.allowedMessageTypes.length, 30);
|
||||
assert.equal(overrides.tone, "Magic");
|
||||
assert.equal(overrides.disconnectBehavior, "continue");
|
||||
const optionSets = M365_DEFAULT_OPTION_SETS as readonly string[];
|
||||
const messageTypes = ALLOWED_MESSAGE_TYPES as readonly string[];
|
||||
for (const stale of ["enable_msa_user", "pdnascan", "cwc_code_interpreter", "rich_responses"]) {
|
||||
assert.ok(!optionSets.includes(stale), `${stale} must not be in the default option sets`);
|
||||
// Entries the 2026-08-21 capture showed that the #10718 lists lacked.
|
||||
for (const present of [
|
||||
"cwc_code_interpreter",
|
||||
"rich_responses",
|
||||
"async_client_interaction",
|
||||
"flux_v3_references",
|
||||
]) {
|
||||
assert.ok(optionSets.includes(present), `${present} must be in the default option sets`);
|
||||
}
|
||||
for (const stale of ["InternalSearchQuery", "GeneratedCode", "RenderCardRequest", "AdsQuery", "SemanticSerp", "GenerateContentQuery"]) {
|
||||
assert.ok(!messageTypes.includes(stale), `${stale} must not be in allowedMessageTypes`);
|
||||
for (const present of ["InternalSearchQuery", "GeneratedCode", "AuthError", "TriggerPlugin"]) {
|
||||
assert.ok(messageTypes.includes(present), `${present} must be in allowedMessageTypes`);
|
||||
}
|
||||
// Entries the capture showed and the old lists lacked.
|
||||
// Entries the #10718 capture showed and this capture still confirms.
|
||||
assert.ok(optionSets.includes("cwcfluxgptv"));
|
||||
assert.ok(messageTypes.includes("EndOfRequest"));
|
||||
});
|
||||
@@ -134,8 +176,7 @@ test("#10718: default tier lists are the recaptured 14-entry optionsSets / 6-ent
|
||||
// ── refresh_token helpers ──────────────────────────────────────────────────
|
||||
|
||||
function fakeJwt(claims: Record<string, unknown>): string {
|
||||
const b64 = (value: unknown) =>
|
||||
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
return `${b64({ alg: "none" })}.${b64(claims)}.sig`;
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,9 @@ test("buildChatInvocation defaults stay backward compatible", () => {
|
||||
requestId: "r",
|
||||
conversationId: "c",
|
||||
}).arguments[0] as Record<string, unknown>;
|
||||
assert.deepEqual(arg.plugins, []);
|
||||
// 2026-08-21 capture (#11069): BingWebSearch BuiltIn plugin is now the
|
||||
// default on all tiers (was [] in the #10718 shape); toolChoice stays null.
|
||||
assert.deepEqual(arg.plugins, [{ Id: "BingWebSearch", Source: "BuiltIn" }]);
|
||||
assert.equal(arg.toolChoice, null);
|
||||
assert.equal(arg.customInstructions, undefined);
|
||||
});
|
||||
|
||||
78
tests/unit/dashboard/m365-har-import.test.ts
Normal file
78
tests/unit/dashboard/m365-har-import.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
extractM365CredentialFromHar,
|
||||
describeHarImportExpiry,
|
||||
M365_CHATHUB_WS_PREFIX,
|
||||
} from "../../../src/shared/utils/m365HarImport.ts";
|
||||
|
||||
function fakeJwt(exp: number): string {
|
||||
const b64 = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
return `${b64({ alg: "none" })}.${b64({ exp })}.sig`;
|
||||
}
|
||||
|
||||
function harWithUrl(url: string | null): string {
|
||||
return JSON.stringify({
|
||||
log: { entries: url ? [{ request: { url } }] : [] },
|
||||
});
|
||||
}
|
||||
|
||||
test("extracts access_token + chathubPath from a matching ChatHub WS entry", () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600;
|
||||
const token = fakeJwt(exp);
|
||||
const url = `${M365_CHATHUB_WS_PREFIX}oid-123%40tenant-456?chatsessionid=abc&access_token=${token}`;
|
||||
const result = extractM365CredentialFromHar(harWithUrl(url));
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
assert.equal(result.chathubPath, "oid-123@tenant-456");
|
||||
assert.equal(result.apiKey, `access_token=${token}; chathubPath=oid-123@tenant-456`);
|
||||
assert.ok(result.expiresAt !== null && Math.abs(result.expiresAt - exp * 1000) < 1000);
|
||||
});
|
||||
|
||||
test("picks the LAST matching entry when a HAR has multiple turns", () => {
|
||||
const oldToken = fakeJwt(1000);
|
||||
const freshToken = fakeJwt(9999999999);
|
||||
const har = JSON.stringify({
|
||||
log: {
|
||||
entries: [
|
||||
{ request: { url: `${M365_CHATHUB_WS_PREFIX}u%40t?access_token=${oldToken}` } },
|
||||
{ request: { url: "https://unrelated.example/" } },
|
||||
{ request: { url: `${M365_CHATHUB_WS_PREFIX}u%40t?access_token=${freshToken}` } },
|
||||
],
|
||||
},
|
||||
});
|
||||
const result = extractM365CredentialFromHar(har);
|
||||
assert.equal(result.ok, true);
|
||||
if (!result.ok) return;
|
||||
assert.ok(result.apiKey.includes(freshToken));
|
||||
});
|
||||
|
||||
test("returns notJson for malformed input", () => {
|
||||
const result = extractM365CredentialFromHar("not json{{{");
|
||||
assert.deepEqual(result, { ok: false, error: "notJson" });
|
||||
});
|
||||
|
||||
test("returns noEntries when log.entries is missing/not an array", () => {
|
||||
const result = extractM365CredentialFromHar(JSON.stringify({ log: {} }));
|
||||
assert.deepEqual(result, { ok: false, error: "noEntries" });
|
||||
});
|
||||
|
||||
test("returns noChathubUrl when no request matches the ChatHub prefix", () => {
|
||||
const result = extractM365CredentialFromHar(harWithUrl("https://example.com/"));
|
||||
assert.deepEqual(result, { ok: false, error: "noChathubUrl" });
|
||||
});
|
||||
|
||||
test("returns missingFields when the URL matches but lacks access_token or chathubPath", () => {
|
||||
const result = extractM365CredentialFromHar(harWithUrl(`${M365_CHATHUB_WS_PREFIX}`));
|
||||
assert.equal(result.ok, false);
|
||||
});
|
||||
|
||||
test("describeHarImportExpiry classifies ok/warn/bad/unknown", () => {
|
||||
const now = Date.now();
|
||||
assert.equal(describeHarImportExpiry(now + 30 * 60000, now).tone, "ok");
|
||||
assert.equal(describeHarImportExpiry(now + 5 * 60000, now).tone, "warn");
|
||||
assert.equal(describeHarImportExpiry(now - 60000, now).tone, "bad");
|
||||
assert.equal(describeHarImportExpiry(null, now).tone, "unknown");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -131,7 +131,22 @@ test("buildChatInvocation produces a type:4 chat invocation carrying the user te
|
||||
assert.equal(arg.sessionId, "session-id");
|
||||
assert.equal(arg.conversationId, "conversation-id");
|
||||
assert.equal(arg.productThreadType, "Office");
|
||||
assert.deepEqual(arg.clientInfo, { clientAppName: "Office", clientPlatform: "mcmcopilot-web" });
|
||||
// 2026-08-21 capture (#11069): clientInfo gained 8 keys (clientEntrypoint,
|
||||
// clientSessionId, ProductCategory, clientAppType, productEntryPoint,
|
||||
// deviceOS, deviceType, clientPlatformVersion). The #10718 base shape only
|
||||
// carried clientAppName + clientPlatform.
|
||||
assert.deepEqual(arg.clientInfo, {
|
||||
clientAppName: "Office",
|
||||
clientPlatform: "mcmcopilot-web",
|
||||
clientEntrypoint: "mcmcopilot-officeweb",
|
||||
clientSessionId: "session-id",
|
||||
ProductCategory: "Chat",
|
||||
clientAppType: "Web",
|
||||
productEntryPoint: "ChatPanel",
|
||||
deviceOS: "Windows",
|
||||
deviceType: "Desktop",
|
||||
clientPlatformVersion: "10",
|
||||
});
|
||||
assert.equal(arg.isStartOfSession, true);
|
||||
assert.ok(Array.isArray(arg.optionsSets));
|
||||
assert.ok((arg.optionsSets as string[]).includes("enable_gg_gpt"));
|
||||
|
||||
@@ -30,10 +30,11 @@ test("model-driven tone overrides the tier default; bare id keeps the tier tone"
|
||||
const enterprise = resolveChatInvocationOverrides("enterprise");
|
||||
const individual = resolveChatInvocationOverrides(undefined);
|
||||
|
||||
// enterprise tier default tone is Magic; individual/EDU sends "magic" (#10718
|
||||
// recapture — the old "" default was part of the silently-dropped shape)
|
||||
// enterprise tier default tone is Magic; individual/EDU now also sends
|
||||
// "Magic" (capitalized) — the 2026-08-21 capture (#11069) showed lowercase
|
||||
// "magic" is part of the shape that gets silently dropped (ping-only socket).
|
||||
assert.equal(enterprise.tone, "Magic");
|
||||
assert.equal(individual.tone, "magic");
|
||||
assert.equal(individual.tone, "Magic");
|
||||
|
||||
// precedence: resolveToneForModel(model) ?? overrides.tone (mirrors the executor wiring)
|
||||
const toneFor = (model: string | undefined, tierTone: string) =>
|
||||
@@ -43,9 +44,9 @@ test("model-driven tone overrides the tier default; bare id keeps the tier tone"
|
||||
assert.equal(toneFor("copilot-m365-claude-opus", enterprise.tone), "Claude_Opus");
|
||||
assert.equal(toneFor("copilot-m365-claude-opus", individual.tone), "Claude_Opus");
|
||||
|
||||
// the bare id keeps whatever the tier resolved
|
||||
// the bare id keeps whatever the tier resolved (both "Magic" post-#11069)
|
||||
assert.equal(toneFor("copilot-m365", enterprise.tone), "Magic");
|
||||
assert.equal(toneFor("copilot-m365", individual.tone), "magic");
|
||||
assert.equal(toneFor("copilot-m365", individual.tone), "Magic");
|
||||
});
|
||||
|
||||
test("registry exposes the bare id (first) plus every tone variant", () => {
|
||||
|
||||
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");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user