saros-proxy 0.7.3 → 0.9.0
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/README.md +98 -47
- package/dist/cli/autostart.js.map +1 -1
- package/dist/cli/daemon.d.ts +31 -4
- package/dist/cli/daemon.js +124 -22
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/dsh-config.d.ts +32 -0
- package/dist/cli/dsh-config.js +122 -0
- package/dist/cli/dsh-config.js.map +1 -0
- package/dist/cli/harness-models.d.ts +26 -2
- package/dist/cli/harness-models.js +192 -2
- package/dist/cli/harness-models.js.map +1 -1
- package/dist/cli/harness-sync.d.ts +27 -10
- package/dist/cli/harness-sync.js +70 -16
- package/dist/cli/harness-sync.js.map +1 -1
- package/dist/cli/help.js +4 -2
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/omp-config.js +22 -5
- package/dist/cli/omp-config.js.map +1 -1
- package/dist/cli/opencode-config.js +22 -15
- package/dist/cli/opencode-config.js.map +1 -1
- package/dist/cli/overview.d.ts +10 -0
- package/dist/cli/overview.js +61 -0
- package/dist/cli/overview.js.map +1 -0
- package/dist/cli/pi-config.js +21 -5
- package/dist/cli/pi-config.js.map +1 -1
- package/dist/cli/setup.d.ts +6 -10
- package/dist/cli/setup.js +107 -233
- package/dist/cli/setup.js.map +1 -1
- package/dist/cli/ui.d.ts +0 -1
- package/dist/cli/ui.js +0 -16
- package/dist/cli/ui.js.map +1 -1
- package/dist/cli/update-check.js.map +1 -1
- package/dist/cli/usage.d.ts +12 -0
- package/dist/cli/usage.js +116 -0
- package/dist/cli/usage.js.map +1 -0
- package/dist/config.d.ts +13 -17
- package/dist/config.js +29 -78
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +7 -6
- package/dist/constants.js +10 -5
- package/dist/constants.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +34 -34
- package/dist/index.js.map +1 -1
- package/dist/model-stats.d.ts +20 -0
- package/dist/model-stats.js +135 -0
- package/dist/model-stats.js.map +1 -0
- package/dist/models-sync.js +10 -4
- package/dist/models-sync.js.map +1 -1
- package/dist/probe-cache.js.map +1 -1
- package/dist/providers/commandcode.d.ts +17 -0
- package/dist/providers/commandcode.js +232 -0
- package/dist/providers/commandcode.js.map +1 -0
- package/dist/providers/index.d.ts +43 -0
- package/dist/providers/index.js +75 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/infer.d.ts +23 -0
- package/dist/providers/infer.js +13 -0
- package/dist/providers/infer.js.map +1 -0
- package/dist/providers/opencode-go.d.ts +4 -0
- package/dist/providers/opencode-go.js +179 -0
- package/dist/providers/opencode-go.js.map +1 -0
- package/dist/providers/types.d.ts +118 -0
- package/dist/providers/types.js +10 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/proxy-logic.d.ts +46 -22
- package/dist/proxy-logic.js +146 -106
- package/dist/proxy-logic.js.map +1 -1
- package/dist/proxy.d.ts +20 -2
- package/dist/proxy.js +231 -57
- package/dist/proxy.js.map +1 -1
- package/dist/usage-client.d.ts +41 -0
- package/dist/usage-client.js +124 -0
- package/dist/usage-client.js.map +1 -0
- package/dist/validation.d.ts +5 -5
- package/dist/validation.js +7 -9
- package/dist/validation.js.map +1 -1
- package/package.json +75 -77
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types.ts — Provider abstraction for multi-provider API key support.
|
|
3
|
+
*
|
|
4
|
+
* A KeyProvider encapsulates everything saros needs to know about one
|
|
5
|
+
* upstream API platform: how to recognize its keys structurally, how to
|
|
6
|
+
* verify them with a cheap network ping (smoke test), where its usage
|
|
7
|
+
* endpoint lives, and how to parse its usage response shape.
|
|
8
|
+
*/
|
|
9
|
+
export type ProviderId = 'opencode-go' | 'commandcode';
|
|
10
|
+
/** Result of a provider smoke test (key verification ping). */
|
|
11
|
+
export interface SmokeResult {
|
|
12
|
+
/** Provider that ran the test. */
|
|
13
|
+
provider: ProviderId;
|
|
14
|
+
/**
|
|
15
|
+
* - 'valid' — key authenticated successfully (HTTP 200)
|
|
16
|
+
* - 'invalid' — upstream definitively rejected the key (401/403 auth)
|
|
17
|
+
* - 'error' — network/timeout/5xx; inconclusive, may retry
|
|
18
|
+
*/
|
|
19
|
+
status: 'valid' | 'invalid' | 'error';
|
|
20
|
+
/** HTTP status code when available. */
|
|
21
|
+
httpStatus?: number;
|
|
22
|
+
/** Human-readable detail (safe to show in the TUI — never the key). */
|
|
23
|
+
detail?: string;
|
|
24
|
+
/** Provider-specific extras (e.g. commandcode planId). */
|
|
25
|
+
meta?: Record<string, string>;
|
|
26
|
+
}
|
|
27
|
+
/** Structural prefix check result. */
|
|
28
|
+
export type PrefixMatch = 'yes' | 'no' | 'maybe';
|
|
29
|
+
/**
|
|
30
|
+
* Common interface every key provider implements.
|
|
31
|
+
* All network methods must never log or echo the key itself.
|
|
32
|
+
*/
|
|
33
|
+
export interface KeyProvider {
|
|
34
|
+
readonly id: ProviderId;
|
|
35
|
+
/** Human-readable name for TUI output. */
|
|
36
|
+
readonly displayName: string;
|
|
37
|
+
/** Upstream base URL (HTTPS enforced by callers). */
|
|
38
|
+
readonly baseUrl: string;
|
|
39
|
+
/**
|
|
40
|
+
* Canonical chat route prefix saros accepts from clients. Clients always
|
|
41
|
+
* speak opencode-go shapes (/zen/go/v1/...); providers without a matching
|
|
42
|
+
* upstream path get their requests remapped (see remapChatPath).
|
|
43
|
+
*/
|
|
44
|
+
readonly chatBasePath: string;
|
|
45
|
+
/**
|
|
46
|
+
* Cheap structural check. 'yes' = prefix uniquely identifies this
|
|
47
|
+
* provider, 'maybe' = prefix shared with another provider (empirical
|
|
48
|
+
* smoke test required), 'no' = definitely not this provider's format.
|
|
49
|
+
*/
|
|
50
|
+
quickMatch(key: string): PrefixMatch;
|
|
51
|
+
/**
|
|
52
|
+
* Verify a key with a cheap authenticated GET.
|
|
53
|
+
* Implementations must use a read-only endpoint (no token spend).
|
|
54
|
+
*/
|
|
55
|
+
smokeTest(key: string, timeoutMs?: number): Promise<SmokeResult>;
|
|
56
|
+
/**
|
|
57
|
+
* Usage/quota endpoint path for this provider (empty string when the
|
|
58
|
+
* provider has no per-key usage API saros can query).
|
|
59
|
+
*/
|
|
60
|
+
usagePath(): string;
|
|
61
|
+
/**
|
|
62
|
+
* Parse a usage response body into the shared KeyUsage shape.
|
|
63
|
+
* Returns null when the body doesn't match this provider's contract.
|
|
64
|
+
*/
|
|
65
|
+
parseUsageResponse(body: string): KeyUsage | null;
|
|
66
|
+
/**
|
|
67
|
+
* Extra headers this provider requires on upstream requests
|
|
68
|
+
* (e.g. CommandCode CLI-identity headers). Called per request;
|
|
69
|
+
* must not include Authorization (added by the proxy).
|
|
70
|
+
*/
|
|
71
|
+
extraUpstreamHeaders?(): Record<string, string>;
|
|
72
|
+
/**
|
|
73
|
+
* Fetch this provider's live model catalog (OpenAI list shape).
|
|
74
|
+
* Returns null when unavailable — callers fall back to bundled data.
|
|
75
|
+
* Implementations must not require a valid key (catalogs are public).
|
|
76
|
+
*/
|
|
77
|
+
fetchCatalog?(timeoutMs?: number): Promise<Array<Record<string, unknown>> | null>;
|
|
78
|
+
/**
|
|
79
|
+
* Structural model-id affinity check. Consulted BEFORE dispatching so a
|
|
80
|
+
* request naming a commandcode-only model (e.g. 'claude-*') is not sent
|
|
81
|
+
* with an opencode-go key (and vice versa).
|
|
82
|
+
*/
|
|
83
|
+
modelAffinity(modelId: string): ModelAffinity;
|
|
84
|
+
/**
|
|
85
|
+
* Affinity by bare model name (without vendor prefix), e.g. 'ds-v4-flash'
|
|
86
|
+
* for 'deepseek/ds-v4-flash'. Providers whose catalogs use vendor-prefixed
|
|
87
|
+
* ids match the bare name against their catalog's last path segment;
|
|
88
|
+
* others return the same verdict as modelAffinity(id).
|
|
89
|
+
* Enables cross-provider aliasing so shared models rotate across all keys.
|
|
90
|
+
*/
|
|
91
|
+
modelAffinityByName?(bareName: string): ModelAffinity;
|
|
92
|
+
/**
|
|
93
|
+
* Map a bare model name to this provider's native catalog id
|
|
94
|
+
* ('deepseek-v4-flash' → 'deepseek/deepseek-v4-flash'), or null when the
|
|
95
|
+
* catalog doesn't serve it. Backed by the provider's live catalog;
|
|
96
|
+
* providers with 1:1 naming may return the input unchanged.
|
|
97
|
+
*/
|
|
98
|
+
resolveNativeId?(bareName: string): string | null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Model → provider affinity.
|
|
102
|
+
*
|
|
103
|
+
* 'yes' = the model id is (almost certainly) served by this provider,
|
|
104
|
+
* 'no' = definitely not, 'maybe' = cannot be decided structurally — the
|
|
105
|
+
* proxy tries matching providers first and falls back to any available key.
|
|
106
|
+
* Used for request routing in mixed-provider key pools.
|
|
107
|
+
*/
|
|
108
|
+
export type ModelAffinity = 'yes' | 'no' | 'maybe';
|
|
109
|
+
/** Shared usage shape used across providers (mirrors usage-client KeyUsage). */
|
|
110
|
+
export interface KeyUsage {
|
|
111
|
+
usedPercent: number;
|
|
112
|
+
rollingPercent: number;
|
|
113
|
+
weeklyPercent: number;
|
|
114
|
+
monthlyPercent: number;
|
|
115
|
+
rollingResetsAt?: string;
|
|
116
|
+
weeklyResetsAt?: string;
|
|
117
|
+
monthlyResetsAt?: string;
|
|
118
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* types.ts — Provider abstraction for multi-provider API key support.
|
|
3
|
+
*
|
|
4
|
+
* A KeyProvider encapsulates everything saros needs to know about one
|
|
5
|
+
* upstream API platform: how to recognize its keys structurally, how to
|
|
6
|
+
* verify them with a cheap network ping (smoke test), where its usage
|
|
7
|
+
* endpoint lives, and how to parse its usage response shape.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/providers/types.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG"}
|
package/dist/proxy-logic.d.ts
CHANGED
|
@@ -12,21 +12,37 @@
|
|
|
12
12
|
interface ApiKey {
|
|
13
13
|
label: string;
|
|
14
14
|
key: string;
|
|
15
|
+
/** Provider id ('opencode-go' | 'commandcode' | future extensions). */
|
|
16
|
+
provider: string;
|
|
15
17
|
enabled: boolean;
|
|
16
18
|
consecutiveFailures: number;
|
|
17
19
|
lastUsed: number | null;
|
|
18
20
|
disabledAt: number | null;
|
|
21
|
+
/** Worst-window used percent (0-100) reported by the upstream usage API. */
|
|
22
|
+
usagePercent: number | null;
|
|
23
|
+
/** Smooth weighted round-robin state (nginx-style). */
|
|
24
|
+
currentWeight: number;
|
|
19
25
|
}
|
|
20
26
|
/** Read-only projection returned to callers — cannot mutate internal state. */
|
|
21
27
|
export interface KeySnapshot {
|
|
22
28
|
label: string;
|
|
23
29
|
key: string;
|
|
30
|
+
/** Provider this key belongs to (set at state creation from config). */
|
|
31
|
+
provider: string;
|
|
24
32
|
}
|
|
25
33
|
/** Tracks a single HTTP request's key usage through its lifecycle. */
|
|
26
34
|
interface RequestContext {
|
|
27
35
|
requestId: string;
|
|
28
36
|
triedKeys: string[];
|
|
29
37
|
currentKey: KeySnapshot | null;
|
|
38
|
+
/**
|
|
39
|
+
* Provider filter fixed at selection time (model affinity / @suffix pin).
|
|
40
|
+
* Failover re-applies it so a pinned commandcode request never fails over
|
|
41
|
+
* to an opencode-go key that cannot serve the (already rewritten) model id.
|
|
42
|
+
*/
|
|
43
|
+
providerFilter?: (provider: string) => boolean;
|
|
44
|
+
/** True when the model is provider-pinned (@suffix) — tier-3 cross-provider fallback disabled. */
|
|
45
|
+
pinned?: boolean;
|
|
30
46
|
}
|
|
31
47
|
export interface ProxyState {
|
|
32
48
|
keys: ApiKey[];
|
|
@@ -34,51 +50,56 @@ export interface ProxyState {
|
|
|
34
50
|
circuitBreakerThreshold: number;
|
|
35
51
|
circuitBreakerCooldownMs: number;
|
|
36
52
|
activeRequests: Map<string, RequestContext>;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
rolling: number | null;
|
|
44
|
-
weekly: number | null;
|
|
45
|
-
monthly: number | null;
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Optional parameters for usage-gated key selection.
|
|
49
|
-
*/
|
|
50
|
-
export interface KeySelectionOptions {
|
|
51
|
-
usageMap?: Map<string, UsageInfo>;
|
|
52
|
-
usageThreshold?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Optional model→provider affinity resolver: (providerId, modelId) →
|
|
55
|
+
* 'yes' | 'no' | 'maybe'. Wired from the provider registry at app setup;
|
|
56
|
+
* absence disables affinity routing (single-provider behavior).
|
|
57
|
+
*/
|
|
58
|
+
affinityResolver?: (provider: string, modelId: string) => 'yes' | 'no' | 'maybe';
|
|
53
59
|
}
|
|
54
60
|
/**
|
|
55
61
|
* Error classification for HTTP responses.
|
|
56
|
-
* - KeyFault: the API key should be penalised (invalid, revoked,
|
|
62
|
+
* - KeyFault: the API key should be penalised (invalid, revoked, quota exhausted)
|
|
57
63
|
* - RequestFault: the request itself is bad — no key penalisation
|
|
58
64
|
* - ServerFault: transient server issue — key penalised (normal circuit-breaker)
|
|
65
|
+
* - NetworkFault: timeout / connection error — no key penalisation. The
|
|
66
|
+
* upstream base URL is shared by every key, so network-level failures are
|
|
67
|
+
* not a per-key fault; disabling keys for them caused total-blackout
|
|
68
|
+
* lockups ("all keys unavailable") when the upstream hung. Failover still
|
|
69
|
+
* tries the next key for this request.
|
|
59
70
|
*/
|
|
60
|
-
export type ErrorType = 'KeyFault' | 'RequestFault' | 'ServerFault';
|
|
71
|
+
export type ErrorType = 'KeyFault' | 'RequestFault' | 'ServerFault' | 'NetworkFault';
|
|
61
72
|
export declare function createProxyState(keys: Array<{
|
|
62
73
|
label: string;
|
|
63
74
|
key: string;
|
|
75
|
+
provider?: string;
|
|
64
76
|
}>, options?: {
|
|
65
77
|
circuitBreakerThreshold?: number;
|
|
66
78
|
circuitBreakerCooldownMs?: number;
|
|
67
79
|
}): ProxyState;
|
|
80
|
+
/**
|
|
81
|
+
* Update per-key usage data (worst-window used percent, 0-100).
|
|
82
|
+
* Keys absent from the map keep their previous value.
|
|
83
|
+
*/
|
|
84
|
+
export declare function updateKeyUsage(state: ProxyState, usage: Map<string, number>): void;
|
|
68
85
|
/**
|
|
69
86
|
* Obtain a key for a new (or in-flight) request.
|
|
70
87
|
* Creates a RequestContext if one does not already exist for this requestId.
|
|
71
88
|
* Skips keys that have already been tried for this request AND keys booked
|
|
72
89
|
* by any other active request.
|
|
90
|
+
*
|
|
91
|
+
* When a modelId is supplied and some providers claim it (modelAffinity
|
|
92
|
+
* 'yes'), keys of other providers are only used after the preferred pool is
|
|
93
|
+
* exhausted (including its booked-share tier).
|
|
73
94
|
*/
|
|
74
|
-
export declare function selectKeyForRequest(state: ProxyState, requestId: string,
|
|
95
|
+
export declare function selectKeyForRequest(state: ProxyState, requestId: string, modelId?: string): KeySnapshot | null;
|
|
75
96
|
/**
|
|
76
97
|
* Failover: record the current key of this request as failed and pick the next
|
|
77
98
|
* available key. The caller should also call markKeyFailed() with the
|
|
78
99
|
* appropriate ErrorType; this function only manages the request-context
|
|
79
100
|
* tracking (adds the current key to triedKeys).
|
|
80
101
|
*/
|
|
81
|
-
export declare function failoverRequest(state: ProxyState, requestId: string
|
|
102
|
+
export declare function failoverRequest(state: ProxyState, requestId: string): KeySnapshot | null;
|
|
82
103
|
/**
|
|
83
104
|
* Complete a request: if successful, mark the current key as succeeded;
|
|
84
105
|
* always clean up the request context.
|
|
@@ -87,7 +108,8 @@ export declare function completeRequest(state: ProxyState, requestId: string, su
|
|
|
87
108
|
/**
|
|
88
109
|
* Mark a key as failed according to the error type (C2).
|
|
89
110
|
* - KeyFault / ServerFault: penalise the key (KeyFault disables immediately)
|
|
90
|
-
* - RequestFault: no penalisation (bad request,
|
|
111
|
+
* - RequestFault / NetworkFault: no penalisation (bad request, or network
|
|
112
|
+
* trouble that is not the key's fault)
|
|
91
113
|
*/
|
|
92
114
|
export declare function markKeyFailed(state: ProxyState, keyLabel: string, errorType: ErrorType): void;
|
|
93
115
|
/**
|
|
@@ -101,7 +123,9 @@ export declare function markKeySucceeded(state: ProxyState, keyLabel: string): v
|
|
|
101
123
|
* 401 → KeyFault (invalid/revoked key — immediate disable)
|
|
102
124
|
* 429 → ServerFault (transient rate-limit — incremental circuit breaker)
|
|
103
125
|
* 500 / 502 / 503 → KeyFault if body mentions quota/balance, else ServerFault
|
|
104
|
-
* 400 / 404 / 422
|
|
126
|
+
* 400 / 403 / 404 / 422 → RequestFault (bad request or permission denial —
|
|
127
|
+
* don't penalise the key; 403 MODEL_NOT_IN_PLAN is a plan
|
|
128
|
+
* restriction, found live on commandcode)
|
|
105
129
|
* everything else → ServerFault
|
|
106
130
|
*/
|
|
107
131
|
export declare function classifyHttpError(status: number, _body?: string): ErrorType;
|
package/dist/proxy-logic.js
CHANGED
|
@@ -18,10 +18,13 @@ export function createProxyState(keys, options) {
|
|
|
18
18
|
keys: keys.map((k) => ({
|
|
19
19
|
label: k.label,
|
|
20
20
|
key: k.key,
|
|
21
|
+
provider: k.provider ?? 'opencode-go',
|
|
21
22
|
enabled: true,
|
|
22
23
|
consecutiveFailures: 0,
|
|
23
24
|
lastUsed: null,
|
|
24
25
|
disabledAt: null,
|
|
26
|
+
usagePercent: null,
|
|
27
|
+
currentWeight: 0,
|
|
25
28
|
})),
|
|
26
29
|
currentIndex: 0,
|
|
27
30
|
circuitBreakerThreshold: options?.circuitBreakerThreshold ?? 3,
|
|
@@ -37,7 +40,7 @@ export function createProxyState(keys, options) {
|
|
|
37
40
|
* (Keeps mutation paths through markKeyFailed / markKeySucceeded.)
|
|
38
41
|
*/
|
|
39
42
|
function toSnapshot(key) {
|
|
40
|
-
return { label: key.label, key: key.key };
|
|
43
|
+
return { label: key.label, key: key.key, provider: key.provider };
|
|
41
44
|
}
|
|
42
45
|
/**
|
|
43
46
|
* Internal: check whether a key is available (enabled & under threshold).
|
|
@@ -60,86 +63,6 @@ function isKeyAvailable(state, key) {
|
|
|
60
63
|
}
|
|
61
64
|
return false;
|
|
62
65
|
}
|
|
63
|
-
/**
|
|
64
|
-
* Check if a key is available based on usage data.
|
|
65
|
-
* A key is unavailable if ANY of its usage windows >= threshold.
|
|
66
|
-
*
|
|
67
|
-
* @param usage - The usage data for this key's account (null = no data = available)
|
|
68
|
-
* @param threshold - The usage threshold (1-100)
|
|
69
|
-
* @returns true if the key is available (usage below threshold or no data)
|
|
70
|
-
*/
|
|
71
|
-
function isKeyAvailableByUsage(usage, threshold) {
|
|
72
|
-
if (usage === null)
|
|
73
|
-
return true; // no data = don't block
|
|
74
|
-
return ((usage.rolling === null || usage.rolling < threshold) &&
|
|
75
|
-
(usage.weekly === null || usage.weekly < threshold) &&
|
|
76
|
-
(usage.monthly === null || usage.monthly < threshold));
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Internal: when all circuit-breaker-available keys are over the usage
|
|
80
|
-
* threshold, fall back to the key with the lowest maximum usage across
|
|
81
|
-
* all windows. If a key has no usage data, it is preferred immediately.
|
|
82
|
-
*/
|
|
83
|
-
function findFallbackKey(state, usageMap) {
|
|
84
|
-
let best = null;
|
|
85
|
-
let bestMaxUsage = Infinity;
|
|
86
|
-
for (const key of state.keys) {
|
|
87
|
-
if (!isKeyAvailable(state, key))
|
|
88
|
-
continue;
|
|
89
|
-
const usage = usageMap.get(key.label);
|
|
90
|
-
if (!usage) {
|
|
91
|
-
// No usage data = best possible – take it immediately
|
|
92
|
-
key.lastUsed = Date.now();
|
|
93
|
-
return toSnapshot(key);
|
|
94
|
-
}
|
|
95
|
-
const maxUsage = Math.max(usage.rolling ?? 0, usage.weekly ?? 0, usage.monthly ?? 0);
|
|
96
|
-
if (maxUsage < bestMaxUsage) {
|
|
97
|
-
bestMaxUsage = maxUsage;
|
|
98
|
-
best = key;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
if (best) {
|
|
102
|
-
best.lastUsed = Date.now();
|
|
103
|
-
return toSnapshot(best);
|
|
104
|
-
}
|
|
105
|
-
return null;
|
|
106
|
-
}
|
|
107
|
-
/**
|
|
108
|
-
* Internal: apply usage-gating to an exclusion set, then attempt to find
|
|
109
|
-
* the next available key. Falls back to findFallbackKey if all
|
|
110
|
-
* circuit-breaker-available keys are over the usage threshold.
|
|
111
|
-
*
|
|
112
|
-
* Shared by selectKeyForRequest and failoverRequest — extracts the ~90%
|
|
113
|
-
* identical usage-gating logic that was previously copy-pasted (PR-5).
|
|
114
|
-
*
|
|
115
|
-
* @param excludeLabels Mutable set; usage-over-threshold labels are added.
|
|
116
|
-
* @returns A key snapshot, or null if no key is available.
|
|
117
|
-
*/
|
|
118
|
-
function selectKeyWithUsageFallback(state, excludeLabels, options) {
|
|
119
|
-
// Exclude keys whose account usage exceeds the threshold
|
|
120
|
-
if (options?.usageMap && options?.usageThreshold !== undefined) {
|
|
121
|
-
const threshold = options.usageThreshold;
|
|
122
|
-
for (const key of state.keys) {
|
|
123
|
-
const usage = options.usageMap.get(key.label) ?? null;
|
|
124
|
-
if (!isKeyAvailableByUsage(usage, threshold)) {
|
|
125
|
-
excludeLabels.add(key.label);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
let snapshot = findNextKey(state, excludeLabels);
|
|
130
|
-
// Fallback: all circuit-breaker-available keys are over threshold
|
|
131
|
-
if (!snapshot && options?.usageMap) {
|
|
132
|
-
const fallback = findFallbackKey(state, options.usageMap);
|
|
133
|
-
if (fallback) {
|
|
134
|
-
const idx = state.keys.findIndex((k) => k.label === fallback.label);
|
|
135
|
-
if (idx !== -1) {
|
|
136
|
-
state.currentIndex = (idx + 1) % state.keys.length;
|
|
137
|
-
}
|
|
138
|
-
snapshot = fallback;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return snapshot;
|
|
142
|
-
}
|
|
143
66
|
/**
|
|
144
67
|
* Internal: return the set of key labels that are currently *in use* by
|
|
145
68
|
* active requests (C4 double-booking avoidance).
|
|
@@ -163,36 +86,128 @@ function buildBookedLabels(state) {
|
|
|
163
86
|
* Internal: find the next available key, skipping:
|
|
164
87
|
* - disabled / under-cooldown keys
|
|
165
88
|
* - labels in `excludeLabels`
|
|
166
|
-
*
|
|
89
|
+
*
|
|
90
|
+
* Selection strategy (smooth weighted round-robin, nginx-style):
|
|
91
|
+
* - When usage data is known for the candidates, keys with more remaining
|
|
92
|
+
* quota are proportionally preferred. E.g. key X at 30% used and key Y at
|
|
93
|
+
* 70% used receive ~70%/30% of requests — exact proportions over a cycle,
|
|
94
|
+
* not random sampling.
|
|
95
|
+
* - Without usage data (or when every candidate is exhausted), falls back to
|
|
96
|
+
* plain round-robin ordering.
|
|
167
97
|
*/
|
|
168
|
-
function findNextKey(state, excludeLabels) {
|
|
98
|
+
function findNextKey(state, excludeLabels, providerFilter) {
|
|
169
99
|
const n = state.keys.length;
|
|
170
100
|
if (n === 0)
|
|
171
101
|
return null;
|
|
102
|
+
const candidates = [];
|
|
172
103
|
for (let i = 0; i < n; i++) {
|
|
173
|
-
const
|
|
174
|
-
const key = state.keys[idx];
|
|
104
|
+
const key = state.keys[(state.currentIndex + i) % n];
|
|
175
105
|
if (excludeLabels.has(key.label))
|
|
176
106
|
continue;
|
|
107
|
+
if (providerFilter && !providerFilter(key.provider))
|
|
108
|
+
continue;
|
|
177
109
|
if (!isKeyAvailable(state, key))
|
|
178
110
|
continue;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
return
|
|
111
|
+
candidates.push(key);
|
|
112
|
+
}
|
|
113
|
+
if (candidates.length === 0)
|
|
114
|
+
return null;
|
|
115
|
+
const picked = pickWeighted(state, candidates);
|
|
116
|
+
state.currentIndex = (state.keys.indexOf(picked) + 1) % n;
|
|
117
|
+
picked.lastUsed = Date.now();
|
|
118
|
+
return toSnapshot(picked);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Internal: smooth weighted round-robin over the candidates.
|
|
122
|
+
* Falls back to the first candidate (round-robin order) when usage data is
|
|
123
|
+
* missing for any candidate or when no candidate has remaining capacity.
|
|
124
|
+
*/
|
|
125
|
+
function pickWeighted(state, candidates) {
|
|
126
|
+
const known = candidates.filter((k) => k.usagePercent !== null);
|
|
127
|
+
if (known.length === 0)
|
|
128
|
+
return candidates[0];
|
|
129
|
+
let weights = candidates.map((k) => Math.max(0, 100 - (k.usagePercent ?? 0)));
|
|
130
|
+
// Keys missing usage data get the median of the known weights — neutral,
|
|
131
|
+
// so one flaky key no longer disables weighting for the whole pool.
|
|
132
|
+
const sorted = known.map((k) => 100 - (k.usagePercent ?? 0)).sort((a, b) => a - b);
|
|
133
|
+
const median = sorted[Math.floor(sorted.length / 2)] ?? 0;
|
|
134
|
+
weights = weights.map((w, i) => (candidates[i].usagePercent === null ? median : w));
|
|
135
|
+
const totalWeight = weights.reduce((sum, w) => sum + w, 0);
|
|
136
|
+
if (totalWeight === 0)
|
|
137
|
+
return candidates[0];
|
|
138
|
+
let best = candidates[0];
|
|
139
|
+
let bestWeight = -Infinity;
|
|
140
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
141
|
+
candidates[i].currentWeight += weights[i];
|
|
142
|
+
if (candidates[i].currentWeight > bestWeight) {
|
|
143
|
+
bestWeight = candidates[i].currentWeight;
|
|
144
|
+
best = candidates[i];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
best.currentWeight -= totalWeight;
|
|
148
|
+
return best;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Update per-key usage data (worst-window used percent, 0-100).
|
|
152
|
+
* Keys absent from the map keep their previous value.
|
|
153
|
+
*/
|
|
154
|
+
export function updateKeyUsage(state, usage) {
|
|
155
|
+
for (const key of state.keys) {
|
|
156
|
+
const percent = usage.get(key.label);
|
|
157
|
+
if (typeof percent === 'number')
|
|
158
|
+
key.usagePercent = percent;
|
|
183
159
|
}
|
|
184
|
-
return null;
|
|
185
160
|
}
|
|
186
161
|
// ---------------------------------------------------------------------------
|
|
187
162
|
// Request-Scoped Key Tracking (C4)
|
|
188
163
|
// ---------------------------------------------------------------------------
|
|
164
|
+
/** Parse a trailing '@provider' suffix from a model id ('x@cc' → 'cc'). */
|
|
165
|
+
function parseModelSuffix(modelId) {
|
|
166
|
+
if (!modelId)
|
|
167
|
+
return null;
|
|
168
|
+
const at = modelId.lastIndexOf('@');
|
|
169
|
+
if (at <= 0 || at === modelId.length - 1)
|
|
170
|
+
return null;
|
|
171
|
+
return modelId.slice(at + 1);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Resolve the provider-preference predicate for a request.
|
|
175
|
+
*
|
|
176
|
+
* Rule: exclude providers whose verdict is definitively 'no' for the model
|
|
177
|
+
* (e.g. claude-* on opencode-go). Everything else — 'yes' AND 'maybe' —
|
|
178
|
+
* serves the request, so genuinely shared models rotate across ALL their
|
|
179
|
+
* keys regardless of which catalog loaded first. When no provider says 'no'
|
|
180
|
+
* the filter is undefined (legacy any-key behavior).
|
|
181
|
+
*/
|
|
182
|
+
function makeProviderFilter(state, modelId) {
|
|
183
|
+
if (!modelId)
|
|
184
|
+
return undefined;
|
|
185
|
+
const resolver = state.affinityResolver;
|
|
186
|
+
if (!resolver)
|
|
187
|
+
return undefined;
|
|
188
|
+
const excluded = new Set();
|
|
189
|
+
for (const key of state.keys) {
|
|
190
|
+
if (excluded.has(key.provider))
|
|
191
|
+
continue;
|
|
192
|
+
if (resolver(key.provider, modelId) === 'no') {
|
|
193
|
+
excluded.add(key.provider);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (excluded.size === 0)
|
|
197
|
+
return undefined;
|
|
198
|
+
return (provider) => !excluded.has(provider);
|
|
199
|
+
}
|
|
189
200
|
/**
|
|
190
201
|
* Obtain a key for a new (or in-flight) request.
|
|
191
202
|
* Creates a RequestContext if one does not already exist for this requestId.
|
|
192
203
|
* Skips keys that have already been tried for this request AND keys booked
|
|
193
204
|
* by any other active request.
|
|
205
|
+
*
|
|
206
|
+
* When a modelId is supplied and some providers claim it (modelAffinity
|
|
207
|
+
* 'yes'), keys of other providers are only used after the preferred pool is
|
|
208
|
+
* exhausted (including its booked-share tier).
|
|
194
209
|
*/
|
|
195
|
-
export function selectKeyForRequest(state, requestId,
|
|
210
|
+
export function selectKeyForRequest(state, requestId, modelId) {
|
|
196
211
|
// If context already exists, return the already-assigned current key
|
|
197
212
|
const existing = state.activeRequests.get(requestId);
|
|
198
213
|
if (existing) {
|
|
@@ -201,18 +216,27 @@ export function selectKeyForRequest(state, requestId, options) {
|
|
|
201
216
|
// Create new context and pick a fresh key
|
|
202
217
|
const ctx = { requestId, triedKeys: [], currentKey: null };
|
|
203
218
|
state.activeRequests.set(requestId, ctx);
|
|
219
|
+
const providerFilter = makeProviderFilter(state, modelId);
|
|
220
|
+
ctx.providerFilter = providerFilter;
|
|
221
|
+
ctx.pinned = Boolean(parseModelSuffix(modelId));
|
|
204
222
|
// Tier 1: prefer a key not booked by another active request (spread load)
|
|
205
223
|
const excludeLabels = new Set(ctx.triedKeys);
|
|
206
224
|
const booked = buildBookedLabels(state);
|
|
207
225
|
for (const label of booked) {
|
|
208
226
|
excludeLabels.add(label);
|
|
209
227
|
}
|
|
210
|
-
let snapshot =
|
|
211
|
-
// Tier 2: all healthy keys are booked — share one via
|
|
212
|
-
// Better to share a key than return 503 when concurrent
|
|
213
|
-
// outnumber the key pool.
|
|
228
|
+
let snapshot = findNextKey(state, excludeLabels, providerFilter);
|
|
229
|
+
// Tier 2: all healthy preferred keys are booked — share one via
|
|
230
|
+
// round-robin. Better to share a key than return 503 when concurrent
|
|
231
|
+
// requests outnumber the key pool.
|
|
214
232
|
if (!snapshot) {
|
|
215
|
-
snapshot =
|
|
233
|
+
snapshot = findNextKey(state, new Set(ctx.triedKeys), providerFilter);
|
|
234
|
+
}
|
|
235
|
+
// Tier 3: preferred providers exhausted → fall back to any provider
|
|
236
|
+
// (pinned requests exempt: a @provider suffix or messages-only model
|
|
237
|
+
// cannot be served by other providers at all).
|
|
238
|
+
if (!snapshot && providerFilter && !ctx.pinned) {
|
|
239
|
+
snapshot = findNextKey(state, new Set(ctx.triedKeys));
|
|
216
240
|
}
|
|
217
241
|
ctx.currentKey = snapshot;
|
|
218
242
|
return snapshot;
|
|
@@ -223,7 +247,7 @@ export function selectKeyForRequest(state, requestId, options) {
|
|
|
223
247
|
* appropriate ErrorType; this function only manages the request-context
|
|
224
248
|
* tracking (adds the current key to triedKeys).
|
|
225
249
|
*/
|
|
226
|
-
export function failoverRequest(state, requestId
|
|
250
|
+
export function failoverRequest(state, requestId) {
|
|
227
251
|
const ctx = state.activeRequests.get(requestId);
|
|
228
252
|
if (!ctx)
|
|
229
253
|
return null;
|
|
@@ -231,18 +255,25 @@ export function failoverRequest(state, requestId, options) {
|
|
|
231
255
|
if (ctx.currentKey) {
|
|
232
256
|
ctx.triedKeys.push(ctx.currentKey.label);
|
|
233
257
|
}
|
|
234
|
-
//
|
|
258
|
+
// The provider preference was fixed when the request context was created;
|
|
259
|
+
// failover re-applies it so cross-provider failover can't pick a key that
|
|
260
|
+
// cannot serve the (possibly rewritten) model id.
|
|
261
|
+
const filter = ctx.providerFilter;
|
|
235
262
|
const excludeLabels = new Set(ctx.triedKeys);
|
|
236
263
|
const booked = buildBookedLabels(state);
|
|
237
264
|
for (const label of booked) {
|
|
238
265
|
excludeLabels.add(label);
|
|
239
266
|
}
|
|
240
|
-
let snapshot =
|
|
241
|
-
// Tier 2: all remaining healthy keys are booked — share one.
|
|
267
|
+
let snapshot = findNextKey(state, excludeLabels, filter);
|
|
268
|
+
// Tier 2: all remaining healthy preferred keys are booked — share one.
|
|
242
269
|
// Still respects per-request triedKeys (don't re-use a key this
|
|
243
270
|
// request already failed on).
|
|
244
271
|
if (!snapshot) {
|
|
245
|
-
snapshot =
|
|
272
|
+
snapshot = findNextKey(state, new Set(ctx.triedKeys), filter);
|
|
273
|
+
}
|
|
274
|
+
// Tier 3: exhausted → any provider, unless the model is provider-pinned.
|
|
275
|
+
if (!snapshot && !ctx.pinned) {
|
|
276
|
+
snapshot = findNextKey(state, new Set(ctx.triedKeys));
|
|
246
277
|
}
|
|
247
278
|
ctx.currentKey = snapshot;
|
|
248
279
|
return snapshot;
|
|
@@ -266,10 +297,11 @@ export function completeRequest(state, requestId, success) {
|
|
|
266
297
|
/**
|
|
267
298
|
* Mark a key as failed according to the error type (C2).
|
|
268
299
|
* - KeyFault / ServerFault: penalise the key (KeyFault disables immediately)
|
|
269
|
-
* - RequestFault: no penalisation (bad request,
|
|
300
|
+
* - RequestFault / NetworkFault: no penalisation (bad request, or network
|
|
301
|
+
* trouble that is not the key's fault)
|
|
270
302
|
*/
|
|
271
303
|
export function markKeyFailed(state, keyLabel, errorType) {
|
|
272
|
-
if (errorType === 'RequestFault')
|
|
304
|
+
if (errorType === 'RequestFault' || errorType === 'NetworkFault')
|
|
273
305
|
return;
|
|
274
306
|
const key = state.keys.find((k) => k.label === keyLabel);
|
|
275
307
|
if (!key)
|
|
@@ -313,15 +345,23 @@ export function markKeySucceeded(state, keyLabel) {
|
|
|
313
345
|
* 401 → KeyFault (invalid/revoked key — immediate disable)
|
|
314
346
|
* 429 → ServerFault (transient rate-limit — incremental circuit breaker)
|
|
315
347
|
* 500 / 502 / 503 → KeyFault if body mentions quota/balance, else ServerFault
|
|
316
|
-
* 400 / 404 / 422
|
|
348
|
+
* 400 / 403 / 404 / 422 → RequestFault (bad request or permission denial —
|
|
349
|
+
* don't penalise the key; 403 MODEL_NOT_IN_PLAN is a plan
|
|
350
|
+
* restriction, found live on commandcode)
|
|
317
351
|
* everything else → ServerFault
|
|
318
352
|
*/
|
|
319
353
|
export function classifyHttpError(status, _body) {
|
|
320
|
-
if (status === 401)
|
|
354
|
+
if (status === 401) {
|
|
355
|
+
// opencode-go returns 401 ModelError for UNKNOWN MODELS (verified live:
|
|
356
|
+
// a Model gpt-5.5 is not supported, with a valid key). That is a request
|
|
357
|
+
// fault — penalising it would disable every key as the pool rotates.
|
|
358
|
+
if (_body && /ModelError|not supported/i.test(_body))
|
|
359
|
+
return 'RequestFault';
|
|
321
360
|
return 'KeyFault';
|
|
361
|
+
}
|
|
322
362
|
if (status === 429)
|
|
323
363
|
return 'ServerFault';
|
|
324
|
-
if (status === 400 || status === 404 || status === 422)
|
|
364
|
+
if (status === 400 || status === 403 || status === 404 || status === 422)
|
|
325
365
|
return 'RequestFault';
|
|
326
366
|
if (status === 500 || status === 502 || status === 503) {
|
|
327
367
|
// Server errors: if the body hints at a quota/balance issue treat as KeyFault
|