@bitkyc08/opencodex 2.42.0 → 2.43.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/AGENTS_INSTALL.md +2 -2
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -1
- package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
- package/gui/dist/assets/index-Djowl68T.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +304 -0
- package/src/adapters/anthropic-image-normalize.ts +8 -298
- package/src/adapters/anthropic.ts +30 -7
- package/src/adapters/command-code.ts +7 -5
- package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
- package/src/adapters/cursor/images.ts +36 -6
- package/src/adapters/cursor/live-transport.ts +7 -2
- package/src/adapters/cursor/native-exec-desktop.ts +2 -15
- package/src/adapters/cursor/protobuf-request.ts +13 -3
- package/src/adapters/cursor/tool-definitions.ts +5 -670
- package/src/adapters/cursor/tool-guidance.ts +236 -0
- package/src/adapters/cursor/tool-naming.ts +252 -0
- package/src/adapters/cursor/tool-schemas.ts +195 -0
- package/src/adapters/cursor/types.ts +6 -3
- package/src/adapters/exec-tool-result-normalize.ts +1 -1
- package/src/adapters/google-errors.ts +9 -1
- package/src/adapters/google.ts +1 -0
- package/src/adapters/kiro-calibration.ts +181 -0
- package/src/adapters/kiro.ts +135 -3
- package/src/adapters/openai-responses.ts +216 -26
- package/src/adapters/responses-code-mode.ts +59 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/adapters/xai-schema-analysis.ts +86 -0
- package/src/adapters/xai-tool-schema.ts +2 -87
- package/src/adapters/xai-web-search.ts +1 -1
- package/src/bridge.ts +22 -10
- package/src/chat/inbound.ts +11 -3
- package/src/claude/inbound-content-options.ts +60 -0
- package/src/claude/inbound-model-options.ts +142 -0
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +10 -202
- package/src/claude/model-info.ts +45 -0
- package/src/cli/account-auth.ts +21 -6
- package/src/cli/capabilities.ts +13 -2
- package/src/cli/claude.ts +232 -39
- package/src/cli/config-command.ts +9 -1
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/effort.ts +372 -0
- package/src/cli/export-command.ts +3 -9
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/init.ts +4 -0
- package/src/cli/model-selection-guidance.ts +30 -0
- package/src/cli/models-runtime.ts +3 -2
- package/src/cli/models.ts +8 -3
- package/src/cli/opencode.ts +4 -1
- package/src/cli/provider-runtime.ts +65 -0
- package/src/cli/provider.ts +8 -0
- package/src/cli/registry.ts +16 -2
- package/src/cli/runtime-api.ts +3 -1
- package/src/cli/star-prompt.ts +22 -6
- package/src/cli/status-probes.ts +168 -0
- package/src/cli/status.ts +5 -168
- package/src/clients/config-export/constants.ts +69 -0
- package/src/clients/config-export/contracts.ts +154 -0
- package/src/clients/config-export/dsh.ts +132 -0
- package/src/clients/config-export/fast-models.ts +29 -0
- package/src/clients/config-export/mcode.ts +83 -0
- package/src/clients/config-export/model-metadata.ts +108 -0
- package/src/clients/config-export/omp.ts +104 -0
- package/src/clients/config-export/zcode.ts +92 -0
- package/src/clients/config-export.ts +18 -710
- package/src/codex/account-lifecycle.ts +20 -3
- package/src/codex/account-usability.ts +2 -0
- package/src/codex/auth-api.ts +170 -23
- package/src/codex/auth-context.ts +328 -24
- package/src/codex/catalog/effort.ts +30 -4
- package/src/codex/catalog/metadata.ts +39 -9
- package/src/codex/catalog/native-models.ts +53 -12
- package/src/codex/catalog/parsing.ts +119 -5
- package/src/codex/catalog/provider-fetch.ts +150 -20
- package/src/codex/catalog/reserve.ts +52 -0
- package/src/codex/catalog/sync.ts +89 -16
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/convergence.ts +2 -0
- package/src/codex/data/upstream-models.json +1 -1
- package/src/codex/forward-transport-headers.ts +25 -0
- package/src/codex/inject.ts +3 -28
- package/src/codex/legacy-config-keys.ts +68 -0
- package/src/codex/log-guard/inspect-schema.ts +137 -0
- package/src/codex/log-guard/inspect.ts +2 -134
- package/src/codex/loopback-target.ts +54 -0
- package/src/codex/main-account-cache.ts +63 -1
- package/src/codex/main-account-hard-lock.ts +52 -0
- package/src/codex/main-account.ts +3 -1
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/model-entitlements.ts +54 -4
- package/src/codex/project-config-warnings.ts +92 -2
- package/src/codex/prompt-layers/encoding.ts +80 -0
- package/src/codex/prompt-layers/paths.ts +54 -0
- package/src/codex/prompt-layers/revision.ts +55 -0
- package/src/codex/prompt-layers/toml-edit.ts +163 -0
- package/src/codex/prompt-layers/toml-read.ts +181 -0
- package/src/codex/prompt-layers.ts +14 -520
- package/src/codex/quota-auto-refresh-state.ts +16 -0
- package/src/codex/quota-auto-refresh.ts +219 -0
- package/src/codex/quota-types.ts +51 -0
- package/src/codex/quota.ts +252 -93
- package/src/codex/reserve-availability.ts +177 -0
- package/src/codex/routing.ts +28 -9
- package/src/codex/shim.ts +53 -11
- package/src/codex/subagent-model-fallback.ts +23 -3
- package/src/combos/failover.ts +125 -7
- package/src/combos/identifiers.ts +89 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/resolve.ts +80 -9
- package/src/combos/types.ts +20 -93
- package/src/config/subagent-models.ts +24 -0
- package/src/config.ts +156 -13
- package/src/generated/compatibility-version.json +454 -166
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/journal.ts +65 -4
- package/src/integrations/store.ts +5 -0
- package/src/lab/events/limits.ts +4 -0
- package/src/lib/destination-policy.ts +31 -2
- package/src/lib/errors.ts +39 -0
- package/src/lib/provider-outbound.ts +69 -3
- package/src/lib/proxy-env.ts +22 -0
- package/src/lib/redact-folding.ts +176 -0
- package/src/lib/redact.ts +2 -175
- package/src/lib/state-store-sweeper.ts +20 -6
- package/src/lib/token-estimate.ts +94 -27
- package/src/lib/windows-user-principal.ts +53 -5
- package/src/oauth/anthropic-routing.ts +99 -3
- package/src/oauth/generic-account-failover.ts +36 -13
- package/src/oauth/index.ts +127 -26
- package/src/oauth/login-cli.ts +5 -0
- package/src/oauth/meta-muse.ts +117 -15
- package/src/oauth/pool-settings-capability.ts +15 -4
- package/src/providers/api-keys.ts +8 -10
- package/src/providers/default-aliases.ts +39 -0
- package/src/providers/derive.ts +10 -2
- package/src/providers/fastwire.ts +36 -7
- package/src/providers/initial-model-selection-runtime.ts +90 -0
- package/src/providers/initial-model-selection.ts +120 -0
- package/src/providers/key-failover.ts +134 -54
- package/src/providers/key-store.ts +11 -1
- package/src/providers/label.ts +1 -1
- package/src/providers/model-discovery.ts +76 -0
- package/src/providers/model-rename-startup.ts +72 -8
- package/src/providers/openai-sidecar.ts +17 -5
- package/src/providers/openai-tiers-destination.ts +102 -0
- package/src/providers/openai-tiers.ts +2 -99
- package/src/providers/opencode-go-transport.ts +41 -0
- package/src/providers/quota-key-accounts.ts +141 -0
- package/src/providers/quota-types.ts +9 -0
- package/src/providers/quota.ts +535 -104
- package/src/providers/registry.ts +60 -17
- package/src/providers/xai-responses-opt-in.ts +31 -5
- package/src/quota/reset-activation.ts +81 -0
- package/src/quota/reset-detector.ts +305 -0
- package/src/quota/reset-notify-config.ts +162 -0
- package/src/quota/reset-observer.ts +125 -0
- package/src/quota/reset-poller.ts +160 -0
- package/src/quota/reset-seen-store.ts +385 -0
- package/src/quota/reset-sinks.ts +199 -0
- package/src/quota/window-mapping.ts +106 -0
- package/src/responses/apply-patch-envelope.ts +46 -0
- package/src/responses/code-mode-helper-compat.ts +39 -1
- package/src/responses/custom-tool-compat.ts +10 -4
- package/src/responses/hosted-tool-policy.ts +12 -4
- package/src/responses/parser-content.ts +133 -0
- package/src/responses/parser-text-format.ts +24 -0
- package/src/responses/parser-tools.ts +188 -0
- package/src/responses/parser.ts +3 -326
- package/src/responses/state.ts +124 -28
- package/src/router.ts +48 -13
- package/src/routing/analytics.ts +1 -0
- package/src/routing/capability.ts +17 -4
- package/src/server/auth-cors.ts +7 -1
- package/src/server/background-lifecycle.ts +23 -1
- package/src/server/chat-completions.ts +25 -3
- package/src/server/claude-messages.ts +62 -5
- package/src/server/effort-row.ts +1 -1
- package/src/server/fast-row.ts +295 -0
- package/src/server/gui-static.ts +30 -4
- package/src/server/index.ts +107 -21
- package/src/server/management/agent-settings-routes.ts +2 -2
- package/src/server/management/combo-routes.ts +37 -9
- package/src/server/management/config-routes.ts +93 -2
- package/src/server/management/integration-routes.ts +108 -0
- package/src/server/management/model-routes.ts +13 -3
- package/src/server/management/model-rows.ts +20 -1
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/oauth-account-routes.ts +38 -10
- package/src/server/management/provider-routes.ts +34 -3
- package/src/server/management/quota-reset-routes.ts +57 -0
- package/src/server/management/route-registry.ts +7 -4
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/system-routes.ts +3 -2
- package/src/server/management-api.ts +14 -2
- package/src/server/ports.ts +12 -2
- package/src/server/relay-eager.ts +38 -23
- package/src/server/relay.ts +4 -0
- package/src/server/request-log.ts +6 -0
- package/src/server/responses/codex-ws-correlation.ts +65 -0
- package/src/server/responses/codex-ws-exchange.ts +261 -0
- package/src/server/responses/codex-ws-metadata.ts +134 -0
- package/src/server/responses/codex-ws-pool.ts +162 -0
- package/src/server/responses/codex-ws-request.ts +87 -0
- package/src/server/responses/codex-ws-session.ts +93 -0
- package/src/server/responses/codex-ws-wire.ts +144 -0
- package/src/server/responses/collaboration.ts +41 -1
- package/src/server/responses/compact.ts +105 -12
- package/src/server/responses/core.ts +476 -57
- package/src/server/responses/fetch-helpers.ts +10 -3
- package/src/server/responses/input-admission.ts +16 -9
- package/src/server/responses/responses-field-backfill.ts +1 -1
- package/src/server/responses/ws-upstream.ts +34 -318
- package/src/server/responses-custom-tool-repair.ts +20 -4
- package/src/server/responses-undeclared-tool-guard.ts +100 -8
- package/src/server/safe-response-headers.ts +23 -0
- package/src/server/search.ts +9 -0
- package/src/server/subagent-models-startup.ts +27 -0
- package/src/server/system-env-shell.ts +238 -0
- package/src/server/system-env.ts +7 -234
- package/src/server/ws-bridge.ts +3 -25
- package/src/server/xai-responses-startup.ts +21 -0
- package/src/service-manager-probe.ts +1 -1
- package/src/service.ts +55 -16
- package/src/types/config.ts +108 -12
- package/src/types/provider.ts +36 -7
- package/src/types/tools.ts +26 -1
- package/src/types.ts +2 -0
- package/src/update/notify.ts +8 -2
- package/src/usage/cost.ts +38 -28
- package/src/usage/expected-prices.ts +34 -15
- package/src/usage/log.ts +2 -0
- package/src/usage/model-identity.ts +26 -0
- package/src/usage/summary.ts +15 -1
- package/src/vision/describe.ts +6 -0
- package/src/vision/image-rewrite.ts +108 -0
- package/src/vision/index.ts +19 -306
- package/src/vision/plan.ts +205 -0
- package/src/web-search/executor.ts +6 -0
- package/src/web-search/index.ts +8 -1
- package/gui/dist/assets/index-BU1tE0sr.js +0 -112
- package/gui/dist/assets/index-DL9-iS6J.css +0 -1
package/src/lib/redact.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { foldForMatching } from "./redact-folding";
|
|
2
|
+
|
|
1
3
|
export const REDACTED_SECRET = "[REDACTED]";
|
|
2
4
|
|
|
3
5
|
/**
|
|
@@ -43,76 +45,6 @@ const CREDENTIAL_HEADER_LABEL_RAW = "x-api-key|x-goog-api-key|x-amz-security-tok
|
|
|
43
45
|
const CREDENTIAL_HEADER_LABEL = CREDENTIAL_HEADER_LABEL_RAW
|
|
44
46
|
.replace(/(?<![\[\\])([A-Za-z])(?![\]\-])/g, "[$1\u0001]");
|
|
45
47
|
|
|
46
|
-
/**
|
|
47
|
-
* Characters that render as a colon separator. Folded to `:` in the matching
|
|
48
|
-
* view so a look-alike cannot hide a header from the label pattern.
|
|
49
|
-
*/
|
|
50
|
-
const COLON_CONFUSABLES = new Set([
|
|
51
|
-
"\uFF1A", "\uFE55", "\uFE13", "\uA789", "\u02D0", "\u2236",
|
|
52
|
-
"\u205A", "\u0589", "\u1361", "\u16EC", "\u1803", "\u2982", "\u2AF6", "\uFE30",
|
|
53
|
-
]);
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Characters dropped from the matching view: anything with no visible width
|
|
57
|
-
* that could split a label into pieces the pattern no longer recognizes.
|
|
58
|
-
* `\p{Default_Ignorable_Code_Point}` is the systematic answer — it covers the
|
|
59
|
-
* zero-width set, the bidi isolates and marks, the Mongolian vowel separator,
|
|
60
|
-
* and the variation selectors in one property instead of a list that review
|
|
61
|
-
* keeps finding another member of. `\p{Cf}` and combining marks are folded too.
|
|
62
|
-
*/
|
|
63
|
-
const INVISIBLE_FORMAT = /[\p{Default_Ignorable_Code_Point}\p{Cf}\p{Mn}\p{Me}]/u;
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* HTML named character references.
|
|
67
|
-
*
|
|
68
|
-
* A hand-picked list is a coverage promise nobody can keep — review found
|
|
69
|
-
* `ⅈ`, `ⅇ`, and `ⅆ` decoding to compatibility letters that
|
|
70
|
-
* NFKD already maps onto `i`, `e`, and `d`, and the WHATWG table holds roughly
|
|
71
|
-
* 2200 entries. Neither Bun nor Node exposes that table, and pulling in a
|
|
72
|
-
* dependency to spell a header name is the wrong trade for this path.
|
|
73
|
-
*
|
|
74
|
-
* So names are not resolved at all. A named reference sitting inside a
|
|
75
|
-
* credential label is folded to a single placeholder character of unknown
|
|
76
|
-
* identity, and the label alternation accepts that placeholder wherever a
|
|
77
|
-
* letter may appear. Every named entity is covered, present and future,
|
|
78
|
-
* without claiming to know what any of them mean.
|
|
79
|
-
*/
|
|
80
|
-
const NAMED_ENTITY_PLACEHOLDER = "\u0001";
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* The handful of named references that spell a SEPARATOR rather than a letter.
|
|
84
|
-
* These have to resolve exactly, because the placeholder stands in for a letter
|
|
85
|
-
* position and a separator is structure, not a character of the name.
|
|
86
|
-
*/
|
|
87
|
-
const SEPARATOR_ENTITIES = new Map<string, string>([
|
|
88
|
-
["colon", ":"], ["semi", ";"], ["equals", "="], ["quot", '"'], ["apos", "'"],
|
|
89
|
-
["lt", "<"], ["gt", ">"], ["amp", "&"], ["sol", "/"], ["lowbar", "_"],
|
|
90
|
-
["hyphen", "-"], ["dash", "-"], ["ndash", "-"], ["mdash", "-"], ["minus", "-"],
|
|
91
|
-
["period", "."], ["comma", ","], ["num", "#"], ["nbsp", " "],
|
|
92
|
-
]);
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Latin look-alikes for the ASCII letters that appear in credential labels.
|
|
96
|
-
* Cyrillic `а`/`е`, Greek `ο`, fullwidth forms and the mathematical alphabets
|
|
97
|
-
* all render as the label to a human, so the matching view folds them back.
|
|
98
|
-
* NFKD handles the width/font variants; this table covers the cross-script
|
|
99
|
-
* homoglyphs NFKD deliberately leaves alone.
|
|
100
|
-
*/
|
|
101
|
-
const LETTER_CONFUSABLES = new Map<string, string>([
|
|
102
|
-
// Cyrillic
|
|
103
|
-
["\u0430", "a"], ["\u0435", "e"], ["\u043E", "o"], ["\u0440", "p"], ["\u0441", "c"],
|
|
104
|
-
["\u0445", "x"], ["\u0443", "y"], ["\u04BB", "h"], ["\u0455", "s"], ["\u0456", "i"],
|
|
105
|
-
["\u0458", "j"], ["\u043A", "k"], ["\u0442", "t"], ["\u0432", "b"], ["\u043C", "m"],
|
|
106
|
-
["\u043D", "h"], ["\u0501", "d"], ["\u0503", "g"], ["\u051B", "q"], ["\u051D", "w"],
|
|
107
|
-
["\u04CF", "l"], ["\u0261", "g"], ["\u04AB", "c"], ["\u04BD", "e"], ["\u0459", "k"],
|
|
108
|
-
// Greek
|
|
109
|
-
["\u03B1", "a"], ["\u03BF", "o"], ["\u03C1", "p"], ["\u03BD", "v"], ["\u03BA", "k"],
|
|
110
|
-
["\u03B5", "e"], ["\u03C4", "t"], ["\u03B9", "i"], ["\u03C5", "u"], ["\u03C7", "x"],
|
|
111
|
-
["\u03B7", "n"], ["\u03BC", "u"], ["\u03C3", "o"], ["\u03B2", "b"], ["\u03B3", "y"],
|
|
112
|
-
// Latin extended / other
|
|
113
|
-
["\u0131", "i"], ["\u0269", "i"], ["\u1D0F", "o"], ["\u0280", "r"], ["\u01BF", "p"],
|
|
114
|
-
["\u0578", "n"], ["\u057D", "u"], ["\u0585", "o"], ["\u0581", "g"], ["\u2044", "/"],
|
|
115
|
-
]);
|
|
116
48
|
|
|
117
49
|
// `\b` is the wrong left boundary for a header name: it matches after a `-` or
|
|
118
50
|
// `_`, so `not-authorization:` and `internal_token:` were treated as the
|
|
@@ -240,111 +172,6 @@ function maskOtherFramingsOnce(value: string, decodeEscapes: boolean): string {
|
|
|
240
172
|
return current;
|
|
241
173
|
}
|
|
242
174
|
|
|
243
|
-
/**
|
|
244
|
-
* Build a folded copy plus an index map back to the original string, so the
|
|
245
|
-
* match runs on normalized text while the output keeps every byte the match did
|
|
246
|
-
* not cover.
|
|
247
|
-
*/
|
|
248
|
-
function foldForMatching(value: string, decodeEscapes = true): { folded: string; map: number[] } {
|
|
249
|
-
let folded = "";
|
|
250
|
-
const map: number[] = [];
|
|
251
|
-
// Serialization escapes are ALIASES for the label, not decoration: a JSON
|
|
252
|
-
// `\u0069`, a percent-encoded `%69`, and an XML `i` all spell the same
|
|
253
|
-
// field name to whatever parses the body, while spelling something else to a
|
|
254
|
-
// literal matcher. Decode them into the matching view (one folded character
|
|
255
|
-
// per escape, with the whole escape mapped back to its start) so
|
|
256
|
-
// `author\u0069zation`, `author%69zation`, and `authorization` are the
|
|
257
|
-
// label they claim to be.
|
|
258
|
-
const decodeEscape = (at: number): { ch: string; width: number } | null => {
|
|
259
|
-
// JSON `\uXXXX`, INCLUDING a surrogate pair. Decoding the halves
|
|
260
|
-
// independently left `\uD835\uDD69` as two lone surrogates, so the
|
|
261
|
-
// mathematical letter they spell was never normalized as one code point.
|
|
262
|
-
const json = /^\\u([0-9a-fA-F]{4})/.exec(value.slice(at, at + 6));
|
|
263
|
-
if (json) {
|
|
264
|
-
const high = parseInt(json[1]!, 16);
|
|
265
|
-
if (high >= 0xd800 && high <= 0xdbff) {
|
|
266
|
-
const low = /^\\u([0-9a-fA-F]{4})/.exec(value.slice(at + 6, at + 12));
|
|
267
|
-
const lowCode = low ? parseInt(low[1]!, 16) : NaN;
|
|
268
|
-
if (lowCode >= 0xdc00 && lowCode <= 0xdfff) {
|
|
269
|
-
return { ch: String.fromCharCode(high, lowCode), width: 12 };
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
return { ch: String.fromCharCode(high), width: 6 };
|
|
273
|
-
}
|
|
274
|
-
// Percent encoding is UTF-8: consecutive `%XX` bytes form ONE character.
|
|
275
|
-
// Decoding each byte on its own turned `%D0%B5` into two unrelated
|
|
276
|
-
// Latin-1 characters instead of the Cyrillic `е` the fold would have
|
|
277
|
-
// recognized.
|
|
278
|
-
const pct = /^(?:%[0-9a-fA-F]{2})+/.exec(value.slice(at, at + 24));
|
|
279
|
-
if (pct) {
|
|
280
|
-
try {
|
|
281
|
-
const decoded = decodeURIComponent(pct[0]);
|
|
282
|
-
if (decoded.length >= 1) {
|
|
283
|
-
// Consume only the bytes that produced the FIRST character, so the
|
|
284
|
-
// rest of the sequence is decoded on the next iteration.
|
|
285
|
-
const first = String.fromCodePoint(decoded.codePointAt(0)!);
|
|
286
|
-
const bytes = new TextEncoder().encode(first).length;
|
|
287
|
-
return { ch: first, width: bytes * 3 };
|
|
288
|
-
}
|
|
289
|
-
} catch {
|
|
290
|
-
const single = parseInt(pct[0].slice(1, 3), 16);
|
|
291
|
-
return { ch: String.fromCharCode(single), width: 3 };
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
const xml = /^&#(x[0-9a-fA-F]{1,6}|[0-9]{1,7});/.exec(value.slice(at, at + 11));
|
|
295
|
-
if (xml) {
|
|
296
|
-
const raw = xml[1]!;
|
|
297
|
-
const code = raw[0] === "x" || raw[0] === "X"
|
|
298
|
-
? parseInt(raw.slice(1), 16)
|
|
299
|
-
: parseInt(raw, 10);
|
|
300
|
-
if (Number.isFinite(code) && code > 0 && code <= 0x10ffff) {
|
|
301
|
-
return { ch: String.fromCodePoint(code), width: xml[0].length };
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
// HTML named references. `:` and the other separator names are
|
|
305
|
-
// resolved exactly; anything else folds to the opaque placeholder so the
|
|
306
|
-
// label still matches without pretending to know the character.
|
|
307
|
-
const named = /^&([A-Za-z][A-Za-z0-9]{1,31});/.exec(value.slice(at, at + 34));
|
|
308
|
-
if (named) {
|
|
309
|
-
const separator = SEPARATOR_ENTITIES.get(named[1]!.toLowerCase());
|
|
310
|
-
return { ch: separator ?? NAMED_ENTITY_PLACEHOLDER, width: named[0].length };
|
|
311
|
-
}
|
|
312
|
-
return null;
|
|
313
|
-
};
|
|
314
|
-
// Iterate by CODE POINT, not UTF-16 code unit: a supplementary character
|
|
315
|
-
// (mathematical letters, variation selectors above the BMP) is two units, so
|
|
316
|
-
// a per-unit loop hands each half to the property tests separately and
|
|
317
|
-
// neither half matches anything. `𝕩-api-key` and a U+E0100 inside a label
|
|
318
|
-
// both walked straight past the fold that way.
|
|
319
|
-
let i = 0;
|
|
320
|
-
while (i < value.length) {
|
|
321
|
-
const escaped = decodeEscapes ? decodeEscape(i) : null;
|
|
322
|
-
const ch = escaped ? escaped.ch : String.fromCodePoint(value.codePointAt(i)!);
|
|
323
|
-
const width = escaped ? escaped.width : ch.length;
|
|
324
|
-
if (INVISIBLE_FORMAT.test(ch)) {
|
|
325
|
-
i += width;
|
|
326
|
-
continue;
|
|
327
|
-
}
|
|
328
|
-
const mapped = COLON_CONFUSABLES.has(ch)
|
|
329
|
-
? ":"
|
|
330
|
-
: LETTER_CONFUSABLES.get(ch.toLowerCase())
|
|
331
|
-
// NFKD collapses fullwidth, circled, and mathematical letter variants
|
|
332
|
-
// onto their ASCII base.
|
|
333
|
-
?? (ch.normalize("NFKD").length === 1 ? ch.normalize("NFKD") : ch);
|
|
334
|
-
// One folded unit per source code point keeps the offset map aligned; a
|
|
335
|
-
// multi-unit fold would desynchronize it, so those keep the original.
|
|
336
|
-
folded += mapped.length === 1 ? mapped : ch;
|
|
337
|
-
// One map entry per EMITTED UTF-16 unit. An escaped supplementary
|
|
338
|
-
// character emits two units, and giving it one entry desynchronized every
|
|
339
|
-
// later offset — the mask then landed mid-token and left part of the
|
|
340
|
-
// credential behind.
|
|
341
|
-
const emittedText = mapped.length === 1 ? mapped : ch;
|
|
342
|
-
for (let k = 0; k < emittedText.length; k += 1) map.push(i);
|
|
343
|
-
i += width;
|
|
344
|
-
}
|
|
345
|
-
map.push(value.length);
|
|
346
|
-
return { folded, map };
|
|
347
|
-
}
|
|
348
175
|
|
|
349
176
|
/**
|
|
350
177
|
* Run the header rule over BOTH matching views and take the union.
|
|
@@ -35,7 +35,13 @@ export interface StateStoreSweeperOptions {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
const registrations = new Map<string, StateStoreRegistration>();
|
|
38
|
-
|
|
38
|
+
interface AfterTickRegistrationNode {
|
|
39
|
+
registration: StateSweepAfterTickRegistration;
|
|
40
|
+
previous: AfterTickRegistrationNode | null;
|
|
41
|
+
active: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const afterTickRegistrations = new Map<string, AfterTickRegistrationNode>();
|
|
39
45
|
let configGeneration = 0;
|
|
40
46
|
let attemptSequence = 0;
|
|
41
47
|
let generationContextBuilder: (() => GenerationContext) | null = null;
|
|
@@ -56,11 +62,19 @@ export function registerStateStore(registration: StateStoreRegistration): () =>
|
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
export function registerStateSweepAfterTick(registration: StateSweepAfterTickRegistration): () => void {
|
|
59
|
-
|
|
65
|
+
const node: AfterTickRegistrationNode = {
|
|
66
|
+
registration,
|
|
67
|
+
previous: afterTickRegistrations.get(registration.name) ?? null,
|
|
68
|
+
active: true,
|
|
69
|
+
};
|
|
70
|
+
afterTickRegistrations.set(registration.name, node);
|
|
60
71
|
return () => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
72
|
+
node.active = false;
|
|
73
|
+
if (afterTickRegistrations.get(registration.name) !== node) return;
|
|
74
|
+
let previous = node.previous;
|
|
75
|
+
while (previous && !previous.active) previous = previous.previous;
|
|
76
|
+
if (previous) afterTickRegistrations.set(registration.name, previous);
|
|
77
|
+
else afterTickRegistrations.delete(registration.name);
|
|
64
78
|
};
|
|
65
79
|
}
|
|
66
80
|
|
|
@@ -106,7 +120,7 @@ export function sweepLiveness(): StateSweepResult {
|
|
|
106
120
|
}
|
|
107
121
|
|
|
108
122
|
function runAfterTickCallbacks(): void {
|
|
109
|
-
for (const registration of afterTickRegistrations.values()) {
|
|
123
|
+
for (const { registration } of afterTickRegistrations.values()) {
|
|
110
124
|
try {
|
|
111
125
|
registration.afterTick();
|
|
112
126
|
} catch {
|
|
@@ -9,50 +9,112 @@
|
|
|
9
9
|
* ~GPT 3.6, ~Gemini 3.8 chars/token (within ~10%). Code / JSON / tool-args (the dominant Codex
|
|
10
10
|
* traffic) pack MORE tokens per char, so a lower chars-per-token ratio is used for those models.
|
|
11
11
|
* Over-counting fails safe (auto-compact fires earlier); under-counting risks context overflow.
|
|
12
|
+
*
|
|
13
|
+
* ## Why the estimate is segmented by script rather than a single divisor
|
|
14
|
+
*
|
|
15
|
+
* A single chars-per-token divisor cannot describe mixed text. Latin prose and code run near
|
|
16
|
+
* 3.2 chars/token; Hangul and Han run near 1.2-1.5, because a Latin-trained BPE spends a token
|
|
17
|
+
* on roughly every CJK character. That is a ~2.5x spread inside one blob, and essentially all
|
|
18
|
+
* real agent traffic is mixed: English code and JSON framing with Korean prose threaded through
|
|
19
|
+
* it.
|
|
20
|
+
*
|
|
21
|
+
* The earlier model divided the whole blob by one ratio and then clamped to a denser ratio only
|
|
22
|
+
* when a SAMPLED CJK share crossed 30%. Two failures followed from that shape. The clamp was a
|
|
23
|
+
* cliff: 29% CJK counted at the sparse ratio, 31% at the dense one, and the band where real
|
|
24
|
+
* traffic actually sits (roughly 1-30% CJK) never triggered it at all. And the sampler read
|
|
25
|
+
* every stride-th character on long blobs, so the share it measured was noisy — a payload of
|
|
26
|
+
* fixed-width records could sample as 100% CJK while being 1.6% CJK, a case still documented in
|
|
27
|
+
* `server/responses/input-admission.ts`.
|
|
28
|
+
*
|
|
29
|
+
* Counting the two scripts separately and adding them removes both problems at once. There is no
|
|
30
|
+
* threshold to sit beside, no sampling error, and the result is continuous in the CJK share, so a
|
|
31
|
+
* blob that gains one Korean character gains a fraction of a token instead of jumping a ratio.
|
|
12
32
|
*/
|
|
13
33
|
|
|
14
34
|
/** Generic English-prose fallback ratio (chars per token). */
|
|
15
35
|
const DEFAULT_CHARS_PER_TOKEN = 4;
|
|
16
36
|
|
|
17
37
|
/**
|
|
18
|
-
* Kiro routes code/JSON-heavy agent traffic
|
|
19
|
-
*
|
|
20
|
-
*
|
|
38
|
+
* Kiro routes code/JSON-heavy agent traffic: diffs, file paths, tool arguments, identifiers.
|
|
39
|
+
* That material tokenizes markedly denser than the "4 chars per token" English-prose rule.
|
|
40
|
+
*
|
|
41
|
+
* Measured two independent ways, which agree. Recorded conversations pair exact text with the
|
|
42
|
+
* provider's authoritative input-token count; over 5,799 pure-Latin samples that aggregates to
|
|
43
|
+
* 2.80 chars/token. Separately, recorded Kiro request bodies charge ~2.43 bytes per token, and
|
|
44
|
+
* those bodies run ~1.12 bytes per counted character of JSON escaping and framing, which implies
|
|
45
|
+
* ~2.17 chars/token at the wire.
|
|
46
|
+
*
|
|
47
|
+
* 2.8 takes the conservative end of that pair. All kiro models are text LLMs, so one Latin ratio
|
|
48
|
+
* applies to the whole family.
|
|
49
|
+
*
|
|
50
|
+
* Applied ONLY to `kiro/`-prefixed ids. The measurement is Kiro's charge for Kiro's payload
|
|
51
|
+
* shape, and the id families below (`claude`, `deepseek`, `qwen`, ...) are also routed by
|
|
52
|
+
* Cursor, Anthropic direct and Antigravity, whose consumers read this same helper to size
|
|
53
|
+
* admission ceilings, `count_tokens` answers, and overflow-vs-429 classification. Widening a
|
|
54
|
+
* Kiro-derived constant to those callers would retune three unrelated subsystems from evidence
|
|
55
|
+
* that says nothing about them.
|
|
56
|
+
*/
|
|
57
|
+
const KIRO_CHARS_PER_TOKEN = 2.8;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Ratio for the model families Kiro shares with other providers, when NOT routed through Kiro.
|
|
61
|
+
*
|
|
62
|
+
* These are code-heavy agent models, so 3.5 remains right for them; it is the value every
|
|
63
|
+
* non-Kiro consumer of this helper was calibrated against.
|
|
21
64
|
*/
|
|
22
|
-
const
|
|
65
|
+
const AGENT_MODEL_CHARS_PER_TOKEN = 3.5;
|
|
23
66
|
|
|
24
|
-
const
|
|
67
|
+
const AGENT_MODEL_PREFIXES = ["kiro", "claude", "deepseek", "minimax", "glm", "qwen"];
|
|
25
68
|
|
|
26
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Model-aware chars-per-token ratio for LATIN text. Unknown models fall back to the generic
|
|
71
|
+
* English ratio.
|
|
72
|
+
*
|
|
73
|
+
* This is the sparse-script ratio only. It stays exported, and keeps returning a single number,
|
|
74
|
+
* because callers and tests use it to compare model families; the CJK component is applied by
|
|
75
|
+
* `estimateTokens` per character rather than by rewriting this ratio.
|
|
76
|
+
*/
|
|
27
77
|
export function charsPerToken(modelId?: string): number {
|
|
28
78
|
if (!modelId) return DEFAULT_CHARS_PER_TOKEN;
|
|
29
79
|
const id = modelId.toLowerCase();
|
|
30
|
-
|
|
80
|
+
// `estimateKiroTokens` always prefixes `kiro/`, so the Kiro-measured ratio reaches Kiro traffic
|
|
81
|
+
// and only Kiro traffic.
|
|
82
|
+
if (id.startsWith("kiro")) return KIRO_CHARS_PER_TOKEN;
|
|
83
|
+
if (AGENT_MODEL_PREFIXES.some(p => id.startsWith(p))) return AGENT_MODEL_CHARS_PER_TOKEN;
|
|
31
84
|
return DEFAULT_CHARS_PER_TOKEN;
|
|
32
85
|
}
|
|
33
86
|
|
|
34
87
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
88
|
+
* Chars per token for CJK text, applied to the CJK characters alone.
|
|
89
|
+
*
|
|
90
|
+
* Solved from the same recorded ground truth: holding the Latin ratio fixed and attributing the
|
|
91
|
+
* remainder of CJK-heavy samples to their Hangul/Han characters gives ~1.50 chars/token. A Korean
|
|
92
|
+
* character therefore costs roughly TWICE what a Latin character does, which is precisely what a
|
|
93
|
+
* single blended divisor cannot express.
|
|
39
94
|
*/
|
|
40
|
-
const CJK_CHARS_PER_TOKEN =
|
|
41
|
-
const CJK_RATIO_THRESHOLD = 0.3;
|
|
42
|
-
// Hangul syllables/jamo, CJK unified ideographs (+ext A), hiragana/katakana.
|
|
43
|
-
const CJK_RE = /[\uAC00-\uD7A3\u1100-\u11FF\u3130-\u318F\u4E00-\u9FFF\u3400-\u4DBF\u3040-\u30FF]/;
|
|
95
|
+
const CJK_CHARS_PER_TOKEN = 1.5;
|
|
44
96
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Count CJK characters exactly, in one pass, with no allocation and no regex object per char.
|
|
99
|
+
*
|
|
100
|
+
* Ranges: Hangul syllables and jamo, CJK unified ideographs and extension A, hiragana/katakana.
|
|
101
|
+
* Surrogate pairs (rare CJK extensions beyond the BMP) are counted as their two code units,
|
|
102
|
+
* which slightly over-counts them — again the safe direction.
|
|
103
|
+
*/
|
|
104
|
+
function countCjk(text: string): number {
|
|
49
105
|
let cjk = 0;
|
|
50
|
-
let
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
106
|
+
for (let i = 0; i < text.length; i++) {
|
|
107
|
+
const c = text.charCodeAt(i);
|
|
108
|
+
if (
|
|
109
|
+
(c >= 0xac00 && c <= 0xd7a3)
|
|
110
|
+
|| (c >= 0x1100 && c <= 0x11ff)
|
|
111
|
+
|| (c >= 0x3130 && c <= 0x318f)
|
|
112
|
+
|| (c >= 0x4e00 && c <= 0x9fff)
|
|
113
|
+
|| (c >= 0x3400 && c <= 0x4dbf)
|
|
114
|
+
|| (c >= 0x3040 && c <= 0x30ff)
|
|
115
|
+
) cjk++;
|
|
54
116
|
}
|
|
55
|
-
return
|
|
117
|
+
return cjk;
|
|
56
118
|
}
|
|
57
119
|
|
|
58
120
|
/**
|
|
@@ -80,7 +142,12 @@ export function estimateTokens(text: string, modelId?: string, contextWindow?: n
|
|
|
80
142
|
if (!text) return 0;
|
|
81
143
|
const len = text.length;
|
|
82
144
|
if (len === 0) return 0;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
145
|
+
const latinRatio = charsPerToken(modelId);
|
|
146
|
+
const cjk = countCjk(text);
|
|
147
|
+
// Continuous in the CJK share: no threshold, so one added Korean character moves the estimate
|
|
148
|
+
// by a fraction of a token instead of switching the whole blob to a different divisor.
|
|
149
|
+
const estimate = cjk === 0
|
|
150
|
+
? Math.ceil(len / latinRatio)
|
|
151
|
+
: Math.ceil((len - cjk) / latinRatio + cjk / CJK_CHARS_PER_TOKEN);
|
|
152
|
+
return capEstimateAtContextWindow(Math.max(1, estimate), contextWindow);
|
|
86
153
|
}
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { existsSync } from "node:fs";
|
|
24
24
|
import { win32 as windowsPath } from "node:path";
|
|
25
25
|
import { waitForSubprocessExit } from "./bounded-subprocess";
|
|
26
|
+
import { decodeWindowsTextBytes } from "./windows-text";
|
|
26
27
|
|
|
27
28
|
import {
|
|
28
29
|
resolveTrustedWindowsPowerShellExe,
|
|
@@ -98,7 +99,12 @@ export interface WindowsPrincipalLookupResult {
|
|
|
98
99
|
success: boolean;
|
|
99
100
|
exitCode: number | null;
|
|
100
101
|
timedOut: boolean;
|
|
101
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Raw child stdout. Bytes are allowed because `powershell.exe` writes the console
|
|
104
|
+
* output code page, not UTF-8, and the decode below is the thing under test: a seam
|
|
105
|
+
* that only carried a decoded string could never exercise it.
|
|
106
|
+
*/
|
|
107
|
+
stdout: string | Uint8Array;
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
export type WindowsPrincipalRunner = (
|
|
@@ -138,7 +144,10 @@ function defaultWindowsPrincipalRunner(timeoutMs: number): WindowsPrincipalLooku
|
|
|
138
144
|
success: result.success,
|
|
139
145
|
exitCode: result.exitCode,
|
|
140
146
|
timedOut: result.exitedDueToTimeout ?? false,
|
|
141
|
-
|
|
147
|
+
// Bytes, NOT .toString(): that is UTF-8, and Windows PowerShell 5.1 emits the
|
|
148
|
+
// console output code page. A non-ASCII account name decoded as UTF-8 becomes
|
|
149
|
+
// U+FFFD and is then frozen into the identity cache.
|
|
150
|
+
stdout: result.stdout ?? new Uint8Array(),
|
|
142
151
|
};
|
|
143
152
|
}
|
|
144
153
|
|
|
@@ -152,8 +161,9 @@ async function defaultAsyncWindowsPrincipalRunner(
|
|
|
152
161
|
windowsHide: true,
|
|
153
162
|
});
|
|
154
163
|
const { exitCode, timedOut } = await waitForSubprocessExit(proc, timeoutMs);
|
|
155
|
-
|
|
156
|
-
|
|
164
|
+
// `.bytes()` rather than `.text()`, for the same reason as the sync runner above.
|
|
165
|
+
const stdout: string | Uint8Array = !timedOut && proc.stdout
|
|
166
|
+
? await new Response(proc.stdout).bytes().catch(() => new Uint8Array())
|
|
157
167
|
: "";
|
|
158
168
|
return {
|
|
159
169
|
success: !timedOut && exitCode === 0,
|
|
@@ -165,6 +175,24 @@ async function defaultAsyncWindowsPrincipalRunner(
|
|
|
165
175
|
|
|
166
176
|
let principalRunner: WindowsPrincipalRunner = defaultWindowsPrincipalRunner;
|
|
167
177
|
let asyncPrincipalRunner: AsyncWindowsPrincipalRunner = defaultAsyncWindowsPrincipalRunner;
|
|
178
|
+
let principalLocaleForTests: string | undefined;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Decode child stdout the way the rest of this repository already decodes Windows
|
|
182
|
+
* console output: UTF-16 with or without a BOM, then STRICT UTF-8, then the locale's
|
|
183
|
+
* legacy code page. Strict-UTF-8-first is what keeps an ordinary UTF-8 host unaffected.
|
|
184
|
+
*
|
|
185
|
+
* The SID on the first line is ASCII by construction and survives either way, which is
|
|
186
|
+
* why this corruption stayed silent: only the account name on the second line breaks.
|
|
187
|
+
*/
|
|
188
|
+
function decodePrincipalStdout(stdout: string | Uint8Array): string {
|
|
189
|
+
if (typeof stdout === "string") return stdout;
|
|
190
|
+
return decodeWindowsTextBytes(
|
|
191
|
+
stdout,
|
|
192
|
+
principalLocaleForTests ? { locale: principalLocaleForTests } : {},
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
168
196
|
export interface WindowsPrincipalIdentity {
|
|
169
197
|
readonly sid: string;
|
|
170
198
|
readonly name: string;
|
|
@@ -220,7 +248,7 @@ function identityFromResult(result: WindowsPrincipalLookupResult): WindowsPrinci
|
|
|
220
248
|
? "timed out"
|
|
221
249
|
: `exited ${result.exitCode ?? "null"}`);
|
|
222
250
|
}
|
|
223
|
-
const lines = result.stdout.trim().split(/\r?\n/);
|
|
251
|
+
const lines = decodePrincipalStdout(result.stdout).trim().split(/\r?\n/);
|
|
224
252
|
const sid = lines[0]?.trim() ?? "";
|
|
225
253
|
const name = lines[1]?.trim() ?? "";
|
|
226
254
|
if (!SID_PATTERN.test(sid)) {
|
|
@@ -341,6 +369,26 @@ export function setAsyncWindowsPrincipalRunnerForTests(
|
|
|
341
369
|
cachedIdentity = null;
|
|
342
370
|
}
|
|
343
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Test seam: pin the locale that selects the legacy code page.
|
|
374
|
+
*
|
|
375
|
+
* Required rather than convenient. `decodeWindowsTextBytes` picks ONE legacy encoding
|
|
376
|
+
* from the ambient locale, so CP949, CP932 and CP936 fixtures cannot all decode
|
|
377
|
+
* correctly in a single process without being told which to expect. Production passes
|
|
378
|
+
* nothing and keeps the ambient locale.
|
|
379
|
+
*
|
|
380
|
+
* Clears the cache and refuses mid-flight for the same reasons the runner setters do:
|
|
381
|
+
* a successful identity is returned from cache BEFORE any decode, and the async path
|
|
382
|
+
* decodes after its runner resolves.
|
|
383
|
+
*/
|
|
384
|
+
export function setWindowsPrincipalLocaleForTests(locale: string | null): void {
|
|
385
|
+
if (asyncLookupInFlight) {
|
|
386
|
+
throw new Error("Cannot change the Windows principal locale while a lookup is in flight.");
|
|
387
|
+
}
|
|
388
|
+
principalLocaleForTests = locale ?? undefined;
|
|
389
|
+
cachedIdentity = null;
|
|
390
|
+
}
|
|
391
|
+
|
|
344
392
|
/** Test seam: clear only process-local principal state. */
|
|
345
393
|
export function resetWindowsPrincipalForTests(): void {
|
|
346
394
|
if (asyncLookupInFlight) {
|
|
@@ -152,6 +152,7 @@ export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
|
|
|
152
152
|
export function clearAnthropicAccountPoolState(): void {
|
|
153
153
|
upstreamHealth.clear();
|
|
154
154
|
sessionAffinity.clear();
|
|
155
|
+
quorumCache = null;
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
export function anthropicSessionAffinitySizeForTests(): number {
|
|
@@ -236,6 +237,79 @@ export function getEligibleAnthropicAccounts(now = Date.now()): string[] {
|
|
|
236
237
|
.map(account => account.id);
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
/**
|
|
241
|
+
* How long a quorum answer may be reused before the store is consulted again.
|
|
242
|
+
*
|
|
243
|
+
* This predicate now runs on the INITIAL resolution of every Anthropic request, not just after a
|
|
244
|
+
* 429, so an uncached implementation puts a synchronous file read in front of ordinary traffic:
|
|
245
|
+
* `getAccountSet` goes through `loadAuthStore`, which has no cache of its own and chmods the
|
|
246
|
+
* config dir, chmods the secret, reads the whole file and normalizes it on every call.
|
|
247
|
+
*
|
|
248
|
+
* Two seconds matches the generic module's `PRESENCE_CACHE_TTL_MS` for the same reason: short
|
|
249
|
+
* enough that a login in another window is visible before the operator can switch back and send a
|
|
250
|
+
* prompt, long enough that a burst of requests shares one read. The cache holds a BOOLEAN derived
|
|
251
|
+
* from a count — never a credential, never an account id.
|
|
252
|
+
*
|
|
253
|
+
* Staleness is bounded by consequence, not only by the TTL. Explicit invalidation covers the
|
|
254
|
+
* roster mutations this module can see (rotation, pool-state reset, affinity clear on account
|
|
255
|
+
* removal, manual selection), but not one it cannot: a 401 elsewhere flagging an account
|
|
256
|
+
* `needsReauth` drops the real quorum to one while a cached `true` survives for up to 2s.
|
|
257
|
+
*
|
|
258
|
+
* That window is harmless in both directions, which is why it is left rather than plumbed
|
|
259
|
+
* through the store. A stale `true` only lets the caller ASK for an alternate;
|
|
260
|
+
* `pickAlternateAnthropicAccount` re-reads the roster through `getEligibleAnthropicAccounts`,
|
|
261
|
+
* skips the reauth-flagged account and returns `null`, so the 429 surfaces exactly as it would
|
|
262
|
+
* have. A stale `false` costs one un-rotated 429 and self-corrects on the next read. Neither
|
|
263
|
+
* can dispatch on an unusable credential, which is the only outcome worth adding a store hook
|
|
264
|
+
* to prevent.
|
|
265
|
+
*/
|
|
266
|
+
const QUORUM_CACHE_TTL_MS = 2_000;
|
|
267
|
+
|
|
268
|
+
let quorumCache: { value: boolean; readAt: number } | null = null;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Whether a 429 has somewhere to go: two or more accounts that could serve traffic if asked.
|
|
272
|
+
*
|
|
273
|
+
* Reactive failover is a safety net, not a routing policy. It runs only AFTER upstream refused,
|
|
274
|
+
* it cannot spread load across a healthy session, and it cannot fire at all unless the operator
|
|
275
|
+
* deliberately logged in twice. So it activates on presence, exactly like an `apiKeyPool` of two
|
|
276
|
+
* keys does in `providers/key-failover.ts` -- and unlike the PROACTIVE pool (affinity,
|
|
277
|
+
* quota-ranked new-session picks, `autoSwitchThreshold`, `strategy`), which changes which
|
|
278
|
+
* account serves a healthy request and therefore stays behind `anthropicAccountPool.enabled`.
|
|
279
|
+
*
|
|
280
|
+
* Cooldowns are deliberately ignored here. They are transient and per-request, while this
|
|
281
|
+
* answers the durable question "did the operator store a second account". Counting a cooled
|
|
282
|
+
* account as absent would switch the feature off for the length of the cooldown -- precisely
|
|
283
|
+
* when it is needed.
|
|
284
|
+
*
|
|
285
|
+
* `isPoolCredentialUsable` is still applied, so the fail-closed background `local-cli` rule
|
|
286
|
+
* holds: an expired background slot is not a quorum and cannot be adopted.
|
|
287
|
+
*/
|
|
288
|
+
export function hasAnthropicFailoverQuorum(now = Date.now()): boolean {
|
|
289
|
+
// Monotonic guard: a caller-supplied `now` that predates the cached read (tests pass explicit
|
|
290
|
+
// clocks) must not be served from a future entry.
|
|
291
|
+
if (quorumCache && now >= quorumCache.readAt && now - quorumCache.readAt < QUORUM_CACHE_TTL_MS) {
|
|
292
|
+
return quorumCache.value;
|
|
293
|
+
}
|
|
294
|
+
const set = getAccountSet(PROVIDER);
|
|
295
|
+
let value = false;
|
|
296
|
+
if (set) {
|
|
297
|
+
let usable = 0;
|
|
298
|
+
for (const account of set.accounts) {
|
|
299
|
+
if (account.needsReauth === true) continue;
|
|
300
|
+
if (!isPoolCredentialUsable(account.id, now)) continue;
|
|
301
|
+
if (++usable >= 2) { value = true; break; }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
quorumCache = { value, readAt: now };
|
|
305
|
+
return value;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Test seam and manual-recovery hook: force the next quorum question to re-read the store. */
|
|
309
|
+
export function forgetAnthropicFailoverQuorum(): void {
|
|
310
|
+
quorumCache = null;
|
|
311
|
+
}
|
|
312
|
+
|
|
239
313
|
/** Earliest remaining cooldown among cooled Anthropic accounts, for client Retry-After. */
|
|
240
314
|
export function getAnthropicPoolRetryAfterSeconds(now = Date.now()): number | null {
|
|
241
315
|
const set = getAccountSet(PROVIDER);
|
|
@@ -559,6 +633,10 @@ export function clearAnthropicSessionAffinityForAccount(accountId: string): void
|
|
|
559
633
|
for (const [key, entry] of sessionAffinity) {
|
|
560
634
|
if (entry.accountId === accountId) sessionAffinity.delete(key);
|
|
561
635
|
}
|
|
636
|
+
// The roster just lost or changed a member. This is the account-removal path, so the next
|
|
637
|
+
// activation question must re-read rather than answer from a count taken while the account
|
|
638
|
+
// was still present -- otherwise a delete leaves a stale quorum for the length of the TTL.
|
|
639
|
+
quorumCache = null;
|
|
562
640
|
}
|
|
563
641
|
|
|
564
642
|
/**
|
|
@@ -573,7 +651,13 @@ export function rotateAnthropicAccountOn429(
|
|
|
573
651
|
sessionKey?: string | null,
|
|
574
652
|
now = Date.now(),
|
|
575
653
|
): string | null {
|
|
576
|
-
|
|
654
|
+
// Reactive 429 failover is NOT gated on the pool flag. That flag buys PROACTIVE routing --
|
|
655
|
+
// session affinity, quota-ranked new-session selection, autoSwitchThreshold, strategy -- all
|
|
656
|
+
// of which move a HEALTHY request and stay opt-in. Rotating away from an account upstream has
|
|
657
|
+
// just rate-limited is a different thing: it only ever runs after a refusal, and stranding a
|
|
658
|
+
// 429 while a second logged-in account sits idle is a defect, not a configuration choice.
|
|
659
|
+
// Presence is the activation rule, the same one an apiKeyPool of two keys already uses.
|
|
660
|
+
if (!isAnthropicAccountPoolEnabled(config) && !hasAnthropicFailoverQuorum(now)) return null;
|
|
577
661
|
|
|
578
662
|
const parsedRetry = parseRetryAfterMs(retryAfterHeader, now);
|
|
579
663
|
const cooldownMs = parsedRetry ?? DEFAULT_COOLDOWN_MS;
|
|
@@ -584,8 +668,17 @@ export function rotateAnthropicAccountOn429(
|
|
|
584
668
|
sweepExpiredOnWrite(now);
|
|
585
669
|
clearAnthropicSessionAffinityForAccount(failedAccountId);
|
|
586
670
|
notePoolRotationFailure(POOL_KEY_ANTHROPIC, failedAccountId);
|
|
587
|
-
|
|
588
|
-
|
|
671
|
+
// A rotation means the roster in use just changed; do not answer the next activation question
|
|
672
|
+
// from a count read taken before the failure.
|
|
673
|
+
quorumCache = null;
|
|
674
|
+
|
|
675
|
+
// The pool's strategy is a PROACTIVE policy. When the pool is disabled, reactive
|
|
676
|
+
// presence-only recovery must not silently reactivate round-robin/fill-first merely
|
|
677
|
+
// because those dormant values remain in config. The quota picker is the neutral
|
|
678
|
+
// recovery policy already used by the default strategy.
|
|
679
|
+
const next = isAnthropicAccountPoolEnabled(config)
|
|
680
|
+
? pickAlternateAnthropicAccount(config, failedAccountId, now)
|
|
681
|
+
: pickLowestUsage(config, failedAccountId, now);
|
|
589
682
|
if (!next) {
|
|
590
683
|
console.warn("[anthropic-pool] all eligible Anthropic OAuth accounts are in cooldown; returning 429");
|
|
591
684
|
return null;
|
|
@@ -614,6 +707,9 @@ export function promoteAnthropicActiveAccount(accountId: string): void {
|
|
|
614
707
|
export function resetAnthropicRoutingForManualSelection(accountId: string): void {
|
|
615
708
|
sessionAffinity.clear();
|
|
616
709
|
seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
|
|
710
|
+
// A manual account selection is an operator statement about the roster; do not answer the
|
|
711
|
+
// next activation question from a count read before it.
|
|
712
|
+
quorumCache = null;
|
|
617
713
|
}
|
|
618
714
|
|
|
619
715
|
/**
|