@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Kiro usage limits — the account-scoped quota read behind the Kiro pool.
3
+ *
4
+ * Kiro's generation traffic goes to `runtime.<region>.kiro.dev`, but the usage numbers
5
+ * live behind a different subdomain and a different AWS JSON-RPC operation:
6
+ * `AmazonCodeWhispererService.GetUsageLimits` on `management.<region>.kiro.dev`. The
7
+ * operation is undocumented, so every field here is best-effort: a shape we do not
8
+ * recognise resolves to `null` ("unknown"), never to a fabricated zero.
9
+ *
10
+ * This module also owns the small amount of state that quota percentages cannot express —
11
+ * whether an account is actually out of allowance, and when its window rolls over — because
12
+ * the pool needs those two answers to decide how long to cool a 429'd account.
13
+ */
14
+ import { getValidAccessSnapshotForAccount } from "../oauth";
15
+ import type { ProviderQuota, ProviderQuotaWindow } from "./quota-types";
16
+ import {
17
+ ACCOUNT_QUOTA_TTL_MS,
18
+ asRecord,
19
+ normalizePercent,
20
+ normalizeResetAt,
21
+ QUOTA_JSON_READ_FAILURE,
22
+ readQuotaJson,
23
+ REQUEST_TIMEOUT_MS,
24
+ toFiniteNumber,
25
+ } from "./quota-wire";
26
+
27
+ const AMZ_USAGE_TARGET = "AmazonCodeWhispererService.GetUsageLimits";
28
+
29
+ /**
30
+ * Regions are interpolated into a hostname, and two of the three candidates below are read
31
+ * out of credential files this process did not write. An allowlist keeps a crafted region
32
+ * from redirecting the request somewhere else entirely.
33
+ */
34
+ const REGION_PATTERN = /^[a-z0-9-]{1,32}$/;
35
+
36
+ /**
37
+ * Which usage bucket represents the plan allowance, in preference order.
38
+ *
39
+ * Selecting by position instead would mean an upstream reordering silently reweights the
40
+ * pool against an unrelated resource, so an unrecognised list resolves to unknown.
41
+ */
42
+ const RESOURCE_PRIORITY = ["AGENTIC_REQUEST", "CREDIT"] as const;
43
+
44
+ export interface KiroUsageContext {
45
+ /** Keys the usage-state row; always the stored account id, never the active account. */
46
+ accountId: string;
47
+ access: string;
48
+ profileArn?: string;
49
+ apiRegion?: string;
50
+ ssoRegion?: string;
51
+ }
52
+
53
+ export interface KiroUsageSnapshot {
54
+ quota: ProviderQuota;
55
+ /** Allowance is spent AND overage is not enabled — not merely "percent hit 100". */
56
+ exhausted: boolean;
57
+ /** Epoch ms when the plan window rolls over, when upstream reports it. */
58
+ nextResetAt?: number;
59
+ }
60
+
61
+ interface KiroUsageStateEntry {
62
+ exhausted: boolean;
63
+ nextResetAt?: number;
64
+ ts: number;
65
+ }
66
+
67
+ /**
68
+ * Exhaustion state, keyed exactly like the per-account quota cache in `quota.ts`.
69
+ *
70
+ * It is written only inside that cache's commit guard and cleared through the same
71
+ * logout/reconcile paths, so a removed account cannot leave a verdict behind for whatever
72
+ * account replaces it.
73
+ */
74
+ const usageState = new Map<string, KiroUsageStateEntry>();
75
+
76
+ function safeRegion(value: string | undefined): string | undefined {
77
+ return value && REGION_PATTERN.test(value) ? value : undefined;
78
+ }
79
+
80
+ /**
81
+ * The profile ARN wins because an enterprise profile can live in a different region from
82
+ * the SSO session that minted the token.
83
+ */
84
+ function usageRegion(ctx: KiroUsageContext): string {
85
+ return safeRegion(ctx.profileArn?.split(":")[3])
86
+ ?? safeRegion(ctx.apiRegion)
87
+ ?? safeRegion(ctx.ssoRegion)
88
+ ?? "us-east-1";
89
+ }
90
+
91
+ export function kiroUsageManagementUrl(region: string): string {
92
+ return `https://management.${region}.kiro.dev/`;
93
+ }
94
+
95
+ /** Credit balances are fractional; the integer fields round 695.17 down to 695. */
96
+ function preciseNumber(row: Record<string, unknown>, precise: string, whole: string): number | undefined {
97
+ return toFiniteNumber(row[precise]) ?? toFiniteNumber(row[whole]);
98
+ }
99
+
100
+ function selectBreakdown(list: unknown): Record<string, unknown> | null {
101
+ if (!Array.isArray(list)) return null;
102
+ const rows = list.map(asRecord).filter((row): row is Record<string, unknown> => row !== null);
103
+ for (const wanted of RESOURCE_PRIORITY) {
104
+ const match = rows.find(row => String(row.resourceType ?? "").trim().toUpperCase() === wanted);
105
+ if (match) return match;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ function parseKiroUsage(body: unknown): KiroUsageSnapshot | null {
111
+ const payload = asRecord(body);
112
+ if (!payload) return null;
113
+ const breakdown = selectBreakdown(payload.usageBreakdownList);
114
+ if (!breakdown) return null;
115
+
116
+ const used = preciseNumber(breakdown, "currentUsageWithPrecision", "currentUsage");
117
+ const limit = preciseNumber(breakdown, "usageLimitWithPrecision", "usageLimit");
118
+ if (used === undefined || limit === undefined || limit <= 0) return null;
119
+
120
+ const percent = normalizePercent((used / limit) * 100);
121
+ if (percent === undefined) return null;
122
+
123
+ const nextResetAt = normalizeResetAt(payload.nextDateReset);
124
+ const customWindows: ProviderQuotaWindow[] = [];
125
+
126
+ // A trial allowance is a separate pool: folding it into the plan window would understate
127
+ // what the account can actually spend.
128
+ const trial = asRecord(breakdown.freeTrialInfo);
129
+ if (trial) {
130
+ const trialUsed = preciseNumber(trial, "currentUsageWithPrecision", "currentUsage");
131
+ const trialLimit = preciseNumber(trial, "usageLimitWithPrecision", "usageLimit");
132
+ if (trialUsed !== undefined && trialLimit !== undefined && trialLimit > 0) {
133
+ const trialPercent = normalizePercent((trialUsed / trialLimit) * 100);
134
+ if (trialPercent !== undefined) customWindows.push({ label: "Free trial", percent: trialPercent });
135
+ }
136
+ }
137
+
138
+ const quota: ProviderQuota = {
139
+ monthlyPercent: percent,
140
+ ...(nextResetAt !== undefined ? { monthlyResetAt: nextResetAt } : {}),
141
+ ...(customWindows.length > 0 ? { customWindows } : {}),
142
+ updatedAt: Date.now(),
143
+ };
144
+
145
+ // Enterprise accounts with overage enabled keep serving past the included limit, so
146
+ // "used >= limit" is not by itself a reason to stop routing to the account.
147
+ const overageEnabled = String(asRecord(payload.overageConfiguration)?.overageStatus ?? "")
148
+ .trim()
149
+ .toUpperCase() === "ENABLED";
150
+
151
+ return {
152
+ quota,
153
+ exhausted: used >= limit && !overageEnabled,
154
+ ...(nextResetAt !== undefined ? { nextResetAt } : {}),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Read one account's usage. Resolves `null` for any transport, status, or schema failure —
160
+ * the caller renders that as "unavailable" and keeps whatever it knew before.
161
+ *
162
+ * `userInfo` in the response carries an email and a user id. Both are read past and
163
+ * discarded here: nothing identifying an operator's person reaches the cache, the API, or
164
+ * a log line.
165
+ */
166
+ export async function fetchKiroUsageSnapshot(ctx: KiroUsageContext): Promise<KiroUsageSnapshot | null> {
167
+ const region = usageRegion(ctx);
168
+ const url = new URL(kiroUsageManagementUrl(region));
169
+ url.searchParams.set("origin", "AI_EDITOR");
170
+ url.searchParams.set("isEmailRequired", "true");
171
+ if (ctx.profileArn) url.searchParams.set("profileArn", ctx.profileArn);
172
+
173
+ // The modeled arguments appear in BOTH the query string and the body. That duplication is
174
+ // the observed Kiro CLI contract, not an oversight; we have no way to test which side the
175
+ // service actually reads, so we reproduce both.
176
+ const body: Record<string, unknown> = { origin: "AI_EDITOR", isEmailRequired: true };
177
+ if (ctx.profileArn) body.profileArn = ctx.profileArn;
178
+
179
+ try {
180
+ const response = await fetch(url, {
181
+ method: "POST",
182
+ headers: {
183
+ authorization: `Bearer ${ctx.access}`,
184
+ "content-type": "application/x-amz-json-1.0",
185
+ accept: "application/json",
186
+ "x-amz-target": AMZ_USAGE_TARGET,
187
+ "x-amzn-codewhisperer-optout": "true",
188
+ },
189
+ body: JSON.stringify(body),
190
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
191
+ });
192
+ if (!response.ok) return null;
193
+ const json = await readQuotaJson(response);
194
+ if (json === QUOTA_JSON_READ_FAILURE) return null;
195
+ return parseKiroUsage(json);
196
+ } catch {
197
+ return null;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Assemble the probe context from ONE account-scoped snapshot.
203
+ *
204
+ * Reading the bearer and the routing metadata from a single snapshot is what keeps account
205
+ * A's token from being sent with account B's profile ARN — the same pairing class of defect
206
+ * #2841 fixed for Copilot origins.
207
+ */
208
+ export async function kiroUsageContextForAccount(accountId: string): Promise<KiroUsageContext> {
209
+ const snapshot = await getValidAccessSnapshotForAccount("kiro", accountId);
210
+ return {
211
+ accountId,
212
+ access: snapshot.accessToken,
213
+ ...(snapshot.kiro?.profileArn ? { profileArn: snapshot.kiro.profileArn } : {}),
214
+ ...(snapshot.kiro?.apiRegion ? { apiRegion: snapshot.kiro.apiRegion } : {}),
215
+ ...(snapshot.kiro?.ssoRegion ? { ssoRegion: snapshot.kiro.ssoRegion } : {}),
216
+ };
217
+ }
218
+
219
+ /** Record exhaustion for a probed account. Called from the quota cache's commit guard. */
220
+ export function commitKiroAccountUsageState(key: string, snapshot: KiroUsageSnapshot | null): void {
221
+ if (!snapshot) {
222
+ usageState.delete(key);
223
+ return;
224
+ }
225
+ usageState.set(key, {
226
+ exhausted: snapshot.exhausted,
227
+ ...(snapshot.nextResetAt !== undefined ? { nextResetAt: snapshot.nextResetAt } : {}),
228
+ ts: Date.now(),
229
+ });
230
+ }
231
+
232
+ /**
233
+ * Is this account known to be out of allowance right now?
234
+ *
235
+ * Returns `null` (unknown) rather than a stale `true`: an expired reading, or one whose
236
+ * reset time has already passed, must degrade to "try it again", never to "keep it parked".
237
+ */
238
+ export function getKiroAccountExhaustion(
239
+ key: string,
240
+ now = Date.now(),
241
+ ): { exhausted: boolean; nextResetAt?: number } | null {
242
+ const entry = usageState.get(key);
243
+ if (!entry) return null;
244
+ if (now - entry.ts >= ACCOUNT_QUOTA_TTL_MS) return null;
245
+ if (entry.nextResetAt !== undefined && entry.nextResetAt <= now) return null;
246
+ return {
247
+ exhausted: entry.exhausted,
248
+ ...(entry.nextResetAt !== undefined ? { nextResetAt: entry.nextResetAt } : {}),
249
+ };
250
+ }
251
+
252
+ /** Drop rows for one provider prefix, or all of them. Mirrors clearAccountQuotaCache. */
253
+ export function clearKiroAccountUsageState(prefix?: string): void {
254
+ if (!prefix) {
255
+ usageState.clear();
256
+ return;
257
+ }
258
+ for (const key of [...usageState.keys()]) {
259
+ if (key.startsWith(prefix)) usageState.delete(key);
260
+ }
261
+ }
262
+
263
+ /** Drop rows whose account no longer exists. Mirrors reconcileProviderAccountQuotaRows. */
264
+ export function reconcileKiroAccountUsageState(liveKeys: ReadonlySet<string>): number {
265
+ let removed = 0;
266
+ for (const key of [...usageState.keys()]) {
267
+ if (liveKeys.has(key)) continue;
268
+ usageState.delete(key);
269
+ removed += 1;
270
+ }
271
+ return removed;
272
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Bounded Ollama Cloud `/api/show` metadata enrichment.
3
+ *
4
+ * `/v1/models` is the authoritative live ID roster, but it carries no per-model context or
5
+ * capability metadata, so a newly announced Ollama model (e.g. glm-5.3 during its rollout)
6
+ * would otherwise be advertised to Codex with generic defaults — a 1M-context model published
7
+ * at 128K, and native vision unknown. `/api/show` fills that gap for canonical Ollama Cloud
8
+ * destinations only.
9
+ *
10
+ * The show request reuses the discovery request's already-materialized captured headers
11
+ * (credential + configured-header precedence resolved by `buildModelsRequest`, not re-derived
12
+ * here) and executes through the same outbound-policy transport as discovery
13
+ * (`providerOutboundPost`: destination policy, DNS pinning, manual redirects, caller-owned
14
+ * executor).
15
+ *
16
+ * Failure is per model and fail-soft: any transport, status, redirect, size, parse, or timeout
17
+ * failure drops the enrichment for that one model and never touches other rows or the ID roster
18
+ * itself. Only evidence-backed fields are extracted; templates, licenses, and tokenizer
19
+ * payloads exist only inside the bounded body and are never projected into CatalogModel
20
+ * metadata.
21
+ */
22
+ import { readBoundedResponseBytes } from "../lib/bounded-body";
23
+ import { isCanonicalOllamaCloudUrl } from "../adapters/ollama-native-url";
24
+ import { providerOutboundPost, providerRedirectError } from "../lib/provider-outbound";
25
+
26
+ /** Hard per-response bound: cloud /api/show metadata is small; anything larger is discarded. */
27
+ const SHOW_MAX_RESPONSE_BYTES = 256 * 1024;
28
+ /**
29
+ * Aggregate deadline for the ENTIRE show-enrichment phase, independent of roster size and of
30
+ * the generic discovery row limit. A stalled endpoint must never turn a successful /v1/models
31
+ * discovery into a multi-minute catalog stall.
32
+ */
33
+ const SHOW_AGGREGATE_DEADLINE_MS = 12_000;
34
+ /** Per-request timeout: a single show request never outlives this, deadline or not. */
35
+ const SHOW_REQUEST_TIMEOUT_MS = 8_000;
36
+ /**
37
+ * Show-specific request cap, independent of the generic 2000-row discovery hard limit.
38
+ * Conservative for the current Ollama Cloud roster (~19 ids) while leaving room for growth;
39
+ * ids beyond the cap simply stay on the existing safe fallback metadata.
40
+ */
41
+ const SHOW_REQUEST_CAP = 48;
42
+ /** Concurrent /api/show requests never exceed this, regardless of roster size. */
43
+ const SHOW_MAX_CONCURRENCY = 4;
44
+ /** A discovered context window must be a plausible positive integer, not arbitrary data. */
45
+ const SHOW_MAX_CONTEXT_LENGTH = 16 * 1024 * 1024;
46
+
47
+ export interface OllamaShowMetadata {
48
+ /** Trained context length reported by the model's own architecture metadata. */
49
+ contextWindow?: number;
50
+ /** Native vision capability reported by Ollama (`capabilities` includes "vision"). */
51
+ nativeVision?: boolean;
52
+ }
53
+
54
+ export interface OllamaShowEnrichmentResult {
55
+ metadata: Map<string, OllamaShowMetadata>;
56
+ /** /api/show requests issued (bounded by the request cap and the roster). */
57
+ showRequests: number;
58
+ /** True when the aggregate deadline stopped the enrichment early. */
59
+ deadlineHit: boolean;
60
+ }
61
+
62
+ export interface OllamaShowEnrichmentOptions {
63
+ /** The already-materialized captured discovery headers (credential + configured precedence). */
64
+ headers: Record<string, string>;
65
+ /** The discovery request URL actually captured for this provider (same origin is used). */
66
+ discoveryUrl: string;
67
+ modelIds: readonly string[];
68
+ /** Show-specific request cap, independent of the generic discovery row limit. */
69
+ showRequestCap?: number;
70
+ /** Aggregate wall-clock deadline for the whole enrichment phase (injectable for tests). */
71
+ deadlineMs?: number;
72
+ /** Per-request timeout (injectable for deterministic tests). */
73
+ requestTimeoutMs?: number;
74
+ /** Outbound config for the policy-checked transport (must carry the test executor). */
75
+ provider: {
76
+ baseUrl: string;
77
+ adapter?: string;
78
+ fetch?: typeof fetch;
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Scope gate: enrichment runs ONLY for the canonical Ollama Cloud destination. Custom
84
+ * ollama-native providers (self-hosted or renamed rows) and every unrelated provider are
85
+ * untouched, so `/api/show` behavior can never widen into a generic provider surface.
86
+ */
87
+ export function ollamaShowEnrichable(
88
+ providerName: string,
89
+ provider: { adapter?: string; baseUrl?: string },
90
+ ): boolean {
91
+ if (providerName !== "ollama-cloud") return false;
92
+ if (provider.adapter !== "ollama-native") return false;
93
+ const baseUrl = provider.baseUrl;
94
+ if (typeof baseUrl !== "string" || !baseUrl) return false;
95
+ return isCanonicalOllamaCloudUrl(baseUrl);
96
+ }
97
+
98
+ /**
99
+ * Extract only evidence-backed catalog metadata from an `/api/show` payload. The input is not
100
+ * mutated; templates, licenses, and tokenizer payloads exist only inside the bounded parse and
101
+ * are never projected into CatalogModel metadata.
102
+ */
103
+ export function ollamaShowMetadataFromPayload(payload: unknown): OllamaShowMetadata | undefined {
104
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) return undefined;
105
+ const raw = payload as Record<string, unknown>;
106
+ const modelInfo = raw.model_info;
107
+ let contextWindow: number | undefined;
108
+ const info = modelInfo !== null && typeof modelInfo === "object" && !Array.isArray(modelInfo)
109
+ ? modelInfo as Record<string, unknown>
110
+ : undefined;
111
+ if (info !== undefined) {
112
+ // Prefer the context length named by the model's own architecture, then fall back to a
113
+ // unique `*.context_length` key only when the architecture spelling is absent or ambiguous.
114
+ // The architecture key is FILTERED while collecting fallback candidates — the parsed input is
115
+ // never mutated.
116
+ const architecture = typeof info["general.architecture"] === "string"
117
+ ? (info["general.architecture"] as string)
118
+ : undefined;
119
+ const architectureKey = architecture !== undefined ? `${architecture}.context_length` : undefined;
120
+ if (architectureKey !== undefined) {
121
+ const value = info[architectureKey];
122
+ if (isPlausibleContextLength(value)) contextWindow = value;
123
+ }
124
+ if (contextWindow === undefined) {
125
+ const candidates = Object.entries(info)
126
+ .filter(([key, value]) =>
127
+ key.endsWith(".context_length")
128
+ && key !== architectureKey
129
+ && isPlausibleContextLength(value))
130
+ .map(([, value]) => value as number);
131
+ if (candidates.length === 1) contextWindow = candidates[0];
132
+ }
133
+ }
134
+
135
+ const capabilities = Array.isArray(raw.capabilities)
136
+ ? raw.capabilities.filter((c): c is string => typeof c === "string")
137
+ : undefined;
138
+ const nativeVision = capabilities?.includes("vision") === true;
139
+
140
+ if (contextWindow === undefined && capabilities === undefined) return undefined;
141
+ return {
142
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
143
+ ...(capabilities !== undefined ? { nativeVision } : {}),
144
+ };
145
+ }
146
+
147
+ function isPlausibleContextLength(value: unknown): value is number {
148
+ return typeof value === "number"
149
+ && Number.isSafeInteger(value)
150
+ && value > 0
151
+ && value <= SHOW_MAX_CONTEXT_LENGTH;
152
+ }
153
+
154
+ /**
155
+ * Reapply the configured provider headers LAST over an already-materialized header map,
156
+ * case-insensitively: a configured Authorization/authorization replaces the generated Bearer
157
+ * (exactly one effective credential spelling survives), matching the native /api/chat adapter's
158
+ * precedence (generated Bearer first, provider.headers last). Non-credential configured headers
159
+ * are likewise reapplied so an explicit operator spelling wins.
160
+ */
161
+ export function applyConfiguredHeadersLast(
162
+ headers: Record<string, string>,
163
+ providerHeaders: Record<string, string> | undefined,
164
+ ): Record<string, string> {
165
+ const out: Record<string, string> = { ...headers };
166
+ for (const [key, value] of Object.entries(providerHeaders ?? {})) {
167
+ const lower = key.toLowerCase();
168
+ for (const existing of Object.keys(out)) {
169
+ if (existing.toLowerCase() === lower && existing !== key) delete out[existing];
170
+ }
171
+ out[key] = value;
172
+ }
173
+ return out;
174
+ }
175
+
176
+ /**
177
+ * Show headers for the JSON POST: force Content-Type case-insensitively (the endpoint has a
178
+ * JSON body), leave every other captured header — including configured Authorization/auth
179
+ * spellings — exactly as the materialized discovery request produced them.
180
+ */
181
+ export function showHeadersFromCaptured(
182
+ capturedHeaders: Record<string, string>,
183
+ ): Record<string, string> {
184
+ const out: Record<string, string> = {};
185
+ for (const [key, value] of Object.entries(capturedHeaders)) {
186
+ if (key.toLowerCase() === "content-type") continue;
187
+ out[key] = value;
188
+ }
189
+ out["Content-Type"] = "application/json";
190
+ return out;
191
+ }
192
+
193
+ /**
194
+ * Enrich discovered Ollama Cloud ids through `POST /api/show`, executed through the same
195
+ * outbound-policy transport as discovery (`providerOutboundPost`) with the already-materialized
196
+ * captured headers — the show request never manufactures its own auth contract.
197
+ *
198
+ * Fail-soft per model: transport errors, non-2xx responses, redirects (never followed, so the
199
+ * credential can never reach another origin), oversized payloads, malformed data, and deadline
200
+ * aborts each skip that model's enrichment without affecting other rows or the success of
201
+ * discovery itself. The aggregate deadline stops new launches and aborts active work; partial
202
+ * results are returned and unenriched ids stay on the existing safe fallback metadata.
203
+ */
204
+ export async function fetchOllamaShowEnrichment(
205
+ options: OllamaShowEnrichmentOptions,
206
+ ): Promise<OllamaShowEnrichmentResult> {
207
+ const {
208
+ headers: capturedHeaders,
209
+ discoveryUrl,
210
+ modelIds,
211
+ showRequestCap = SHOW_REQUEST_CAP,
212
+ deadlineMs = SHOW_AGGREGATE_DEADLINE_MS,
213
+ requestTimeoutMs = SHOW_REQUEST_TIMEOUT_MS,
214
+ provider,
215
+ } = options;
216
+ // Late-worker isolation: workers that complete or throw after the phase returns mutate ONLY
217
+ // the internal map; the returned metadata is the EXACT snapshot the phase promise resolved
218
+ // with — never a re-snapshot of the mutable map after resolution.
219
+ const metadata = new Map<string, OllamaShowMetadata>();
220
+
221
+ // Same origin as the materialized discovery request, so /api/show can never point at another
222
+ // destination than the one the credential was already materialized for.
223
+ const showUrl = new URL("/api/show", new URL(discoveryUrl).origin).toString();
224
+ const showHeaders = showHeadersFromCaptured(capturedHeaders);
225
+
226
+ const deadlineAbort = new AbortController();
227
+ const requestSignal = () => AbortSignal.any([
228
+ AbortSignal.timeout(requestTimeoutMs),
229
+ deadlineAbort.signal,
230
+ ]);
231
+
232
+ const ids = modelIds.slice(0, Math.min(modelIds.length, showRequestCap));
233
+ let cursor = 0;
234
+ let active = 0;
235
+ let showRequests = 0;
236
+ let deadlineHit = false;
237
+ let settled = false;
238
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
239
+ let resolvePhase: ((snapshot: Map<string, OllamaShowMetadata>) => void) | undefined;
240
+
241
+ // The phase-finishing path: stops launches (settled guard), takes the metadata snapshot at
242
+ // this exact moment, and resolves the phase promise with it. The caller returns THAT resolved
243
+ // snapshot, so late worker settlement can never change the returned metadata.
244
+ const finish = (deadline: boolean): void => {
245
+ if (settled) return;
246
+ settled = true;
247
+ deadlineHit = deadline;
248
+ if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
249
+ const snapshot = new Map(metadata);
250
+ resolvePhase?.(snapshot);
251
+ };
252
+
253
+ const result = await new Promise<OllamaShowEnrichmentResult>((resolve) => {
254
+ resolvePhase = (snapshot) => resolve({
255
+ metadata: snapshot,
256
+ showRequests,
257
+ deadlineHit,
258
+ });
259
+
260
+ // The aggregate deadline TIMER ITSELF is the return bound: it prevents further launches
261
+ // (settled guard in pump), aborts active workers, and resolves the phase IMMEDIATELY. It
262
+ // never relies on a worker settling, its finally block, the per-request timeout, or another
263
+ // pump() call. Declared after finish so the callback has no TDZ reference.
264
+ deadlineTimer = setTimeout(() => {
265
+ if (settled) return;
266
+ deadlineAbort.abort(new DOMException("ollama /api/show aggregate deadline", "TimeoutError"));
267
+ finish(true);
268
+ }, deadlineMs);
269
+
270
+ const pump = () => {
271
+ if (settled) return;
272
+ while (active < SHOW_MAX_CONCURRENCY && cursor < ids.length) {
273
+ const id = ids[cursor++];
274
+ active += 1;
275
+ showRequests += 1;
276
+ void (async () => {
277
+ try {
278
+ const res = await providerOutboundPost("ollama-cloud", provider, showUrl, {
279
+ headers: showHeaders,
280
+ body: JSON.stringify({ model: id }),
281
+ signal: requestSignal(),
282
+ });
283
+ const redirectError = await providerRedirectError(res, showUrl);
284
+ // Redirect handling: never follow — the credential must never reach another origin.
285
+ // A redirected or non-2xx show response is a per-model failure, not a retry.
286
+ if (redirectError || !res.ok || ![200, 201].includes(res.status)) return;
287
+ const bounded = await readBoundedResponseBytes(res, { maxBytes: SHOW_MAX_RESPONSE_BYTES });
288
+ if (bounded.oversized) return;
289
+ let payload: unknown;
290
+ try {
291
+ payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bounded.bytes));
292
+ } catch {
293
+ return;
294
+ }
295
+ const parsed = ollamaShowMetadataFromPayload(payload);
296
+ if (parsed) metadata.set(id, parsed);
297
+ } catch {
298
+ // Fail-soft: this model simply stays unenriched. Late settlement after the phase has
299
+ // returned only touches the internal map — the caller holds the exact snapshot.
300
+ } finally {
301
+ active -= 1;
302
+ pump();
303
+ }
304
+ })();
305
+ }
306
+ if (cursor >= ids.length && active === 0) finish(false);
307
+ };
308
+ pump();
309
+ });
310
+ return result;
311
+ }
@@ -140,6 +140,9 @@ export async function resolveFirstUsableOpenAiSidecar(
140
140
  probeLeaseId: authContext.probeLeaseId,
141
141
  probeQuotaScope: authContext.probeQuotaScope,
142
142
  writerGeneration: authContext.writerGeneration,
143
+ // 401/403 here is evidence about this exact stored credential; without the generation a
144
+ // replacement inherits the quarantine (#2892 gap 4).
145
+ ...(authContext.kind === "pool" ? { credentialGeneration: authContext.generation } : {}),
143
146
  },
144
147
  ),
145
148
  };
@@ -172,6 +175,8 @@ export async function resolveFirstUsableOpenAiSidecar(
172
175
  threadId: authContext.affinityKey,
173
176
  probeLeaseId: authContext.probeLeaseId,
174
177
  writerGeneration: authContext.writerGeneration,
178
+ // Same fence as the exact-account recorder above (#2892 gap 4).
179
+ ...(authContext.kind === "pool" ? { credentialGeneration: authContext.generation } : {}),
175
180
  },
176
181
  ),
177
182
  }
@@ -0,0 +1,32 @@
1
+ import type { ProviderQuota, ProviderQuotaReport } from "./quota";
2
+
3
+ const quotaCache = new Map<string, ProviderQuota>();
4
+
5
+ export function clearCachedProviderQuotas(): void {
6
+ quotaCache.clear();
7
+ }
8
+
9
+ export function replaceCachedProviderQuotas(reports: ProviderQuotaReport[]): void {
10
+ quotaCache.clear();
11
+ for (const report of reports) {
12
+ quotaCache.set(report.provider, report.quota);
13
+ }
14
+ }
15
+
16
+ export function getCachedProviderQuota(
17
+ provider: string,
18
+ now: number,
19
+ maxAgeMs = 30 * 60_000,
20
+ ): ProviderQuota | null {
21
+ const quota = quotaCache.get(provider);
22
+ if (!quota) return null;
23
+ if (now - quota.updatedAt > maxAgeMs) return null;
24
+ return quota;
25
+ }
26
+
27
+ export function setCachedProviderQuotaForTests(
28
+ provider: string,
29
+ quota: ProviderQuota,
30
+ ): void {
31
+ quotaCache.set(provider, quota);
32
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Provider quota shapes, split out of `quota.ts` so a provider-specific quota module can
3
+ * describe its result without importing the aggregator that will consume it.
4
+ *
5
+ * `quota.ts` imports the Kiro usage module for its fetcher; if that module reached back
6
+ * into `quota.ts` for these types the two would depend on each other. Types have no
7
+ * runtime edge, but a cycle that exists only in the type graph is still a cycle, and it
8
+ * blocks any later attempt to load one side without the other.
9
+ */
10
+
11
+ export interface ProviderQuotaWindow {
12
+ label: string;
13
+ percent: number;
14
+ resetAt?: number;
15
+ }
16
+
17
+ export interface ProviderQuotaCreditsUsd {
18
+ used: number;
19
+ limit: number;
20
+ remaining: number;
21
+ percent: number;
22
+ expiresAt?: number;
23
+ unlimited?: boolean;
24
+ }
25
+
26
+ export interface ProviderQuota {
27
+ fiveHourPercent?: number;
28
+ fiveHourResetAt?: number;
29
+ weeklyPercent?: number;
30
+ weeklyResetAt?: number;
31
+ monthlyPercent?: number;
32
+ monthlyResetAt?: number;
33
+ customWindows?: ProviderQuotaWindow[];
34
+ creditsUsd?: ProviderQuotaCreditsUsd;
35
+ updatedAt: number;
36
+ }