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