ldrouter 1.17.8 → 1.17.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,13 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/) and the project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [1.17.9] - 2026-09-22
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **A tripped provider circuit breaker locked the provider out until the process restarted.** The breaker's `open -> half_open` decay runs inside `getEffectiveState()`, which is only called from the attempt loop, but both candidate filters read the raw `isOpen()` flag instead — so once the flag was set no candidate ever reached the attempt loop that would have decayed it, and the provider answered `provider circuit is open` to every request until the container was restarted. `health_state` kept reading `healthy` throughout, so nothing in the UI showed the lockout. Live production: `code` and `qo` had each accumulated five consecutive upstream failures (a real upstream `503`) and were both in that state, while the upstream itself answered `200` to the same model and key when called directly. Candidate construction is now cooldown-aware through `circuitBlocks()`, so a candidate is admitted again as soon as the cooldown has elapsed and the half-open probe can close the breaker. Verified on a container against the released image: breaker open with the cooldown elapsed returned a zero-length `200`; with the fix it streams normally again.
|
|
12
|
+
- **The admin "test model" button reported `Stream ended unexpectedly` instead of the actual reason.** The `test-stream` route hijacks the reply, so a `GatewayError` rethrown from its catch block could never reach Fastify's error handler — the response simply ended as `HTTP 200` with a zero-length body and no events, leaving the UI nothing to parse but that placeholder. The route now sends the reason as a `test_error` event. Measured on the released build with the breaker open: status `200`, `content-type` null, `body` 0 bytes; with the fix: `text/event-stream` carrying the real message.
|
|
13
|
+
|
|
7
14
|
## [1.17.8] - 2026-09-21
|
|
8
15
|
|
|
9
16
|
### Changed
|
|
@@ -11,7 +11,7 @@ import { qoderAttemptFailure, qoderCredentialsFor } from '../providers/qoder/cre
|
|
|
11
11
|
import { refreshQoderCreditsIfStale } from '../providers/qoder/credits.js';
|
|
12
12
|
import { callQoderNonStreaming, callQoderStreaming } from '../providers/qoder/client.js';
|
|
13
13
|
import { refreshCodexUsageIfStale } from '../providers/codex-autostart.js';
|
|
14
|
-
import { getEffectiveState,
|
|
14
|
+
import { getEffectiveState, recordSuccess, recordFailure, halfOpenProbeAllowed, circuitBlocks } from '../routing/circuit.js';
|
|
15
15
|
import { checkRpm, checkTpm, acquireConcurrent, releaseConcurrent } from '../routing/ratelimit.js';
|
|
16
16
|
import { checkDailyMonthly, consumeUsage } from '../routing/quota.js';
|
|
17
17
|
import { keyAllowedFor } from '../auth/api-key.js';
|
|
@@ -436,7 +436,10 @@ export class GatewayRunner {
|
|
|
436
436
|
enabled: m.enabled,
|
|
437
437
|
providerEnabled: p.enabled,
|
|
438
438
|
upstreamAvailable: m.upstreamAvailable,
|
|
439
|
-
|
|
439
|
+
// Cooldown-aware, not the raw flag: `isOpen()` stays true until some request decays it,
|
|
440
|
+
// so a raw read here refused every candidate forever and the provider was unroutable
|
|
441
|
+
// until a restart.
|
|
442
|
+
circuitOpen: circuitBlocks(m.providerId, p.cbCooldownSeconds),
|
|
440
443
|
capabilities: caps,
|
|
441
444
|
providerType: p.type,
|
|
442
445
|
};
|
|
@@ -479,6 +482,7 @@ export class GatewayRunner {
|
|
|
479
482
|
const providers = db.select().from(schema.providers).all();
|
|
480
483
|
const providerEnabled = new Map(providers.map((p) => [p.id, p.enabled]));
|
|
481
484
|
const providerType = new Map(providers.map((p) => [p.id, p.type]));
|
|
485
|
+
const providerCooldown = new Map(providers.map((p) => [p.id, p.cbCooldownSeconds]));
|
|
482
486
|
// Deliberately unfiltered: every combo member must reach selectCandidates so
|
|
483
487
|
// it can report WHY it was skipped. Pre-filtering here erased the model rows
|
|
484
488
|
// and turned every distinct reason into "model not found".
|
|
@@ -489,7 +493,10 @@ export class GatewayRunner {
|
|
|
489
493
|
enabled: m.enabled,
|
|
490
494
|
providerEnabled: providerEnabled.get(m.providerId),
|
|
491
495
|
upstreamAvailable: m.upstreamAvailable,
|
|
492
|
-
|
|
496
|
+
// Cooldown-aware: a raw `isOpen()` here froze the whole combo on one bad minute,
|
|
497
|
+
// because the flags are computed once per request while the breaker stays open
|
|
498
|
+
// until some request decays it — which this filter prevented from ever happening.
|
|
499
|
+
circuitOpen: circuitBlocks(m.providerId, providerCooldown.get(m.providerId) ?? 0),
|
|
493
500
|
capabilities: safeJson(m.capabilitiesJson),
|
|
494
501
|
providerType: providerType.get(m.providerId),
|
|
495
502
|
}));
|
|
@@ -312,8 +312,9 @@ export async function registerModelRoutes(app) {
|
|
|
312
312
|
catch (e) {
|
|
313
313
|
// Never let a raw non-GatewayError (e.g. MasterKeyError from credential
|
|
314
314
|
// decryption) escape into the global handler as an opaque "Gateway error".
|
|
315
|
-
|
|
316
|
-
|
|
315
|
+
// The reply is hijacked, so a rethrow here cannot reach the error handler:
|
|
316
|
+
// it ends the response having sent nothing, which the UI can only report as
|
|
317
|
+
// "Stream ended unexpectedly". Report the reason over the stream instead.
|
|
317
318
|
send('test_error', { message: e.message });
|
|
318
319
|
}
|
|
319
320
|
finally {
|
|
@@ -35,3 +35,16 @@ export function getEffectiveState(providerId, cooldownSeconds) {
|
|
|
35
35
|
export function halfOpenProbeAllowed(providerId) {
|
|
36
36
|
return circuitState(providerId) === 'half_open';
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Should the candidate filter refuse this provider outright?
|
|
40
|
+
*
|
|
41
|
+
* `isOpen()` reports the raw state, which stays `'open'` until a request walks the
|
|
42
|
+
* attempt loop — so a tripped breaker rejected every candidate for the lifetime of the
|
|
43
|
+
* process and made the provider unroutable until a restart (the combo path and the
|
|
44
|
+
* direct-model path both read the flag off the candidate record). A candidate must be
|
|
45
|
+
* allowed through once the cooldown has elapsed so the probe that closes the breaker
|
|
46
|
+
* can actually run; `getEffectiveState` is what performs that open -> half_open decay.
|
|
47
|
+
*/
|
|
48
|
+
export function circuitBlocks(providerId, cooldownSeconds) {
|
|
49
|
+
return getEffectiveState(providerId, cooldownSeconds) === 'open';
|
|
50
|
+
}
|