@bitkyc08/opencodex 2.55.0 → 2.57.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.
Files changed (256) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  3. package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -3
  6. package/src/adapters/base.ts +21 -0
  7. package/src/adapters/codebuddy/adapter.ts +2 -1
  8. package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
  9. package/src/adapters/command-code.ts +1 -1
  10. package/src/adapters/cursor/envelope-echo.ts +8 -2
  11. package/src/adapters/cursor/transport-retry.ts +46 -1
  12. package/src/adapters/cursor.ts +4 -0
  13. package/src/adapters/google.ts +7 -7
  14. package/src/adapters/kiro/adapter.ts +42 -1
  15. package/src/adapters/kiro/payload.ts +17 -3
  16. package/src/adapters/kiro/reasoning.ts +70 -7
  17. package/src/adapters/kiro/stream.ts +8 -2
  18. package/src/adapters/kiro/wire.ts +2 -1
  19. package/src/adapters/kiro-events.ts +21 -13
  20. package/src/adapters/kiro-retry.ts +23 -4
  21. package/src/adapters/openai-chat/errors.ts +116 -0
  22. package/src/adapters/openai-chat/messages.ts +346 -0
  23. package/src/adapters/openai-chat/passthrough.ts +146 -0
  24. package/src/adapters/openai-chat/response-events.ts +117 -0
  25. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  26. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  27. package/src/adapters/openai-chat/tool-schema.ts +495 -0
  28. package/src/adapters/openai-chat/wire.ts +50 -0
  29. package/src/adapters/openai-chat.ts +40 -1452
  30. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  31. package/src/adapters/openai-responses/image-gen.ts +406 -0
  32. package/src/adapters/openai-responses/internal.ts +3 -0
  33. package/src/adapters/openai-responses/passthrough.ts +642 -0
  34. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  35. package/src/adapters/openai-responses/reasoning.ts +220 -0
  36. package/src/adapters/openai-responses/request-strips.ts +185 -0
  37. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  38. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  39. package/src/adapters/openai-responses/web-search.ts +156 -0
  40. package/src/adapters/openai-responses.ts +4 -2625
  41. package/src/bridge/errors.ts +58 -0
  42. package/src/bridge/internal.ts +174 -0
  43. package/src/bridge/response-json.ts +630 -0
  44. package/src/bridge/sse.ts +1462 -0
  45. package/src/bridge.ts +5 -2204
  46. package/src/chat/inbound.ts +12 -1
  47. package/src/claude/desktop-profile.ts +66 -9
  48. package/src/claude/outbound.ts +18 -0
  49. package/src/cli/account-main.ts +1 -1
  50. package/src/cli/capabilities.ts +2 -2
  51. package/src/cli/combo.ts +10 -1
  52. package/src/cli/index.ts +48 -5
  53. package/src/cli/registry.ts +2 -1
  54. package/src/cli/system-command.ts +4 -4
  55. package/src/clients/config-export.ts +7 -3
  56. package/src/codex/account-label.ts +14 -3
  57. package/src/codex/account-lifecycle.ts +3 -0
  58. package/src/codex/account-store.ts +184 -35
  59. package/src/codex/account-usability.ts +21 -0
  60. package/src/codex/auth-api/account-list.ts +507 -0
  61. package/src/codex/auth-api/http.ts +32 -0
  62. package/src/codex/auth-api/login-flow.ts +566 -0
  63. package/src/codex/auth-api/login-state.ts +64 -0
  64. package/src/codex/auth-api/main-account-probe.ts +331 -0
  65. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  66. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  67. package/src/codex/auth-api/reset-credit-service.ts +431 -0
  68. package/src/codex/auth-api/routes.ts +425 -0
  69. package/src/codex/auth-api/runtime-config.ts +48 -0
  70. package/src/codex/auth-api.ts +27 -3118
  71. package/src/codex/auth-context.ts +252 -35
  72. package/src/codex/catalog/aggregation.ts +80 -1
  73. package/src/codex/catalog/auto-review.ts +507 -0
  74. package/src/codex/catalog/build-entries.ts +981 -0
  75. package/src/codex/catalog/combo-member.ts +375 -0
  76. package/src/codex/catalog/derive-entry.ts +229 -0
  77. package/src/codex/catalog/effort.ts +0 -1
  78. package/src/codex/catalog/gated-native-warn.ts +63 -0
  79. package/src/codex/catalog/gather-capture.ts +533 -0
  80. package/src/codex/catalog/model-hints.ts +691 -0
  81. package/src/codex/catalog/model-visibility.ts +305 -0
  82. package/src/codex/catalog/provider-fetch.ts +52 -2942
  83. package/src/codex/catalog/provider-models.ts +685 -0
  84. package/src/codex/catalog/remote.ts +30 -0
  85. package/src/codex/catalog/restore.ts +132 -0
  86. package/src/codex/catalog/retained-sync.ts +714 -0
  87. package/src/codex/catalog/routed-gather.ts +895 -0
  88. package/src/codex/catalog/subagent-roster.ts +176 -0
  89. package/src/codex/catalog/sync.ts +52 -2698
  90. package/src/codex/cli-install-provenance.ts +7 -1
  91. package/src/codex/convergence.ts +7 -2
  92. package/src/codex/desktop-app/types.ts +11 -2
  93. package/src/codex/desktop-app/windows.ts +5 -5
  94. package/src/codex/inject/config-toml.ts +563 -0
  95. package/src/codex/inject/remove.ts +192 -0
  96. package/src/codex/inject/restore.ts +567 -0
  97. package/src/codex/inject/routing-classify.ts +109 -0
  98. package/src/codex/inject/routing-target.ts +125 -0
  99. package/src/codex/inject.ts +89 -1444
  100. package/src/codex/lineage.ts +458 -0
  101. package/src/codex/model-entitlements.ts +152 -15
  102. package/src/codex/pool-refresh-backoff.ts +161 -0
  103. package/src/codex/quota-rejection.ts +104 -15
  104. package/src/codex/routing/active-account.ts +194 -0
  105. package/src/codex/routing/cache-affinity.ts +70 -0
  106. package/src/codex/routing/cooldown-math.ts +285 -0
  107. package/src/codex/routing/health-store.ts +402 -0
  108. package/src/codex/routing/probe-lease.ts +358 -0
  109. package/src/codex/routing/selection.ts +780 -0
  110. package/src/codex/routing/thread-affinity.ts +586 -0
  111. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  112. package/src/codex/routing.ts +370 -2271
  113. package/src/codex/shim-fingerprint.ts +223 -0
  114. package/src/codex/shim-inspect.ts +175 -0
  115. package/src/codex/shim-probe.ts +367 -0
  116. package/src/codex/shim-restore-lock.ts +169 -0
  117. package/src/codex/shim-state-file.ts +151 -0
  118. package/src/codex/shim-templates.ts +265 -0
  119. package/src/codex/shim.ts +48 -1268
  120. package/src/codex/warmup.ts +1 -1
  121. package/src/combos/failover.ts +85 -0
  122. package/src/combos/request.ts +17 -10
  123. package/src/combos/types.ts +23 -2
  124. package/src/config/diagnostics.ts +705 -0
  125. package/src/config/feature-flags.ts +55 -0
  126. package/src/config/live-reconcile.ts +403 -0
  127. package/src/config/load-degrade.ts +880 -0
  128. package/src/config/mutation-lock.ts +244 -0
  129. package/src/config/openai-tier-backup.ts +268 -0
  130. package/src/config/pending-teardown.ts +31 -0
  131. package/src/config/persist-unlocked.ts +92 -0
  132. package/src/config/proxy-env.ts +188 -0
  133. package/src/config/salvage.ts +244 -0
  134. package/src/config/schema/config-schema.ts +640 -0
  135. package/src/config/schema/leaf-validators.ts +855 -0
  136. package/src/config/warn-memo.ts +28 -0
  137. package/src/config.ts +234 -4481
  138. package/src/generated/compatibility-version.json +649 -121
  139. package/src/images/loop.ts +1 -1
  140. package/src/lib/errors.ts +17 -0
  141. package/src/lib/request-execution-budget.ts +198 -23
  142. package/src/lib/spend-reservation-ledger.ts +958 -0
  143. package/src/lib/state-store-registrations.ts +6 -2
  144. package/src/lib/test-home-guard.ts +85 -1
  145. package/src/lib/upstream-retry.ts +132 -21
  146. package/src/lib/windows-elevation.ts +76 -14
  147. package/src/lib/workflow-budget.ts +553 -30
  148. package/src/oauth/index.ts +2 -2
  149. package/src/oauth/key-providers.ts +2 -2
  150. package/src/providers/kiro-models.ts +4 -3
  151. package/src/providers/label.ts +19 -1
  152. package/src/providers/model-discovery.ts +16 -0
  153. package/src/providers/quota/account-cache.ts +441 -0
  154. package/src/providers/quota/antigravity.ts +295 -0
  155. package/src/providers/quota/report-cache.ts +320 -0
  156. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  157. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  158. package/src/providers/quota.ts +324 -3079
  159. package/src/providers/registry/entries-core.ts +1228 -0
  160. package/src/providers/registry/entries-extended.ts +1213 -0
  161. package/src/providers/registry/model-seeds.ts +912 -0
  162. package/src/providers/registry/types.ts +352 -0
  163. package/src/providers/registry.ts +24 -3536
  164. package/src/responses/continuation-ownership.ts +29 -0
  165. package/src/responses/reasoning-envelope.ts +6 -3
  166. package/src/responses/state/replay-fingerprint.ts +80 -0
  167. package/src/responses/state/snapshot-codec.ts +104 -0
  168. package/src/responses/state/spill-failure.ts +118 -0
  169. package/src/responses/state/spill-queue.ts +665 -0
  170. package/src/responses/state/temp-recovery.ts +257 -0
  171. package/src/responses/state.ts +82 -1143
  172. package/src/routing/identity-domains.ts +456 -0
  173. package/src/routing/probe-lease.ts +613 -0
  174. package/src/server/chat-completions.ts +3 -1
  175. package/src/server/chat-native.ts +37 -9
  176. package/src/server/index/bounded-request.ts +88 -0
  177. package/src/server/index/live-sideband.ts +601 -0
  178. package/src/server/index/serve-options.ts +1766 -0
  179. package/src/server/index/startup-warnings.ts +213 -0
  180. package/src/server/index/websocket-handler.ts +339 -0
  181. package/src/server/index.ts +45 -2552
  182. package/src/server/inspection-tee.ts +107 -0
  183. package/src/server/live.ts +46 -1
  184. package/src/server/management/combo-routes.ts +10 -1
  185. package/src/server/management/route-registry.ts +26 -23
  186. package/src/server/management/shared.ts +8 -5
  187. package/src/server/management/workflow-budget-routes.ts +133 -0
  188. package/src/server/management-api.ts +12 -0
  189. package/src/server/relay-eager.ts +2 -0
  190. package/src/server/relay.ts +14 -19
  191. package/src/server/request-log-conversation.ts +9 -7
  192. package/src/server/request-log.ts +372 -4
  193. package/src/server/response-log-body.ts +153 -0
  194. package/src/server/responses/account-change-state.ts +307 -0
  195. package/src/server/responses/adapter-continuation.ts +540 -0
  196. package/src/server/responses/adapter-delivery.ts +208 -0
  197. package/src/server/responses/adapter-dispatch.ts +1042 -0
  198. package/src/server/responses/codex-ws-wire.ts +5 -0
  199. package/src/server/responses/collaboration.ts +74 -4
  200. package/src/server/responses/combo-session-recall.ts +68 -8
  201. package/src/server/responses/compact.ts +113 -17
  202. package/src/server/responses/completion-policy.ts +33 -0
  203. package/src/server/responses/core-auth.ts +529 -0
  204. package/src/server/responses/core-codex-account.ts +907 -0
  205. package/src/server/responses/core-combo-failure.ts +210 -0
  206. package/src/server/responses/core-combo.ts +787 -0
  207. package/src/server/responses/core-errors.ts +170 -0
  208. package/src/server/responses/core-lifetime.ts +95 -0
  209. package/src/server/responses/core-normalize.ts +350 -0
  210. package/src/server/responses/core-opaque-recovery.ts +380 -0
  211. package/src/server/responses/core-options.ts +159 -0
  212. package/src/server/responses/core-replay.ts +298 -0
  213. package/src/server/responses/core.ts +192 -8893
  214. package/src/server/responses/encrypted-payload.ts +0 -1
  215. package/src/server/responses/input-admission.ts +126 -6
  216. package/src/server/responses/passthrough-delivery.ts +869 -0
  217. package/src/server/responses/passthrough-dispatch.ts +1494 -0
  218. package/src/server/responses/passthrough-error.ts +38 -2
  219. package/src/server/responses/passthrough-execution.ts +54 -0
  220. package/src/server/responses/request-prepare.ts +1080 -0
  221. package/src/server/responses/request-send-budget.ts +259 -0
  222. package/src/server/responses/request-sidecar-auth.ts +149 -0
  223. package/src/server/responses/request-spend.ts +147 -0
  224. package/src/server/responses/request-transport.ts +803 -0
  225. package/src/server/responses/response-effects.ts +157 -0
  226. package/src/server/responses/run-turn-execution.ts +476 -0
  227. package/src/server/responses/sidecar-execution.ts +463 -0
  228. package/src/server/responses/terminal-guard.ts +65 -4
  229. package/src/server/responses-image-gen-repair.ts +1 -1
  230. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  231. package/src/server/workflow-refusal.ts +84 -0
  232. package/src/service/windows-ops.ts +210 -16
  233. package/src/service/windows-scheduler.ts +28 -21
  234. package/src/service.ts +1 -1
  235. package/src/types/config.ts +34 -1
  236. package/src/types/request.ts +8 -5
  237. package/src/types/tools.ts +24 -0
  238. package/src/types.ts +2 -0
  239. package/src/update/index.ts +10 -0
  240. package/src/update/stop-contract.d.mts +1 -0
  241. package/src/update/stop-contract.mjs +19 -0
  242. package/src/update/stop-decision.d.mts +1 -1
  243. package/src/update/stop-decision.mjs +12 -3
  244. package/src/usage/log.ts +147 -1
  245. package/src/usage/summary.ts +171 -21
  246. package/src/vision/anthropic-describe.ts +1 -1
  247. package/src/vision/describe.ts +5 -5
  248. package/src/web-search/anthropic-executor.ts +1 -1
  249. package/src/web-search/exa-executor.ts +1 -1
  250. package/src/web-search/executor.ts +1 -1
  251. package/src/web-search/gemini-executor.ts +1 -1
  252. package/src/web-search/loop.ts +1 -1
  253. package/src/web-search/ollama-executor.ts +1 -1
  254. package/src/web-search/parse.ts +67 -14
  255. package/src/web-search/passthrough-bridge.ts +64 -31
  256. package/src/web-search/xai-executor.ts +1 -1
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Authentication identity, quota domain, and cache domain are three different
3
+ * questions (#4546, wp6).
4
+ *
5
+ * A credential pool is stored as a flat list, which smuggles in two assumptions that are
6
+ * each wrong in the opposite direction: two API keys are treated as two independent pools
7
+ * of capacity, and two accounts on one provider are treated as not sharing a cache. The
8
+ * first overcounts available capacity -- OpenAI rate limits are per organization and
9
+ * project, so failing over from key A to key B inside the same limit buys nothing while
10
+ * still paying a cold prefix. The second discards warm prefixes the provider would have
11
+ * served, or worse, assumes a hit the provider never promised.
12
+ *
13
+ * This module is a conservative CLASSIFIER, not a claim about where a provider stores
14
+ * anything. Every answer carries provenance: "operator-declared" comes from configured
15
+ * credential groups, "provider-documented" comes from the small built-in table below for
16
+ * the cases the PRD names, and "unknown" is a first-class result. "unknown" is never
17
+ * silently read as "no sharing" and never as "shared" -- relations report it explicitly
18
+ * so the caller applies its own conservative rule.
19
+ *
20
+ * Provenance is only half of it. A documented rule can prove that two credentials are in
21
+ * DIFFERENT domains without proving that two others are in the SAME one, so every domain
22
+ * also carries which of those two facts its key supports ({@link DomainEvidence}). That
23
+ * is why two OpenAI keys in one organization and region relate "unknown" for cache: the
24
+ * documentation separates, then declines to promise the hit.
25
+ *
26
+ * Conversational-state portability is a separate question from cache compatibility and
27
+ * is deliberately not folded into the domain keys: a request carrying
28
+ * previous_response_id, a provider-side conversation id, uploaded file ids, or encrypted
29
+ * reasoning cannot be replayed onto another credential at all, no matter how the domains
30
+ * relate. `canPortConversationState` is that separate check.
31
+ */
32
+
33
+ /** Where a domain answer comes from. Order of trust: operator > provider docs > nothing. */
34
+ export type IdentityDomainProvenance = "operator-declared" | "provider-documented" | "unknown";
35
+
36
+ /**
37
+ * What a domain key is evidence FOR, which is two facts rather than one.
38
+ *
39
+ * Proven SEPARATION and proven SHARING are different claims, and a provider routinely
40
+ * gives the first without the second. OpenAI's prompt-caching guide states the separating
41
+ * half outright -- "Caches are not shared across organizations and cannot be reused across
42
+ * regional processing boundaries" -- and never states a sharing half at all. The page does
43
+ * not discuss two API keys inside one organization, and what it does say about keys is that
44
+ * they "influence routing; they do not pin requests to a machine or guarantee a cache hit."
45
+ * So a different org-or-region key proves two domains, while an identical one proves
46
+ * nothing. A positive cache inference needs the provider to promise the hit, and no such
47
+ * promise exists here -- the silence is the evidence, not a documented denial. Inferring
48
+ * "shared" from an equal key would be the same guess this module exists to refuse, only
49
+ * pointed the other way.
50
+ *
51
+ * "separates" therefore means two different keys are two different domains while two
52
+ * identical keys stay "unknown". "separates-and-shares" means the same source also
53
+ * promised that one key is one domain.
54
+ */
55
+ export type DomainEvidence = "separates" | "separates-and-shares";
56
+
57
+ /**
58
+ * An opaque, comparable domain. `key` is only meaningful for equality when both sides
59
+ * are known; two "unknown" domains never compare shared because each carries a key
60
+ * derived from its own credential id. `evidence` decides whether an equal key is even
61
+ * allowed to mean "shared".
62
+ */
63
+ export interface IdentityDomain {
64
+ readonly key: string;
65
+ readonly provenance: IdentityDomainProvenance;
66
+ readonly evidence: DomainEvidence;
67
+ }
68
+
69
+ /**
70
+ * What the classifier knows about one credential. Every field beyond `credentialId` is
71
+ * optional evidence; a documented rule that needs a field this ref does not have yields
72
+ * "unknown", never a guess.
73
+ */
74
+ export interface CredentialDomainRef {
75
+ readonly credentialId: string;
76
+ readonly provider?: string;
77
+ readonly organizationId?: string;
78
+ readonly projectId?: string;
79
+ readonly workspaceId?: string;
80
+ readonly deploymentId?: string;
81
+ readonly region?: string;
82
+ }
83
+
84
+ export interface CredentialIdentity {
85
+ /** The credential the request is sent as. Never grouped, never shared. */
86
+ readonly authIdentity: string;
87
+ /** The set of credentials that demonstrably share one usage limit. */
88
+ readonly quotaDomain: IdentityDomain;
89
+ /** The conservative prompt-cache compatibility class. */
90
+ readonly cacheDomain: IdentityDomain;
91
+ /**
92
+ * Group ids that claim this credential when the declaration is ambiguous: the same
93
+ * group id declared twice, or the credential listed in more than one group. An
94
+ * ambiguous declaration is never resolved by list order -- the quota domain falls back
95
+ * to the provider-documented or unknown answer and the conflict is reported here.
96
+ * `pool.credentialGroups` rejects such a declaration on write and drops it on load, so
97
+ * this covers a caller that assembled groups some other way.
98
+ */
99
+ readonly declaredGroupConflict?: readonly string[];
100
+ }
101
+
102
+ /**
103
+ * How two domains relate. "unknown" is returned rather than collapsed into either
104
+ * answer, because treating it as "distinct" rotates within a shared limit (paying a
105
+ * cold prefix for zero capacity) and treating it as "shared" strands capacity that may
106
+ * be independent. An equal key whose evidence only proves separation also relates
107
+ * "unknown", which is how a documented non-sharing rule stays a non-sharing rule.
108
+ */
109
+ export type DomainRelation = "shared" | "distinct" | "unknown";
110
+
111
+ /**
112
+ * Operator-declared grouping from `pool.credentialGroups`.
113
+ *
114
+ * `credentials` holds PROVIDER-QUALIFIED ids, `"<provider>:<credential-id>"`. A bare id
115
+ * is ambiguous: credential ids are provider-scoped everywhere else -- `src/oauth/store.ts`
116
+ * keys an account by provider and id -- so `"acct-1"` names one credential per provider,
117
+ * and a bare declaration would silently merge unrelated quota domains. The provider
118
+ * segment normalizes through the same alias table as a classified ref, so
119
+ * `"chatgpt:acct-1"` and `"codex:acct-1"` name the same credential.
120
+ *
121
+ * Group ids must be unique, `credentials` must be non-empty, and a credential may appear
122
+ * in at most one group. {@link credentialGroupIssues} is the shared checker.
123
+ */
124
+ export interface DeclaredCredentialGroup {
125
+ readonly id: string;
126
+ readonly credentials: readonly string[];
127
+ readonly note?: string;
128
+ }
129
+
130
+ /**
131
+ * The provider-documented cases the PRD names, and only those. A rule returns
132
+ * undefined when the ref lacks the evidence the documentation requires; the caller
133
+ * then classifies "unknown" rather than extrapolating.
134
+ *
135
+ * - OpenAI: rate limits are per organization and project, with model groups sharing a
136
+ * limit ("Rate limits are defined at the organization level and at the project level, not
137
+ * user level", plus the documented shared limit across a model family); prompt caches are
138
+ * not shared across organizations or regional processing boundaries.
139
+ * - Anthropic: prompt cache is isolated per workspace even inside one organization.
140
+ * (Cache-read tokens are also excluded from input TPM there, which is quota
141
+ * accounting, not domain shape, so it does not appear here.)
142
+ * - Azure: limits and cache breakpoints are per deployment.
143
+ *
144
+ * Each rule also carries what its documented sentence proves ({@link DomainEvidence}),
145
+ * because two of these are separation rules and the rest promise sharing as well.
146
+ */
147
+ interface DocumentedDomainRule {
148
+ key(ref: CredentialDomainRef): string | undefined;
149
+ readonly evidence: DomainEvidence;
150
+ }
151
+
152
+ const PROVIDER_DOCUMENTED_DOMAINS: Record<string, {
153
+ quota?: DocumentedDomainRule;
154
+ cache?: DocumentedDomainRule;
155
+ }> = {
156
+ openai: {
157
+ quota: {
158
+ // Positive on both halves: the limit is defined per organization and project, and
159
+ // model groups share one limit, so two keys in one org and project are one limit.
160
+ key: (ref) => ref.organizationId !== undefined && ref.projectId !== undefined
161
+ ? `openai:org:${ref.organizationId}:project:${ref.projectId}`
162
+ : undefined,
163
+ evidence: "separates-and-shares",
164
+ },
165
+ cache: {
166
+ // Separation only, and the asymmetry is in the source. The prompt-caching guide says
167
+ // "Caches are not shared across organizations and cannot be reused across regional
168
+ // processing boundaries", which settles a DIFFERENT org or region as distinct. It
169
+ // states no counterpart for an identical one: the guide never discusses two keys in
170
+ // one organization, and a key is documented to "influence routing" without
171
+ // guaranteeing a hit. Same org and region therefore stays "unknown" -- claiming
172
+ // "shared" would assert a warm prefix the provider never promised, and the caller
173
+ // would pay for the guess by replaying a long prompt that misses.
174
+ key: (ref) => ref.organizationId !== undefined && ref.region !== undefined
175
+ ? `openai:org:${ref.organizationId}:region:${ref.region}`
176
+ : undefined,
177
+ evidence: "separates",
178
+ },
179
+ },
180
+ anthropic: {
181
+ cache: {
182
+ // The cache is scoped to the workspace as a resource: isolated from other
183
+ // workspaces inside one organization, and reused within it. Both halves come from
184
+ // the same documented scoping, so an equal key may mean shared.
185
+ key: (ref) => ref.workspaceId !== undefined
186
+ ? `anthropic:workspace:${ref.workspaceId}`
187
+ : undefined,
188
+ evidence: "separates-and-shares",
189
+ },
190
+ },
191
+ azure: {
192
+ quota: {
193
+ // Quota and cache are both properties of the deployment resource itself.
194
+ key: (ref) => ref.deploymentId !== undefined
195
+ ? `azure:deployment:${ref.deploymentId}`
196
+ : undefined,
197
+ evidence: "separates-and-shares",
198
+ },
199
+ cache: {
200
+ key: (ref) => ref.deploymentId !== undefined
201
+ ? `azure:deployment:${ref.deploymentId}`
202
+ : undefined,
203
+ evidence: "separates-and-shares",
204
+ },
205
+ },
206
+ };
207
+
208
+ const PROVIDER_ALIASES: Record<string, string> = {
209
+ "azure-openai": "azure",
210
+ "chatgpt": "openai",
211
+ "codex": "openai",
212
+ };
213
+
214
+ function normalizedProvider(provider: string | undefined): string | undefined {
215
+ if (provider === undefined) return undefined;
216
+ const lowered = provider.trim().toLowerCase();
217
+ return PROVIDER_ALIASES[lowered] ?? lowered;
218
+ }
219
+
220
+ function unknownDomain(kind: "quota" | "cache", credentialId: string): IdentityDomain {
221
+ // The credential id in the key keeps two unknown domains from ever comparing equal:
222
+ // uniqueness is what makes "unknown" impossible to misread as "shared".
223
+ return { key: `unknown:${kind}:${credentialId}`, provenance: "unknown", evidence: "separates" };
224
+ }
225
+
226
+ function documentedDomain(
227
+ rule: DocumentedDomainRule | undefined,
228
+ ref: CredentialDomainRef,
229
+ ): IdentityDomain | undefined {
230
+ const key = rule?.key(ref);
231
+ if (rule === undefined || key === undefined) return undefined;
232
+ return { key, provenance: "provider-documented", evidence: rule.evidence };
233
+ }
234
+
235
+ /** `"<provider>:<credential-id>"`, the only accepted spelling of a declared member. */
236
+ export const CREDENTIAL_GROUP_MEMBER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*:\S+$/;
237
+
238
+ function splitMember(member: string): { provider: string; credentialId: string } | undefined {
239
+ if (!CREDENTIAL_GROUP_MEMBER_PATTERN.test(member)) return undefined;
240
+ const separator = member.indexOf(":");
241
+ const provider = normalizedProvider(member.slice(0, separator));
242
+ if (provider === undefined || provider === "") return undefined;
243
+ return { provider, credentialId: member.slice(separator + 1) };
244
+ }
245
+
246
+ function canonicalMember(member: string): string {
247
+ const parsed = splitMember(member);
248
+ return parsed === undefined ? `unqualified:${member}` : `${parsed.provider}:${parsed.credentialId}`;
249
+ }
250
+
251
+ function memberMatches(member: string, ref: CredentialDomainRef): boolean {
252
+ const parsed = splitMember(member);
253
+ if (parsed === undefined) return false;
254
+ if (parsed.credentialId !== ref.credentialId) return false;
255
+ const refProvider = normalizedProvider(ref.provider);
256
+ // A ref without a provider cannot be matched to a provider-scoped declaration, so it
257
+ // keeps the documented or unknown answer instead of borrowing someone else's group.
258
+ return refProvider !== undefined && refProvider === parsed.provider;
259
+ }
260
+
261
+ /**
262
+ * Every way a declared grouping can be ambiguous, as operator-readable messages. The
263
+ * config write path rejects on any of these and the load path drops the list, so an
264
+ * ambiguous declaration is reported rather than resolved by whichever group came first.
265
+ */
266
+ export function credentialGroupIssues(groups: readonly DeclaredCredentialGroup[]): string[] {
267
+ const issues: string[] = [];
268
+ const seenIds = new Set<string>();
269
+ const owner = new Map<string, string>();
270
+ for (const group of groups) {
271
+ // A duplicate id is not cosmetic: both groups key to `declared:<id>`, so the second
272
+ // group's members join the first group's quota domain without anyone saying so.
273
+ if (seenIds.has(group.id)) issues.push(`duplicate group id ${JSON.stringify(group.id)}`);
274
+ seenIds.add(group.id);
275
+ if (group.credentials.length === 0) {
276
+ issues.push(`group ${JSON.stringify(group.id)} lists no credentials`);
277
+ }
278
+ for (const member of group.credentials) {
279
+ if (splitMember(member) === undefined) {
280
+ issues.push(
281
+ `group ${JSON.stringify(group.id)} member ${JSON.stringify(member)} must be provider-qualified as "<provider>:<credential-id>"`,
282
+ );
283
+ continue;
284
+ }
285
+ const existing = owner.get(canonicalMember(member));
286
+ if (existing === group.id) {
287
+ issues.push(`credential ${JSON.stringify(member)} is listed twice in group ${JSON.stringify(group.id)}`);
288
+ } else if (existing !== undefined) {
289
+ issues.push(
290
+ `credential ${JSON.stringify(member)} is declared in more than one group (${existing}, ${group.id})`,
291
+ );
292
+ } else {
293
+ owner.set(canonicalMember(member), group.id);
294
+ }
295
+ }
296
+ }
297
+ return issues;
298
+ }
299
+
300
+ function resolveDeclaredGroup(
301
+ ref: CredentialDomainRef,
302
+ groups: readonly DeclaredCredentialGroup[],
303
+ ): { group?: DeclaredCredentialGroup; conflict?: readonly string[] } {
304
+ const matches = groups.filter((group) => group.credentials.some((member) => memberMatches(member, ref)));
305
+ if (matches.length === 0) return {};
306
+ const conflicting = new Set<string>();
307
+ for (const match of matches) {
308
+ if (matches.length > 1) conflicting.add(match.id);
309
+ if (groups.filter((group) => group.id === match.id).length > 1) conflicting.add(match.id);
310
+ }
311
+ if (conflicting.size > 0) return { conflict: [...conflicting] };
312
+ return { group: matches[0] };
313
+ }
314
+
315
+ /**
316
+ * Classify one credential. `declaredGroups` is `pool.credentialGroups`; an operator
317
+ * declaration wins over the provider table because the operator can observe account
318
+ * topology the table cannot. Declared groups speak only to quota: sharing a usage
319
+ * limit says nothing about cache compatibility, so the cache domain never reads them.
320
+ *
321
+ * An ambiguous declaration -- a duplicated group id, or a credential claimed by two
322
+ * groups -- is not resolved by taking the first match. It is reported on
323
+ * `declaredGroupConflict` and the quota domain falls back to the documented or unknown
324
+ * answer, so a config that slipped past validation cannot silently merge two unrelated
325
+ * quota domains.
326
+ */
327
+ export function classifyCredential(
328
+ ref: CredentialDomainRef,
329
+ declaredGroups: readonly DeclaredCredentialGroup[] = [],
330
+ ): CredentialIdentity {
331
+ const { group: declared, conflict } = resolveDeclaredGroup(ref, declaredGroups);
332
+ const documented = PROVIDER_DOCUMENTED_DOMAINS[normalizedProvider(ref.provider) ?? ""] ?? {};
333
+
334
+ const quotaDomain: IdentityDomain = declared !== undefined
335
+ ? { key: `declared:${declared.id}`, provenance: "operator-declared", evidence: "separates-and-shares" }
336
+ : documentedDomain(documented.quota, ref) ?? unknownDomain("quota", ref.credentialId);
337
+
338
+ const cacheDomain: IdentityDomain = documentedDomain(documented.cache, ref)
339
+ ?? unknownDomain("cache", ref.credentialId);
340
+
341
+ return conflict === undefined
342
+ ? { authIdentity: ref.credentialId, quotaDomain, cacheDomain }
343
+ : { authIdentity: ref.credentialId, quotaDomain, cacheDomain, declaredGroupConflict: conflict };
344
+ }
345
+
346
+ function relateDomains(a: IdentityDomain, b: IdentityDomain): DomainRelation {
347
+ if (a.provenance === "unknown" || b.provenance === "unknown") return "unknown";
348
+ if (a.key !== b.key) return "distinct";
349
+ // Equal keys are proof of sharing only when both sides' evidence includes the sharing
350
+ // half. A separation-only rule (OpenAI's cache) stops here at "unknown".
351
+ return a.evidence === "separates-and-shares" && b.evidence === "separates-and-shares"
352
+ ? "shared"
353
+ : "unknown";
354
+ }
355
+
356
+ export function relateQuotaDomain(a: CredentialIdentity, b: CredentialIdentity): DomainRelation {
357
+ return relateDomains(a.quotaDomain, b.quotaDomain);
358
+ }
359
+
360
+ export function relateCacheDomain(a: CredentialIdentity, b: CredentialIdentity): DomainRelation {
361
+ return relateDomains(a.cacheDomain, b.cacheDomain);
362
+ }
363
+
364
+ /**
365
+ * What a quota refusal on `from` means for rotating to `to`. A refusal inside a known
366
+ * shared domain must not be answered by rotating within it -- the limit is the same,
367
+ * so the move pays a cold prefix for zero new capacity. "unknown" hands the decision
368
+ * back to the caller, which applies its own conservative rule.
369
+ */
370
+ export type QuotaRotationVerdict = "same-domain" | "distinct-domain" | "unknown";
371
+
372
+ export function assessQuotaRotation(
373
+ from: CredentialIdentity,
374
+ to: CredentialIdentity,
375
+ ): QuotaRotationVerdict {
376
+ const relation = relateQuotaDomain(from, to);
377
+ if (relation === "shared") return "same-domain";
378
+ if (relation === "distinct") return "distinct-domain";
379
+ return "unknown";
380
+ }
381
+
382
+ /**
383
+ * Available capacity across a credential set. Credentials in one known quota domain
384
+ * count ONCE. Unknown-domain credentials are reported separately rather than merged
385
+ * into either count, so the caller decides whether each is its own pool or not.
386
+ */
387
+ export function countQuotaCapacity(identities: readonly CredentialIdentity[]): {
388
+ readonly known: number;
389
+ readonly unknown: number;
390
+ } {
391
+ const knownKeys = new Set<string>();
392
+ let unknown = 0;
393
+ for (const identity of identities) {
394
+ if (identity.quotaDomain.provenance === "unknown") {
395
+ unknown += 1;
396
+ } else {
397
+ knownKeys.add(identity.quotaDomain.key);
398
+ }
399
+ }
400
+ return { known: knownKeys.size, unknown };
401
+ }
402
+
403
+ /** Why a conversation cannot be replayed onto a different credential. */
404
+ export type PortabilityDenial =
405
+ | "previous-response-id"
406
+ | "provider-conversation-id"
407
+ | "uploaded-file-ids"
408
+ | "encrypted-reasoning";
409
+
410
+ /**
411
+ * The parts of a request that bind it to the credential that produced them. Presence
412
+ * is what matters; the values stay opaque so nothing here logs or inspects ids.
413
+ */
414
+ export interface ConversationStateCarriers {
415
+ readonly previousResponseId?: string | null;
416
+ readonly providerConversationId?: string | null;
417
+ readonly fileIds?: readonly string[];
418
+ readonly encryptedReasoning?: unknown;
419
+ }
420
+
421
+ export type PortabilityVerdict =
422
+ | { readonly portable: true }
423
+ | { readonly portable: false; readonly reason: PortabilityDenial };
424
+
425
+ function present(value: unknown): boolean {
426
+ if (value === undefined || value === null) return false;
427
+ if (typeof value === "string" || Array.isArray(value)) return value.length > 0;
428
+ return true;
429
+ }
430
+
431
+ /**
432
+ * Whether a request's conversational state can move credentials at all. This is NOT
433
+ * cache compatibility: a shared cacheDomain means a replayed prefix might hit, while a
434
+ * refusal here means replaying is wrong regardless of warmth -- a previous_response_id
435
+ * or provider conversation id names server-side state another credential cannot see,
436
+ * and an uploaded file id or encrypted reasoning payload is bound to the account that
437
+ * issued it. A same-cacheDomain answer must never be read as portability, and a
438
+ * portable request gains no cache promise.
439
+ */
440
+ export function canPortConversationState(
441
+ state: ConversationStateCarriers,
442
+ ): PortabilityVerdict {
443
+ if (present(state.previousResponseId)) {
444
+ return { portable: false, reason: "previous-response-id" };
445
+ }
446
+ if (present(state.providerConversationId)) {
447
+ return { portable: false, reason: "provider-conversation-id" };
448
+ }
449
+ if (present(state.fileIds)) {
450
+ return { portable: false, reason: "uploaded-file-ids" };
451
+ }
452
+ if (present(state.encryptedReasoning)) {
453
+ return { portable: false, reason: "encrypted-reasoning" };
454
+ }
455
+ return { portable: true };
456
+ }