mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Integrated into release/v3.8.0 — adds useUpstream429BreakerHints toggle with per-provider defaults for circuit breaker cooldown trust.
This commit is contained in:
@@ -22,10 +22,18 @@ import {
|
||||
getCircuitBreaker,
|
||||
STATE,
|
||||
} from "../../src/shared/utils/circuitBreaker";
|
||||
import {
|
||||
classify429FromError,
|
||||
type FailureKind,
|
||||
} from "../../src/shared/utils/classify429";
|
||||
import { resolveUseUpstream429BreakerHints } from "../../src/shared/utils/providerHints";
|
||||
|
||||
|
||||
type ProviderProfile = {
|
||||
baseCooldownMs: number;
|
||||
useUpstreamRetryHints: boolean;
|
||||
/** Issue #2100 follow-up. Stored override; undefined → per-provider default. */
|
||||
useUpstream429BreakerHints?: boolean;
|
||||
maxBackoffSteps: number;
|
||||
failureThreshold: number;
|
||||
resetTimeoutMs: number;
|
||||
@@ -182,6 +190,9 @@ function buildProviderProfile(
|
||||
return {
|
||||
baseCooldownMs: connectionCooldown.baseCooldownMs,
|
||||
useUpstreamRetryHints: connectionCooldown.useUpstreamRetryHints,
|
||||
// Issue #2100 follow-up: propagate stored override (boolean | undefined)
|
||||
// so the runtime resolver picks user setting first, then per-provider default.
|
||||
useUpstream429BreakerHints: connectionCooldown.useUpstream429BreakerHints,
|
||||
maxBackoffSteps: connectionCooldown.maxBackoffSteps,
|
||||
failureThreshold: providerBreaker.failureThreshold,
|
||||
resetTimeoutMs: providerBreaker.resetTimeoutMs,
|
||||
@@ -532,9 +543,23 @@ function configureProviderBreaker(
|
||||
if (!provider) return null;
|
||||
|
||||
const resolvedProfile = { ...getProviderProfile(provider), ...(profile ?? {}) };
|
||||
// Issue #2100 follow-up: resolve useUpstream429BreakerHints from the
|
||||
// provider profile (stored override) or fall back to per-provider default.
|
||||
// Stored value type is `boolean | undefined` — never `null` after PATCH.
|
||||
const userValue = resolvedProfile.useUpstream429BreakerHints;
|
||||
const useHints = resolveUseUpstream429BreakerHints(provider, userValue);
|
||||
return getCircuitBreaker(provider, {
|
||||
failureThreshold: resolvedProfile.failureThreshold ?? resolvedProfile.circuitBreakerThreshold,
|
||||
resetTimeout: resolvedProfile.resetTimeoutMs ?? resolvedProfile.circuitBreakerReset,
|
||||
...(useHints
|
||||
? {
|
||||
cooldownByKind: {
|
||||
rate_limit: 60_000,
|
||||
quota_exhausted: 3_600_000,
|
||||
} satisfies Partial<Record<FailureKind, number>>,
|
||||
classifyError: classify429FromError,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ type RequestQueueSettings = {
|
||||
type ConnectionCooldownProfileSettings = {
|
||||
baseCooldownMs: number;
|
||||
useUpstreamRetryHints: boolean;
|
||||
// Issue #2100 follow-up. Optional / undefined when unset; the per-provider
|
||||
// default in src/shared/utils/providerHints.ts resolves at runtime.
|
||||
useUpstream429BreakerHints?: boolean;
|
||||
maxBackoffSteps: number;
|
||||
};
|
||||
|
||||
@@ -360,6 +363,48 @@ function ConnectionCooldownCard({
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="text-text-muted">
|
||||
Use upstream 429 hints for breaker cooldown
|
||||
</span>
|
||||
<select
|
||||
className="rounded border border-border-default bg-surface-1 px-2 py-1 text-sm font-mono"
|
||||
value={
|
||||
current.useUpstream429BreakerHints === true
|
||||
? "on"
|
||||
: current.useUpstream429BreakerHints === false
|
||||
? "off"
|
||||
: "default"
|
||||
}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
const next: boolean | undefined =
|
||||
v === "on" ? true : v === "off" ? false : undefined;
|
||||
setDraft((prev) => {
|
||||
const profile = { ...prev[key] };
|
||||
if (next === undefined) {
|
||||
delete (profile as { useUpstream429BreakerHints?: boolean }).useUpstream429BreakerHints;
|
||||
} else {
|
||||
(profile as { useUpstream429BreakerHints?: boolean }).useUpstream429BreakerHints = next;
|
||||
}
|
||||
return { ...prev, [key]: profile };
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="default">Default (per provider)</option>
|
||||
<option value="on">Always on</option>
|
||||
<option value="off">Always off</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-xs text-text-muted">
|
||||
Apply Retry-After / quota-exhausted signals from 429 responses to
|
||||
circuit-breaker cooldown duration. Default uses a per-provider
|
||||
policy: direct cloud providers default on; reverse-proxy /
|
||||
self-hosted / CLI-backed providers default off. Independent of
|
||||
"Use upstream retry hints".
|
||||
</p>
|
||||
</div>
|
||||
<NumberField
|
||||
label="Max backoff steps"
|
||||
value={current.maxBackoffSteps}
|
||||
@@ -381,6 +426,16 @@ function ConnectionCooldownCard({
|
||||
{current.useUpstreamRetryHints ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Use upstream 429 hints (breaker)</span>
|
||||
<span className="font-mono text-text-main">
|
||||
{current.useUpstream429BreakerHints === true
|
||||
? "Yes"
|
||||
: current.useUpstream429BreakerHints === false
|
||||
? "No"
|
||||
: "Default"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-text-muted">Max backoff steps</span>
|
||||
<span className="font-mono text-text-main">{current.maxBackoffSteps}</span>
|
||||
@@ -414,7 +469,26 @@ function ConnectionCooldownCard({
|
||||
setEditing(false);
|
||||
}}
|
||||
onSave={async () => {
|
||||
await onSave(draft);
|
||||
// Build PATCH-ready payload: convert undefined useUpstream429BreakerHints
|
||||
// to explicit null sentinel so the server treats it as unset (not as
|
||||
// partial-merge "leave unchanged"). JSON.stringify drops undefined keys.
|
||||
const payload = {
|
||||
oauth: {
|
||||
...draft.oauth,
|
||||
useUpstream429BreakerHints:
|
||||
draft.oauth.useUpstream429BreakerHints === undefined
|
||||
? (null as unknown as boolean | undefined)
|
||||
: draft.oauth.useUpstream429BreakerHints,
|
||||
},
|
||||
apikey: {
|
||||
...draft.apikey,
|
||||
useUpstream429BreakerHints:
|
||||
draft.apikey.useUpstream429BreakerHints === undefined
|
||||
? (null as unknown as boolean | undefined)
|
||||
: draft.apikey.useUpstream429BreakerHints,
|
||||
},
|
||||
};
|
||||
await onSave(payload as typeof draft);
|
||||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/lib/resilience/settings";
|
||||
import { updateResilienceSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { resetAllCircuitBreakers } from "@/shared/utils/circuitBreaker";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -196,6 +197,20 @@ export async function PATCH(request) {
|
||||
});
|
||||
await syncRuntimeSettings(nextResilience);
|
||||
|
||||
// Issue #2100 follow-up: detect transitions in useUpstream429BreakerHints
|
||||
// and reset breakers so the registry stops serving cached options.
|
||||
// Compared on STORED override transition (boolean | undefined) so that
|
||||
// `null` (PATCH input) → undefined (stored) is correctly detected as
|
||||
// "unset request" when the previous stored value was a boolean.
|
||||
const breakerHintsChanged =
|
||||
currentResilience.connectionCooldown.oauth.useUpstream429BreakerHints !==
|
||||
nextResilience.connectionCooldown.oauth.useUpstream429BreakerHints ||
|
||||
currentResilience.connectionCooldown.apikey.useUpstream429BreakerHints !==
|
||||
nextResilience.connectionCooldown.apikey.useUpstream429BreakerHints;
|
||||
if (breakerHintsChanged) {
|
||||
resetAllCircuitBreakers();
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
requestQueue: nextResilience.requestQueue,
|
||||
|
||||
@@ -14,6 +14,17 @@ export interface RequestQueueSettings {
|
||||
export interface ConnectionCooldownProfileSettings {
|
||||
baseCooldownMs: number;
|
||||
useUpstreamRetryHints: boolean;
|
||||
/**
|
||||
* Issue #2100 follow-up: opt-in toggle for upstream 429 hint trust at the
|
||||
* circuit-breaker cooldown layer (independent of `useUpstreamRetryHints`
|
||||
* which controls retry scheduling).
|
||||
*
|
||||
* Stored shape is intentionally optional / `boolean | undefined`: when
|
||||
* unset, the per-provider default from `providerHints.ts` applies.
|
||||
* Normalize/merge MUST preserve `undefined` — do not coerce via
|
||||
* `toBoolean(value, fallback)`.
|
||||
*/
|
||||
useUpstream429BreakerHints?: boolean;
|
||||
maxBackoffSteps: number;
|
||||
}
|
||||
|
||||
@@ -155,7 +166,29 @@ function normalizeConnectionCooldownProfile(
|
||||
fallback: ConnectionCooldownProfileSettings
|
||||
): ConnectionCooldownProfileSettings {
|
||||
const record = asRecord(next);
|
||||
return {
|
||||
// useUpstream429BreakerHints uses a 3-state input contract:
|
||||
// - boolean → user override, store as-is
|
||||
// - null → explicit unset sentinel, drop key so the per-provider
|
||||
// default in `providerHints.ts` resolves at runtime
|
||||
// - omitted → leave existing fallback value unchanged (partial-merge)
|
||||
// Never coerce via `toBoolean(value, fallback)` because that would
|
||||
// collapse the unset state.
|
||||
const hasHintsKey = Object.prototype.hasOwnProperty.call(
|
||||
record,
|
||||
"useUpstream429BreakerHints",
|
||||
);
|
||||
const rawHints = record.useUpstream429BreakerHints;
|
||||
let useUpstream429BreakerHints: boolean | undefined;
|
||||
if (!hasHintsKey) {
|
||||
useUpstream429BreakerHints = fallback.useUpstream429BreakerHints;
|
||||
} else if (rawHints === null) {
|
||||
useUpstream429BreakerHints = undefined;
|
||||
} else if (typeof rawHints === "boolean") {
|
||||
useUpstream429BreakerHints = rawHints;
|
||||
} else {
|
||||
useUpstream429BreakerHints = fallback.useUpstream429BreakerHints;
|
||||
}
|
||||
const out: ConnectionCooldownProfileSettings = {
|
||||
baseCooldownMs: toInteger(record.baseCooldownMs, fallback.baseCooldownMs, {
|
||||
min: 0,
|
||||
max: 24 * 60 * 60 * 1000,
|
||||
@@ -166,6 +199,11 @@ function normalizeConnectionCooldownProfile(
|
||||
max: 32,
|
||||
}),
|
||||
};
|
||||
// Only attach the key when defined — preserves omission across round-trips.
|
||||
if (useUpstream429BreakerHints !== undefined) {
|
||||
out.useUpstream429BreakerHints = useUpstream429BreakerHints;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizeLegacyConnectionCooldownProfile(
|
||||
|
||||
@@ -162,3 +162,84 @@ export function retryAfterFromResponse(response: {
|
||||
}): number | null {
|
||||
return parseRetryAfter(getHeader(response.headers, "retry-after"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an unknown headers-like value into a plain `Record<string, string>`.
|
||||
* Native `Headers` (from `fetch`) does NOT respond to `Object.entries` — it
|
||||
* exposes `.entries()` instead. Without this normalization, `getHeader` would
|
||||
* silently miss every header on a Headers instance.
|
||||
*/
|
||||
function normalizeHeaders(raw: unknown): Record<string, string> | undefined {
|
||||
if (raw === null || typeof raw !== "object") return undefined;
|
||||
const maybeIter = (raw as { entries?: unknown }).entries;
|
||||
if (typeof maybeIter === "function") {
|
||||
try {
|
||||
return Object.fromEntries(
|
||||
(raw as { entries: () => Iterable<[string, string]> }).entries(),
|
||||
);
|
||||
} catch {
|
||||
// fall through to plain-object treatment
|
||||
}
|
||||
}
|
||||
return raw as Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter that takes an error thrown by an HTTP client (fetch wrapper, axios,
|
||||
* upstream SDK, etc.) and produces a {@link FailureKind} suitable for the
|
||||
* `classifyError` option of the circuit breaker.
|
||||
*
|
||||
* Recognises the common error shapes:
|
||||
* - `err.status` + `err.headers` + `err.body` (low-level fetch wrapper)
|
||||
* - `err.response.status` + `err.response.headers` + `err.response.data` (axios-style)
|
||||
* - `err.message` (last-resort body for keyword scan)
|
||||
*
|
||||
* Returns `undefined` when the error doesn't carry enough information to
|
||||
* classify, so the breaker can decide what to do without a kind tag.
|
||||
*
|
||||
* Companion to issue #2100 follow-up.
|
||||
*/
|
||||
export function classify429FromError(err: unknown): FailureKind | undefined {
|
||||
if (err === null || typeof err !== "object") return undefined;
|
||||
const e = err as Record<string, unknown>;
|
||||
|
||||
let status: number | undefined;
|
||||
let headers: Record<string, string> | undefined;
|
||||
let body: unknown;
|
||||
|
||||
if (typeof e.status === "number") {
|
||||
status = e.status;
|
||||
}
|
||||
if (typeof e.statusCode === "number" && status === undefined) {
|
||||
status = e.statusCode;
|
||||
}
|
||||
|
||||
if (e.response && typeof e.response === "object") {
|
||||
const resp = e.response as Record<string, unknown>;
|
||||
if (typeof resp.status === "number" && status === undefined) {
|
||||
status = resp.status;
|
||||
}
|
||||
if (resp.headers && typeof resp.headers === "object") {
|
||||
headers = normalizeHeaders(resp.headers);
|
||||
}
|
||||
if (resp.data !== undefined) {
|
||||
body = resp.data;
|
||||
} else if (typeof resp.body !== "undefined") {
|
||||
body = resp.body;
|
||||
}
|
||||
}
|
||||
|
||||
if (headers === undefined && e.headers && typeof e.headers === "object") {
|
||||
headers = normalizeHeaders(e.headers);
|
||||
}
|
||||
if (body === undefined) {
|
||||
if (typeof e.body !== "undefined") {
|
||||
body = e.body;
|
||||
} else if (typeof e.message === "string") {
|
||||
body = e.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof status !== "number") return undefined;
|
||||
return classify429({ status, headers, body });
|
||||
}
|
||||
|
||||
73
src/shared/utils/providerHints.ts
Normal file
73
src/shared/utils/providerHints.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Per-provider default policy for upstream 429 hint trust.
|
||||
*
|
||||
* @see Issue #2100 follow-up — surface a user-overridable per-profile toggle
|
||||
* that decides whether the circuit breaker uses upstream 429 body / Retry-After
|
||||
* hints (`classify429`, `cooldownByKind`) to differentiate rate-limit from
|
||||
* quota-exhausted failure cooldowns.
|
||||
*
|
||||
* This helper returns the **default** answer for a given provider. The actual
|
||||
* runtime decision is the user override (if any) OR this default. See
|
||||
* `accountFallback.ts` / `chat.ts` / `chatHelpers.ts` for the resolution
|
||||
* call sites:
|
||||
*
|
||||
* ```ts
|
||||
* const userValue = providerProfile.useUpstream429BreakerHints; // boolean | undefined
|
||||
* const useHints = userValue !== undefined
|
||||
* ? userValue
|
||||
* : defaultUseUpstream429BreakerHints(provider);
|
||||
* ```
|
||||
*
|
||||
* Default policy: direct cloud providers default `true` because their 429
|
||||
* bodies and `Retry-After` headers are authoritative. Reverse-proxy /
|
||||
* self-hosted / CLI-backed providers default `false` because forwarded 429
|
||||
* metadata is often unreliable or fabricated by the proxy.
|
||||
*
|
||||
* @module shared/utils/providerHints
|
||||
*/
|
||||
|
||||
import {
|
||||
UPSTREAM_PROXY_PROVIDERS,
|
||||
SELF_HOSTED_CHAT_PROVIDER_IDS,
|
||||
isLocalProvider,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
} from "../constants/providers";
|
||||
|
||||
/**
|
||||
* Conservative per-provider default for `useUpstream429BreakerHints`.
|
||||
*
|
||||
* Returns `false` for any provider whose 429 metadata may be forwarded by
|
||||
* an intermediary (proxy, self-hosted runtime, CLI wrapper). Returns `true`
|
||||
* for direct cloud providers where the upstream response is authoritative.
|
||||
*/
|
||||
export function defaultUseUpstream429BreakerHints(providerId: string): boolean {
|
||||
if (Object.prototype.hasOwnProperty.call(UPSTREAM_PROXY_PROVIDERS, providerId)) {
|
||||
return false;
|
||||
}
|
||||
if (isLocalProvider(providerId)) {
|
||||
return false;
|
||||
}
|
||||
if (SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId)) {
|
||||
return false;
|
||||
}
|
||||
if (isClaudeCodeCompatibleProvider(providerId)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective `useHints` decision: the user override wins if set,
|
||||
* otherwise fall back to the per-provider default.
|
||||
*
|
||||
* `undefined` means "not user-set" and triggers the default lookup.
|
||||
*/
|
||||
export function resolveUseUpstream429BreakerHints(
|
||||
providerId: string,
|
||||
userValue: boolean | undefined,
|
||||
): boolean {
|
||||
if (userValue !== undefined) {
|
||||
return userValue;
|
||||
}
|
||||
return defaultUseUpstream429BreakerHints(providerId);
|
||||
}
|
||||
@@ -775,6 +775,11 @@ const connectionCooldownProfileSchema = z
|
||||
.object({
|
||||
baseCooldownMs: z.number().int().min(0).optional(),
|
||||
useUpstreamRetryHints: z.boolean().optional(),
|
||||
// Issue #2100 follow-up: per-profile toggle for upstream 429 hint trust.
|
||||
// `null` is an explicit unset sentinel — PATCH handler deletes the key
|
||||
// from stored settings so the per-provider default resolves at runtime.
|
||||
// `undefined` (key omitted) means "leave existing value unchanged".
|
||||
useUpstream429BreakerHints: z.boolean().nullable().optional(),
|
||||
maxBackoffSteps: z.number().int().min(0).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -45,6 +45,11 @@ import {
|
||||
} from "./chatHelpers";
|
||||
|
||||
// Pipeline integration — wired modules
|
||||
import {
|
||||
classify429FromError,
|
||||
type FailureKind,
|
||||
} from "@/shared/utils/classify429";
|
||||
import { resolveUseUpstream429BreakerHints } from "@/shared/utils/providerHints";
|
||||
import { getCircuitBreaker } from "../../shared/utils/circuitBreaker";
|
||||
import { markAccountExhaustedFrom429 } from "../../domain/quotaCache";
|
||||
import { RequestTelemetry, recordTelemetry } from "../../shared/utils/requestTelemetry";
|
||||
@@ -627,11 +632,25 @@ async function handleSingleModelChat(
|
||||
});
|
||||
if (gate) return gate;
|
||||
|
||||
// Issue #2100 follow-up: opt-in upstream 429 hint trust per provider.
|
||||
const useHints429 = resolveUseUpstream429BreakerHints(
|
||||
provider,
|
||||
(providerProfile as { useUpstream429BreakerHints?: boolean }).useUpstream429BreakerHints,
|
||||
);
|
||||
const breaker = getCircuitBreaker(provider, {
|
||||
failureThreshold: providerProfile.failureThreshold,
|
||||
resetTimeout: providerProfile.resetTimeoutMs,
|
||||
onStateChange: (name: string, from: string, to: string) =>
|
||||
log.info("CIRCUIT", `${name}: ${from} → ${to}`),
|
||||
...(useHints429
|
||||
? {
|
||||
cooldownByKind: {
|
||||
rate_limit: 60_000,
|
||||
quota_exhausted: 3_600_000,
|
||||
} satisfies Partial<Record<FailureKind, number>>,
|
||||
classifyError: classify429FromError,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
const userAgent = request?.headers?.get("user-agent") || "";
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
} from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
import { resolveProxyForConnection } from "@/lib/localDb";
|
||||
import { CircuitBreakerOpenError, getCircuitBreaker } from "../../shared/utils/circuitBreaker";
|
||||
import {
|
||||
classify429FromError,
|
||||
type FailureKind,
|
||||
} from "../../shared/utils/classify429";
|
||||
import { resolveUseUpstream429BreakerHints } from "../../shared/utils/providerHints";
|
||||
|
||||
import { logProxyEvent } from "../../lib/proxyLogger";
|
||||
import { logTranslationEvent } from "../../lib/translatorEvents";
|
||||
import { getRuntimeProviderProfile } from "@omniroute/open-sse/services/accountFallback.ts";
|
||||
@@ -238,11 +244,25 @@ export async function checkPipelineGates(
|
||||
) {
|
||||
const bypassReason = options.bypassReason || "pipeline override";
|
||||
const providerProfile = options.providerProfile ?? (await getRuntimeProviderProfile(provider));
|
||||
// Issue #2100 follow-up: opt-in upstream 429 hint trust per provider.
|
||||
const useHints429 = resolveUseUpstream429BreakerHints(
|
||||
provider,
|
||||
(providerProfile as { useUpstream429BreakerHints?: boolean }).useUpstream429BreakerHints,
|
||||
);
|
||||
const breaker = getCircuitBreaker(provider, {
|
||||
failureThreshold: providerProfile.failureThreshold ?? providerProfile.circuitBreakerThreshold,
|
||||
resetTimeout: providerProfile.resetTimeoutMs ?? providerProfile.circuitBreakerReset,
|
||||
onStateChange: (name: string, from: string, to: string) =>
|
||||
log.info("CIRCUIT", `${name}: ${from} → ${to}`),
|
||||
...(useHints429
|
||||
? {
|
||||
cooldownByKind: {
|
||||
rate_limit: 60_000,
|
||||
quota_exhausted: 3_600_000,
|
||||
} satisfies Partial<Record<FailureKind, number>>,
|
||||
classifyError: classify429FromError,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
if (options.ignoreCircuitBreaker && !breaker.canExecute()) {
|
||||
log.info("CIRCUIT", `Bypassing OPEN circuit breaker for ${provider} (${bypassReason})`);
|
||||
|
||||
58
tests/unit/provider-hints.test.ts
Normal file
58
tests/unit/provider-hints.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
defaultUseUpstream429BreakerHints,
|
||||
resolveUseUpstream429BreakerHints,
|
||||
} from "../../src/shared/utils/providerHints.ts";
|
||||
|
||||
test("defaultUseUpstream429BreakerHints: direct cloud providers default true", () => {
|
||||
for (const id of ["openai", "anthropic", "groq", "cerebras", "mistral", "google"]) {
|
||||
assert.equal(
|
||||
defaultUseUpstream429BreakerHints(id),
|
||||
true,
|
||||
`expected true for ${id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultUseUpstream429BreakerHints: cliproxyapi defaults false", () => {
|
||||
assert.equal(defaultUseUpstream429BreakerHints("cliproxyapi"), false);
|
||||
});
|
||||
|
||||
test("defaultUseUpstream429BreakerHints: self-hosted chat providers default false", () => {
|
||||
for (const id of ["lm-studio", "vllm", "lemonade", "llamafile", "triton", "xinference", "oobabooga"]) {
|
||||
assert.equal(
|
||||
defaultUseUpstream429BreakerHints(id),
|
||||
false,
|
||||
`expected false for ${id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultUseUpstream429BreakerHints: claude-code-* prefix defaults false", () => {
|
||||
for (const id of ["anthropic-compatible-cc-direct", "anthropic-compatible-cc-bedrock", "anthropic-compatible-cc-vertex"]) {
|
||||
assert.equal(
|
||||
defaultUseUpstream429BreakerHints(id),
|
||||
false,
|
||||
`expected false for ${id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveUseUpstream429BreakerHints: user override wins (both directions)", () => {
|
||||
// Cloud provider with user override OFF → false
|
||||
assert.equal(resolveUseUpstream429BreakerHints("openai", false), false);
|
||||
// Cloud provider with user override ON → true (no-op vs default)
|
||||
assert.equal(resolveUseUpstream429BreakerHints("openai", true), true);
|
||||
// Proxy provider with user override ON → true (user explicitly trusted it)
|
||||
assert.equal(resolveUseUpstream429BreakerHints("cliproxyapi", true), true);
|
||||
// Proxy provider with user override OFF → false (no-op vs default)
|
||||
assert.equal(resolveUseUpstream429BreakerHints("cliproxyapi", false), false);
|
||||
});
|
||||
|
||||
test("resolveUseUpstream429BreakerHints: undefined falls back to per-provider default", () => {
|
||||
// Critical: this is the v3 regression-test for the v1 default-vs-gate bug.
|
||||
assert.equal(resolveUseUpstream429BreakerHints("openai", undefined), true);
|
||||
assert.equal(resolveUseUpstream429BreakerHints("cliproxyapi", undefined), false);
|
||||
assert.equal(resolveUseUpstream429BreakerHints("lm-studio", undefined), false);
|
||||
});
|
||||
94
tests/unit/resilience-settings-upstream429-breaker.test.ts
Normal file
94
tests/unit/resilience-settings-upstream429-breaker.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
DEFAULT_RESILIENCE_SETTINGS,
|
||||
mergeResilienceSettings,
|
||||
resolveResilienceSettings,
|
||||
type ResilienceSettings,
|
||||
} from "../../src/lib/resilience/settings.ts";
|
||||
|
||||
function cloneDefaults(): ResilienceSettings {
|
||||
// structuredClone is enough for the plain-object settings shape.
|
||||
return structuredClone(DEFAULT_RESILIENCE_SETTINGS);
|
||||
}
|
||||
|
||||
test("defaults: useUpstream429BreakerHints omitted (undefined) on both profiles", () => {
|
||||
const settings = cloneDefaults();
|
||||
assert.equal(settings.connectionCooldown.oauth.useUpstream429BreakerHints, undefined);
|
||||
assert.equal(settings.connectionCooldown.apikey.useUpstream429BreakerHints, undefined);
|
||||
});
|
||||
|
||||
test("mergeResilienceSettings: explicit true is stored", () => {
|
||||
const current = cloneDefaults();
|
||||
const next = mergeResilienceSettings(current, {
|
||||
connectionCooldown: { oauth: { useUpstream429BreakerHints: true } },
|
||||
});
|
||||
assert.equal(next.connectionCooldown.oauth.useUpstream429BreakerHints, true);
|
||||
// apikey unchanged
|
||||
assert.equal(next.connectionCooldown.apikey.useUpstream429BreakerHints, undefined);
|
||||
});
|
||||
|
||||
test("mergeResilienceSettings: explicit false is stored", () => {
|
||||
const current = cloneDefaults();
|
||||
const next = mergeResilienceSettings(current, {
|
||||
connectionCooldown: { apikey: { useUpstream429BreakerHints: false } },
|
||||
});
|
||||
assert.equal(next.connectionCooldown.apikey.useUpstream429BreakerHints, false);
|
||||
});
|
||||
|
||||
test("mergeResilienceSettings: null sentinel deletes the field (back to undefined)", () => {
|
||||
// Start with explicit false on oauth
|
||||
const start = mergeResilienceSettings(cloneDefaults(), {
|
||||
connectionCooldown: { oauth: { useUpstream429BreakerHints: false } },
|
||||
});
|
||||
assert.equal(start.connectionCooldown.oauth.useUpstream429BreakerHints, false);
|
||||
// PATCH with null should reset to undefined
|
||||
const next = mergeResilienceSettings(start, {
|
||||
connectionCooldown: {
|
||||
oauth: { useUpstream429BreakerHints: null as unknown as boolean },
|
||||
},
|
||||
});
|
||||
assert.equal(next.connectionCooldown.oauth.useUpstream429BreakerHints, undefined);
|
||||
// Key should not appear in JSON
|
||||
const serialized = JSON.parse(JSON.stringify(next.connectionCooldown.oauth));
|
||||
assert.equal(
|
||||
"useUpstream429BreakerHints" in serialized,
|
||||
false,
|
||||
"key should be absent in serialized JSON",
|
||||
);
|
||||
});
|
||||
|
||||
test("mergeResilienceSettings: omitted key (partial-merge) leaves existing value", () => {
|
||||
// Start with explicit true on apikey
|
||||
const start = mergeResilienceSettings(cloneDefaults(), {
|
||||
connectionCooldown: { apikey: { useUpstream429BreakerHints: true } },
|
||||
});
|
||||
// PATCH oauth only — apikey must keep its value
|
||||
const next = mergeResilienceSettings(start, {
|
||||
connectionCooldown: { oauth: { baseCooldownMs: 5000 } },
|
||||
});
|
||||
assert.equal(next.connectionCooldown.apikey.useUpstream429BreakerHints, true);
|
||||
});
|
||||
|
||||
test("resolveResilienceSettings: omitted field in record stays undefined (no toBoolean coercion)", () => {
|
||||
const record = {
|
||||
connectionCooldown: {
|
||||
oauth: { baseCooldownMs: 1000, useUpstreamRetryHints: true, maxBackoffSteps: 5 },
|
||||
apikey: { baseCooldownMs: 2000, useUpstreamRetryHints: false, maxBackoffSteps: 3 },
|
||||
},
|
||||
};
|
||||
const resolved = resolveResilienceSettings(record as Parameters<typeof resolveResilienceSettings>[0]);
|
||||
assert.equal(resolved.connectionCooldown.oauth.useUpstream429BreakerHints, undefined);
|
||||
assert.equal(resolved.connectionCooldown.apikey.useUpstream429BreakerHints, undefined);
|
||||
});
|
||||
|
||||
test("mixed-provider round-trip: oauth=false + apikey=true survives merge", () => {
|
||||
const next = mergeResilienceSettings(cloneDefaults(), {
|
||||
connectionCooldown: {
|
||||
oauth: { useUpstream429BreakerHints: false },
|
||||
apikey: { useUpstream429BreakerHints: true },
|
||||
},
|
||||
});
|
||||
assert.equal(next.connectionCooldown.oauth.useUpstream429BreakerHints, false);
|
||||
assert.equal(next.connectionCooldown.apikey.useUpstream429BreakerHints, true);
|
||||
});
|
||||
Reference in New Issue
Block a user