@bitkyc08/opencodex 2.45.0 → 2.46.0-preview.20260907

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 (52) hide show
  1. package/gui/dist/assets/{index-J96sug5C.css → index-BFgUC17B.css} +1 -1
  2. package/gui/dist/assets/{index-CCfD72yq.js → index-NcAVXkST.js} +19 -19
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/raycast.svg +3 -0
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +8 -3
  7. package/src/adapters/openai-responses.ts +7 -0
  8. package/src/bridge.ts +27 -7
  9. package/src/claude/inbound.ts +23 -5
  10. package/src/claude/outbound.ts +87 -19
  11. package/src/cli/capabilities.ts +4 -1
  12. package/src/cli/dispatch.ts +1 -1
  13. package/src/cli/doctor.ts +2 -2
  14. package/src/cli/export-command.ts +22 -10
  15. package/src/cli/help.ts +1 -1
  16. package/src/cli/index.ts +34 -7
  17. package/src/cli/integrations.ts +34 -1
  18. package/src/cli/provider.ts +27 -15
  19. package/src/cli/registry.ts +2 -2
  20. package/src/cli/version-skew.ts +36 -3
  21. package/src/clients/aside-profiles.ts +8 -7
  22. package/src/clients/config-export/contracts.ts +2 -1
  23. package/src/clients/config-export/raycast.ts +106 -0
  24. package/src/clients/config-export.ts +36 -0
  25. package/src/clients/model-presentation.ts +61 -0
  26. package/src/codex/catalog/sync.ts +44 -2
  27. package/src/codex/convergence.ts +7 -0
  28. package/src/generated/compatibility-version.json +59 -43
  29. package/src/generated/model-metadata.ts +1 -0
  30. package/src/images/loop.ts +10 -2
  31. package/src/integrations/catalog-refresh.ts +1 -1
  32. package/src/integrations/merge.ts +158 -25
  33. package/src/integrations/raycast-detect.ts +111 -0
  34. package/src/integrations/registry.ts +18 -0
  35. package/src/integrations/state.ts +82 -13
  36. package/src/integrations/writer.ts +46 -31
  37. package/src/lib/bounded-body.ts +22 -7
  38. package/src/oauth/anthropic-routing.ts +59 -14
  39. package/src/oauth/health.ts +3 -0
  40. package/src/providers/quota.ts +145 -9
  41. package/src/providers/registry.ts +31 -0
  42. package/src/responses/parser.ts +16 -3
  43. package/src/responses/reasoning-envelope.ts +3 -2
  44. package/src/server/grok-responses-control-frame.ts +43 -0
  45. package/src/server/management/config-routes.ts +6 -2
  46. package/src/server/management/integration-routes.ts +29 -2
  47. package/src/server/management/model-routes.ts +9 -1
  48. package/src/server/request-decompress.ts +34 -8
  49. package/src/server/responses/agent-task-recovery-cache.ts +43 -14
  50. package/src/server/responses/agent-task-recovery.ts +28 -16
  51. package/src/server/responses/core.ts +39 -6
  52. package/src/web-search/loop.ts +10 -2
@@ -1552,6 +1552,59 @@ type AccountQuotaCacheEntry = {
1552
1552
  identity?: string;
1553
1553
  isCurrent?: () => boolean;
1554
1554
  };
1555
+ /** Expired measurements become unknown; missing reset evidence never implies a fresh allowance. */
1556
+ function normalizeAnthropicQuota(quota: ProviderQuota | null | undefined, now: number): ProviderQuota | null {
1557
+ if (!quota) return null;
1558
+ const validReset = (resetAt: unknown): resetAt is number => typeof resetAt === "number"
1559
+ && Number.isFinite(resetAt) && resetAt > 0 && Number.isFinite(new Date(resetAt).getTime());
1560
+ let result = quota;
1561
+ for (const [percent, reset] of [
1562
+ ["fiveHourPercent", "fiveHourResetAt"],
1563
+ ["weeklyPercent", "weeklyResetAt"],
1564
+ ["monthlyPercent", "monthlyResetAt"],
1565
+ ] as const) {
1566
+ const resetAt = quota[reset];
1567
+ if (resetAt === undefined) continue;
1568
+ const valid = validReset(resetAt);
1569
+ if (valid && resetAt > now) continue;
1570
+ if (result === quota) result = { ...quota };
1571
+ if (valid) delete result[percent];
1572
+ delete result[reset];
1573
+ }
1574
+ // Persisted rows validate only the outer quota object, so custom data may be malformed.
1575
+ if (quota.customWindows !== undefined) {
1576
+ const windows = Array.isArray(quota.customWindows) ? quota.customWindows : [];
1577
+ const retained: ProviderQuotaWindow[] = [];
1578
+ let changed = !Array.isArray(quota.customWindows);
1579
+ for (const window of windows) {
1580
+ if (!window || typeof window !== "object" || typeof window.label !== "string" || !window.label.trim()
1581
+ || typeof window.percent !== "number" || !Number.isFinite(window.percent)
1582
+ || window.percent < 0 || window.percent > 100) {
1583
+ changed = true;
1584
+ continue;
1585
+ }
1586
+ if (validReset(window.resetAt) && window.resetAt <= now) {
1587
+ changed = true;
1588
+ continue;
1589
+ }
1590
+ if (window.resetAt !== undefined && !validReset(window.resetAt)) {
1591
+ const normalized = { ...window };
1592
+ delete normalized.resetAt;
1593
+ retained.push(normalized);
1594
+ changed = true;
1595
+ } else {
1596
+ retained.push(window);
1597
+ }
1598
+ }
1599
+ if (changed) {
1600
+ if (result === quota) result = { ...quota };
1601
+ if (retained.length) result.customWindows = retained;
1602
+ else delete result.customWindows;
1603
+ }
1604
+ }
1605
+ return hasQuotaRows(result) ? result : null;
1606
+ }
1607
+
1555
1608
  const accountQuotaCache = new Map<string, AccountQuotaCacheEntry>();
1556
1609
  let explicitAccountEpoch = 0;
1557
1610
 
@@ -1568,14 +1621,23 @@ function hydrateAccountQuotaCache(): void {
1568
1621
  if (diskHydrated) return;
1569
1622
  diskHydrated = true;
1570
1623
  for (const [key, quota] of readPersistedAccountQuotas()) {
1571
- if (!accountQuotaCache.has(key)) accountQuotaCache.set(key, { ts: quota.updatedAt, quota });
1624
+ // Disk stores observation time, not the Anthropic usage probe's clock.
1625
+ if (!accountQuotaCache.has(key)) {
1626
+ const anthropic = key.startsWith("anthropic\u0000");
1627
+ accountQuotaCache.set(key, {
1628
+ ts: anthropic ? 0 : quota.updatedAt,
1629
+ quota: anthropic ? normalizeAnthropicQuota(quota, Date.now()) : quota,
1630
+ });
1631
+ }
1572
1632
  }
1573
1633
  }
1574
1634
 
1575
1635
  function persistAccountQuotaCache(): void {
1576
1636
  schedulePersistAccountQuotas(function* () {
1637
+ const now = Date.now();
1577
1638
  for (const [key, entry] of accountQuotaCache) {
1578
- if (entry.quota) yield [key, entry.quota] as [string, ProviderQuota];
1639
+ const quota = key.startsWith("anthropic\u0000") ? normalizeAnthropicQuota(entry.quota, now) : entry.quota;
1640
+ if (quota) yield [key, quota] as [string, ProviderQuota];
1579
1641
  }
1580
1642
  });
1581
1643
  }
@@ -1625,7 +1687,7 @@ function accountCacheKey(provider: string, accountId: string): string {
1625
1687
  export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null {
1626
1688
  const entry = accountQuotaCache.get(accountCacheKey(provider, accountId));
1627
1689
  if (entry?.isCurrent && !entry.isCurrent()) return null;
1628
- return entry?.quota ?? null;
1690
+ return provider === "anthropic" ? normalizeAnthropicQuota(entry?.quota, Date.now()) : entry?.quota ?? null;
1629
1691
  }
1630
1692
 
1631
1693
  /** Test-only: seed or clear the per-account quota cache without probing upstream. */
@@ -1642,6 +1704,68 @@ export function setCachedProviderAccountQuotaForTests(
1642
1704
  accountQuotaCache.set(key, { ts: Date.now(), quota });
1643
1705
  }
1644
1706
 
1707
+ /** Unified headers report utilization fractions and epoch-second reset times. */
1708
+ function anthropicHeaderResetAt(value: string | null): number | undefined {
1709
+ const seconds = toFiniteNumber(value);
1710
+ if (seconds === undefined || seconds <= 0) return undefined;
1711
+ const timestamp = seconds * 1000;
1712
+ return Number.isFinite(new Date(timestamp).getTime()) ? timestamp : undefined;
1713
+ }
1714
+
1715
+ export function parseAnthropicRateLimitHeaders(headers: Headers): ProviderQuota | null {
1716
+ const fiveHourPercent = normalizeUtilizationFraction(headers.get("anthropic-ratelimit-unified-5h-utilization"));
1717
+ const weeklyPercent = normalizeUtilizationFraction(headers.get("anthropic-ratelimit-unified-7d-utilization"));
1718
+ if (fiveHourPercent === undefined && weeklyPercent === undefined) return null;
1719
+ const fiveHourResetAt = anthropicHeaderResetAt(headers.get("anthropic-ratelimit-unified-5h-reset"));
1720
+ const weeklyResetAt = anthropicHeaderResetAt(headers.get("anthropic-ratelimit-unified-7d-reset"));
1721
+ return {
1722
+ ...(fiveHourPercent !== undefined ? { fiveHourPercent } : {}),
1723
+ ...(fiveHourPercent !== undefined && fiveHourResetAt !== undefined ? { fiveHourResetAt } : {}),
1724
+ ...(weeklyPercent !== undefined ? { weeklyPercent } : {}),
1725
+ ...(weeklyPercent !== undefined && weeklyResetAt !== undefined ? { weeklyResetAt } : {}),
1726
+ updatedAt: Date.now(),
1727
+ };
1728
+ }
1729
+
1730
+ /** Reject unknown scales; round fraction conversion for persisted/displayed percentages. */
1731
+ function normalizeUtilizationFraction(value: string | null): number | undefined {
1732
+ const numeric = toFiniteNumber(value);
1733
+ if (numeric === undefined || numeric < 0 || numeric > 1) return undefined;
1734
+ return Math.round(numeric * 10_000) / 100;
1735
+ }
1736
+
1737
+ /**
1738
+ * Merge serving-account observations without advancing the usage probe's clock or
1739
+ * erasing model-specific windows. The caller owns credential attribution; this guard
1740
+ * prevents a retired account key from being revived by an older config generation.
1741
+ */
1742
+ export function recordAnthropicAccountQuotaFromHeaders(
1743
+ accountId: string,
1744
+ headers: Headers,
1745
+ writerGeneration: number,
1746
+ ): void {
1747
+ if (!accountId) return;
1748
+ const observed = parseAnthropicRateLimitHeaders(headers);
1749
+ if (!observed) return;
1750
+ const key = accountCacheKey("anthropic", accountId);
1751
+ if (!mayCommitAccountQuotaKey(key, writerGeneration)) return;
1752
+ // Hydrate before writing, for the same reason `recordPassiveAccountQuota` does: this write
1753
+ // arrives unprompted from the request path, and `persistAccountQuotaCache` serializes the
1754
+ // whole map. Landing before any reader has hydrated would persist this single row and erase
1755
+ // every other provider's saved row.
1756
+ hydrateAccountQuotaCache();
1757
+ const previous = accountQuotaCache.get(key);
1758
+ accountQuotaCache.set(key, {
1759
+ ...previous,
1760
+ // Headers do not prove that the last usage probe succeeded.
1761
+ ts: previous?.ts ?? 0,
1762
+ quota: normalizeAnthropicQuota({
1763
+ ...normalizeAnthropicQuota(previous?.quota, observed.updatedAt), ...observed,
1764
+ }, observed.updatedAt),
1765
+ });
1766
+ persistAccountQuotaCache();
1767
+ }
1768
+
1645
1769
  /**
1646
1770
  * Providers whose per-account quota is OBSERVED in-band, never probed.
1647
1771
  *
@@ -1714,7 +1838,11 @@ export function readPassiveProviderAccountQuotas(provider: string): ProviderAcco
1714
1838
  export function sweepExpiredProviderAccountQuotaRows(now = Date.now()): number {
1715
1839
  let removed = 0;
1716
1840
  for (const [key, entry] of accountQuotaCache) {
1717
- if (entry.ts + ACCOUNT_QUOTA_TTL_MS > now) continue;
1841
+ // Anthropic observations extend retention, never the usage probe's eligibility clock.
1842
+ const retainedAt = key.startsWith("anthropic\u0000")
1843
+ ? Math.max(entry.ts, entry.quota?.updatedAt ?? 0)
1844
+ : entry.ts;
1845
+ if (retainedAt + ACCOUNT_QUOTA_TTL_MS > now) continue;
1718
1846
  accountQuotaCache.delete(key);
1719
1847
  removed += 1;
1720
1848
  }
@@ -1907,10 +2035,13 @@ async function fetchAccountQuota(
1907
2035
  ): Promise<AccountQuotaCacheEntry> {
1908
2036
  if (!supportsPerAccountQuota(provider)) return { ts: Date.now(), quota: null, unavailable: true };
1909
2037
  if (explicitAccountReader(provider)) return fetchExplicitAccountQuota(provider, accountId, forceRefresh, providerConfig);
2038
+ if (provider === "anthropic") hydrateAccountQuotaCache();
1910
2039
  const key = accountCacheKey(provider, accountId);
1911
2040
  const writerGeneration = captureConfigGeneration();
1912
2041
  const cached = accountQuotaCache.get(key);
1913
- if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) return cached;
2042
+ if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) {
2043
+ return provider === "anthropic" ? { ...cached, quota: normalizeAnthropicQuota(cached.quota, Date.now()) } : cached;
2044
+ }
1914
2045
  const joinable = accountQuotaInflight.get(key);
1915
2046
  if (joinable) return joinable;
1916
2047
 
@@ -1947,7 +2078,9 @@ async function fetchAccountQuota(
1947
2078
  // negative-cache instead of re-probing on every GUI poll.
1948
2079
  const entry: AccountQuotaCacheEntry = {
1949
2080
  ts: Date.now(),
1950
- quota: cached?.quota ?? null,
2081
+ // Settle once for all joiners against observations committed during the probe.
2082
+ quota: provider === "anthropic"
2083
+ ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null,
1951
2084
  unavailable: true,
1952
2085
  };
1953
2086
  if (mayCommitAccountQuotaKey(key, writerGeneration)) {
@@ -1957,7 +2090,9 @@ async function fetchAccountQuota(
1957
2090
  }
1958
2091
  return entry;
1959
2092
  }
1960
- const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota };
2093
+ const entry: AccountQuotaCacheEntry = {
2094
+ ts: Date.now(), quota: provider === "anthropic" ? normalizeAnthropicQuota(quota, Date.now()) : quota,
2095
+ };
1961
2096
  if (mayCommitAccountQuotaKey(key, writerGeneration)) {
1962
2097
  accountQuotaCache.set(key, entry);
1963
2098
  // Exhaustion state rides the SAME commit guard as the quota row: a probe from a
@@ -1969,7 +2104,8 @@ async function fetchAccountQuota(
1969
2104
  } catch {
1970
2105
  const entry: AccountQuotaCacheEntry = {
1971
2106
  ts: Date.now(),
1972
- quota: cached?.quota ?? null,
2107
+ quota: provider === "anthropic"
2108
+ ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null,
1973
2109
  unavailable: true,
1974
2110
  };
1975
2111
  if (mayCommitAccountQuotaKey(key, writerGeneration)) {
@@ -2001,7 +2137,7 @@ export async function fetchProviderAccountQuotas(
2001
2137
  const entry = await fetchAccountQuota(provider, account.id, forceRefresh, providerConfig);
2002
2138
  const result: ProviderAccountQuota = {
2003
2139
  accountId: account.id,
2004
- quota: entry.quota,
2140
+ quota: provider === "anthropic" ? normalizeAnthropicQuota(entry.quota, Date.now()) : entry.quota,
2005
2141
  ...(entry.unavailable ? { unavailable: true as const } : {}),
2006
2142
  };
2007
2143
  if (!explicitAccountReader(provider)) return result;
@@ -2544,6 +2544,37 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2544
2544
  // yields an empty picker at runtime.
2545
2545
  note: "Domestic BigModel Coding Plan endpoint (open.bigmodel.cn)",
2546
2546
  },
2547
+ // Narrowed carry of #3641: the official Codex example declares a local static catalog,
2548
+ // not an HTTP /models contract. Keep Responses separate from the Chat endpoint above.
2549
+ // Source: https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md (checked 2026-09-07).
2550
+ {
2551
+ id: "zhipu-bigmodel-responses",
2552
+ label: "Zhipu AI — BigModel Coding Plan (Responses)",
2553
+ baseUrl: "https://open.bigmodel.cn/api/v1",
2554
+ adapter: "openai-responses",
2555
+ authKind: "key",
2556
+ dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys",
2557
+ defaultModel: "glm-5.3",
2558
+ models: ["glm-5.3", "glm-5-turbo"],
2559
+ liveModels: false,
2560
+ // The local Codex catalog does not establish an authenticated HTTP /models contract.
2561
+ apiKeyValidation: "unknown",
2562
+ jawcodeBundle: "zai",
2563
+ // A pre-existing same-named custom provider must retain its destination and key boundary.
2564
+ preserveCustomDestination: true,
2565
+ modelContextWindows: { "glm-5.3": 1_048_576, "glm-5-turbo": 204_800 },
2566
+ modelInputModalities: { "glm-5.3": ["text"], "glm-5-turbo": ["text"] },
2567
+ modelReasoningEfforts: {
2568
+ "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS,
2569
+ // Explicitly empty: Turbo must not inherit the generic selectable effort ladder.
2570
+ "glm-5-turbo": [],
2571
+ },
2572
+ modelDefaultReasoningEfforts: { "glm-5.3": "max", "glm-5-turbo": "max" },
2573
+ modelSupportsReasoningSummaries: { "glm-5.3": true, "glm-5-turbo": true },
2574
+ // Responses replay uses this provider-level flag, not the Chat-path model list.
2575
+ preserveResponsesReasoningContent: true,
2576
+ note: "Domestic BigModel Coding Plan Responses endpoint; static model roster",
2577
+ },
2547
2578
  { id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api" },
2548
2579
  { id: "synthetic", label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://synthetic.new" },
2549
2580
  // SiliconFlow publishes an OpenAI-compatible chat endpoint and a dynamic model catalog. Do not
@@ -126,6 +126,12 @@ export function parseRequest(
126
126
  }
127
127
  return holder;
128
128
  };
129
+ const preservePendingReplay = () => {
130
+ const replay = pendingReasoning.filter(entry => entry.envelopeSigned || entry.part.redacted?.length);
131
+ if (replay.length > 0) {
132
+ ensureAssistantPlaceholder(messages, data.model, now).content.push(...replay.map(entry => entry.part));
133
+ }
134
+ };
129
135
  // Tool specs surfaced by a prior tool_search (deferred tools, e.g. subagents). Codex does not
130
136
  // re-list these in `tools`, but chat models can only call listed tools — so we re-inject them.
131
137
  const loadedToolSpecs: unknown[] = [];
@@ -148,6 +154,12 @@ export function parseRequest(
148
154
  const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined);
149
155
  const itemRole = (item as { role?: string }).role;
150
156
  const externalTaskInput = effectiveType === "function_call_output" ? externalTaskInputContent(item) : undefined;
157
+ // A signed/opaque assistant-only turn still owns its replay blocks, even
158
+ // without a following assistant text or tool call to drain the pending list.
159
+ if (effectiveType === "agent_message" || externalTaskInput !== undefined
160
+ || (effectiveType === "message" && ["user", "developer", "system"].includes(itemRole ?? ""))) {
161
+ preservePendingReplay();
162
+ }
151
163
  // Raw protocol items do not map one-to-one onto context messages. Capture the boundary while
152
164
  // both representations are available so later metadata can stay before conversation in both.
153
165
  if (
@@ -269,7 +281,7 @@ export function parseRequest(
269
281
  const envelope = typeof reasoning.encrypted_content === "string"
270
282
  ? decodeReasoningEnvelope(reasoning.encrypted_content)
271
283
  : null;
272
- const thinkingText = envelope?.txt || text;
284
+ const thinkingText = envelope?.txt ?? text;
273
285
 
274
286
  // Kiro reasoning round-trip: a krc-only item carries nothing renderable — it is provider
275
287
  // state for the assistant turn that ALREADY closed, because Kiro emits its
@@ -285,7 +297,7 @@ export function parseRequest(
285
297
 
286
298
  // Native/non-ocxr1 encrypted-only reasoning is opaque here. Do not create a detached
287
299
  // assistant turn or invent replayable plaintext/signatures from the encrypted payload.
288
- if (thinkingText.length > 0) {
300
+ if (thinkingText.length > 0 || envelope?.sig || envelope?.red?.length) {
289
301
  const part: OcxThinkingContent = {
290
302
  type: "thinking",
291
303
  thinking: thinkingText,
@@ -296,7 +308,7 @@ export function parseRequest(
296
308
  const envelopeSigned = typeof envelope?.sig === "string";
297
309
  const previous = pendingReasoning[pendingReasoning.length - 1];
298
310
 
299
- if (!envelopeSigned && previous && !previous.envelopeSigned) {
311
+ if (!envelopeSigned && !part.redacted && previous && !previous.envelopeSigned && !previous.part.redacted) {
300
312
  previous.part = {
301
313
  ...part,
302
314
  thinking: `${previous.part.thinking}\n${part.thinking}`,
@@ -466,6 +478,7 @@ export function parseRequest(
466
478
  }
467
479
  }
468
480
  }
481
+ preservePendingReplay();
469
482
  if (data.previous_response_id && continuationConversationMessageIndex === undefined) {
470
483
  continuationConversationMessageIndex = messages.length;
471
484
  }
@@ -50,10 +50,11 @@ export function decodeReasoningEnvelope(encryptedContent: string): ReasoningEnve
50
50
  if (red.length > 0) envelope.red = red;
51
51
  }
52
52
  const txt = (parsed as { txt?: unknown }).txt;
53
- if (typeof txt === "string" && txt.length > 0) envelope.txt = txt;
53
+ const hasTxt = typeof txt === "string";
54
+ if (hasTxt) envelope.txt = txt;
54
55
  const krc = (parsed as { krc?: unknown }).krc;
55
56
  if (typeof krc === "string" && krc.length > 0) envelope.krc = krc;
56
- return envelope.sig || envelope.red || envelope.txt || envelope.krc ? envelope : null;
57
+ return envelope.sig || envelope.red || hasTxt || envelope.krc ? envelope : null;
57
58
  } catch {
58
59
  return null;
59
60
  }
@@ -0,0 +1,43 @@
1
+ import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
2
+
3
+ const GROK_CONTROL_FRAME_TYPES: Record<string, true> = {
4
+ "codex.rate_limits": true,
5
+ "codex.response.metadata": true,
6
+ };
7
+
8
+ /**
9
+ * Hide Codex-only control frames from Grok's strict Responses decoder.
10
+ *
11
+ * The inspection branch still sees these frames before this client-facing
12
+ * rewrite, so quota accounting and response metadata remain available to the
13
+ * proxy while Grok receives only its declared Responses event variants.
14
+ */
15
+ export function createGrokResponsesControlFrameBlockRewrite(): SseBlockRewrite {
16
+ return (block) => {
17
+ let eventName = "";
18
+ // SSE overwrites the event type on every event field, including empty resets.
19
+ // Like sseDataPayload, remove only one optional ASCII space after the colon.
20
+ for (const line of block.split(/\r?\n/)) {
21
+ if (line === "event") eventName = "";
22
+ else if (line.startsWith("event:")) {
23
+ const value = line.slice("event:".length);
24
+ eventName = value.startsWith(" ") ? value.slice(1) : value;
25
+ }
26
+ }
27
+ if (GROK_CONTROL_FRAME_TYPES[eventName] === true) return [];
28
+
29
+ const payload = sseDataPayload(block);
30
+ if (payload === null || payload === "[DONE]") return [block];
31
+
32
+ let event: unknown;
33
+ try {
34
+ event = JSON.parse(payload);
35
+ } catch {
36
+ return [block];
37
+ }
38
+ if (!event || typeof event !== "object" || Array.isArray(event) || !("type" in event)) return [block];
39
+ return typeof event.type === "string" && GROK_CONTROL_FRAME_TYPES[event.type] === true
40
+ ? []
41
+ : [block];
42
+ };
43
+ }
@@ -161,7 +161,7 @@ interface ClientIntegrationSyncOutcome {
161
161
  }
162
162
 
163
163
  /**
164
- * Re-inject native clients that are switched ON and file integrations whose
164
+ * Re-inject native clients that are switched ON and every file integration whose
165
165
  * OpenCodex ownership record is the operator's durable opt-in.
166
166
  *
167
167
  * Only Codex used to run here, so a catalog change reached Codex and nothing else: a Grok
@@ -169,6 +169,10 @@ interface ClientIntegrationSyncOutcome {
169
169
  * next `ocx start`. The startup path already gates each client on its own toggle
170
170
  * (`src/cli/index.ts`), and this is that same fan-out for the on-demand command.
171
171
  *
172
+ * File integrations use the catalog-refresh coordinator so owned blocks are
173
+ * updated without claiming unowned files. Aside remains on its multi-profile
174
+ * server-owned path inside that coordinator.
175
+ *
172
176
  * A client that is OFF or never connected is omitted from the result rather than reported as skipped — the
173
177
  * caller has to be able to tell "not touched" from "tried and failed". A client that fails
174
178
  * does not fail the sync: Codex is the one that matters for routing, and a broken Grok file
@@ -233,7 +237,7 @@ export async function syncEnabledClientIntegrations(
233
237
  },
234
238
  config,
235
239
  port,
236
- }, ["mcode", "pi", "aside"]));
240
+ }, ["mcode", "pi", "aside", "raycast"]));
237
241
 
238
242
  return out;
239
243
  }
@@ -22,6 +22,7 @@ import {
22
22
  isIntegrationClientId,
23
23
  type IntegrationClientId,
24
24
  } from "../../integrations/registry";
25
+ import { detectRaycast, type RaycastInstall } from "../../integrations/raycast-detect";
25
26
  import { readIntegrationState } from "../../integrations/state";
26
27
  import { createIntegrationStateStore, type IntegrationStateStore } from "../../integrations/store";
27
28
  import {
@@ -58,6 +59,13 @@ type RestoreResult = Awaited<ReturnType<typeof restoreIntegrationCoordinated>>;
58
59
 
59
60
  export type IntegrationStateEnvelope = {
60
61
  clientId: IntegrationClientId;
62
+ /**
63
+ * Raycast only, and only on the single-client read. Custom Providers is a
64
+ * Pro feature, so a file that is `current` can still be one Raycast ignores;
65
+ * this is the fact that lets status and the GUI say so. It is not part of
66
+ * the shared `IntegrationStatus`, which describes the file, not the app.
67
+ */
68
+ raycast?: RaycastInstall;
61
69
  } & IntegrationStateRecord;
62
70
 
63
71
  export interface IntegrationStateListEnvelope {
@@ -141,6 +149,17 @@ export function setIntegrationPathTestHooks(hooks: { env?: NodeJS.ProcessEnv; ho
141
149
  integrationPathTestHooks = hooks;
142
150
  }
143
151
 
152
+ /**
153
+ * Raycast detection override for tests. The real detector spawns `defaults` and
154
+ * reads the developer's own subscription state, which is exactly the kind of
155
+ * host fact a route test must not depend on.
156
+ */
157
+ let raycastDetectTestHook: (() => RaycastInstall) | null = null;
158
+
159
+ export function setRaycastDetectTestHook(hook: (() => RaycastInstall) | null): void {
160
+ raycastDetectTestHook = hook;
161
+ }
162
+
144
163
  /** The `env`/`home` overrides, spread into every registry-resolving call. */
145
164
  function pathOverrides(): { env?: NodeJS.ProcessEnv; home?: string } {
146
165
  return {
@@ -177,7 +196,10 @@ export function setIntegrationMutationFlightTestHooks(
177
196
  setIntegrationMutationFlightTestHook(hooks?.run ?? null);
178
197
  // Path overrides are part of the same isolation contract: clearing flights
179
198
  // while leaving a temp home bound would let the next suite write real files.
180
- if (hooks === null) integrationPathTestHooks = null;
199
+ if (hooks === null) {
200
+ integrationPathTestHooks = null;
201
+ raycastDetectTestHook = null;
202
+ }
181
203
  }
182
204
 
183
205
  /**
@@ -633,7 +655,12 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise<R
633
655
  try {
634
656
  const input = await buildIntegrationWriteInput(requestedClient, ctx, integrationStore());
635
657
  const state = readIntegrationState(input);
636
- return jsonResponse(state satisfies IntegrationStateEnvelope, 200, req, ctx.config);
658
+ // Detection runs only for the client that needs it: `defaults` is a
659
+ // process spawn, and no other client's read should pay for it.
660
+ const envelope: IntegrationStateEnvelope = requestedClient === "raycast"
661
+ ? { ...state, raycast: (raycastDetectTestHook ?? detectRaycast)() }
662
+ : state;
663
+ return jsonResponse(envelope, 200, req, ctx.config);
637
664
  } catch (error) {
638
665
  return internalErrorResponse(error, ctx);
639
666
  }
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
+ import { shouldInjectApiAuthHeader } from "../../codex/loopback-target";
3
4
 
4
5
  /**
5
6
  * Codex parses a catalog entry's `input_modalities` as a closed enum, and one out-of-enum
@@ -480,6 +481,13 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
480
481
  if (!(error instanceof ClientPathError)) throw error;
481
482
  return jsonResponse({ error: error.message }, 400, req, config);
482
483
  }
484
+ if (requested === "raycast" && shouldInjectApiAuthHeader(config)) {
485
+ return jsonResponse({
486
+ error: "Raycast export requires an unauthenticated loopback destination; this listener requires an admission header Raycast cannot supply.",
487
+ reason: "non_loopback",
488
+ }, 400, req, config);
489
+ }
490
+ const baseUrl = opencodeProxyBaseUrl(Number(url.port) || config.port, config.hostname, config);
483
491
  let models: ExportModel[];
484
492
  try {
485
493
  // The ONE loader every export surface uses. It carries the visibility
@@ -499,7 +507,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
499
507
  );
500
508
  }
501
509
  const built = buildClientConfigText(requested, {
502
- baseUrl: opencodeProxyBaseUrl(Number(url.port) || config.port, config.hostname),
510
+ baseUrl,
503
511
  models,
504
512
  config,
505
513
  });
@@ -27,14 +27,39 @@ export class UnsupportedContentEncodingError extends Error {
27
27
  }
28
28
  }
29
29
 
30
+ export type BodySizeMeasurement =
31
+ | "declared_wire"
32
+ | "observed_wire_lower_bound"
33
+ | "decoded_exact"
34
+ | "decoded_lower_bound";
35
+
30
36
  export class DecompressedBodyTooLargeError extends Error {
31
- constructor(readonly bytes: number, limit: number = MAX_DECOMPRESSED_BODY_BYTES) {
32
- super(`Decompressed request body exceeds ${limit} bytes`);
37
+ readonly measurement: BodySizeMeasurement | null;
38
+
39
+ constructor(
40
+ readonly bytes: number,
41
+ readonly limit: number = MAX_DECOMPRESSED_BODY_BYTES,
42
+ measurement: BodySizeMeasurement | null = null,
43
+ ) {
44
+ // Legacy callers supply no provenance. Only fixed categories and finite
45
+ // numbers may reach the public message, including calls from untyped code.
46
+ const category = measurement === "declared_wire" || measurement === "observed_wire_lower_bound"
47
+ || measurement === "decoded_exact" || measurement === "decoded_lower_bound"
48
+ ? measurement : null;
49
+ const suffix = category !== null && Number.isFinite(bytes) && bytes >= 0
50
+ && Number.isFinite(limit) && limit >= 0
51
+ ? ` [measurement=${category}; bytes=${bytes}]` : "";
52
+ super(`Decompressed request body exceeds ${Number.isFinite(limit) ? limit : "unknown"} bytes${suffix}`);
53
+ this.measurement = category;
33
54
  }
34
55
  }
35
56
 
36
- function assertBodySizeWithinLimit(body: Uint8Array, maxBytes: number): Uint8Array {
37
- if (body.byteLength > maxBytes) throw new DecompressedBodyTooLargeError(body.byteLength, maxBytes);
57
+ function assertBodySizeWithinLimit(
58
+ body: Uint8Array,
59
+ maxBytes: number,
60
+ measurement: BodySizeMeasurement = "decoded_exact",
61
+ ): Uint8Array {
62
+ if (body.byteLength > maxBytes) throw new DecompressedBodyTooLargeError(body.byteLength, maxBytes, measurement);
38
63
  return body;
39
64
  }
40
65
 
@@ -112,7 +137,7 @@ async function readRequestBodyBytesCapped(
112
137
  if (!value || value.byteLength === 0) continue;
113
138
 
114
139
  if (value.byteLength > maxBytes - retainedBytes) {
115
- const error = new DecompressedBodyTooLargeError(retainedBytes + value.byteLength, maxBytes);
140
+ const error = new DecompressedBodyTooLargeError(retainedBytes + value.byteLength, maxBytes, "observed_wire_lower_bound");
116
141
  cancel(error);
117
142
  throw error;
118
143
  }
@@ -173,7 +198,8 @@ export function decodeRequestBody(
173
198
  else throw new UnsupportedContentEncodingError(encoding);
174
199
  } catch (err) {
175
200
  if ((err as NodeJS.ErrnoException | null)?.code === "ERR_BUFFER_TOO_LARGE") {
176
- throw new DecompressedBodyTooLargeError(maxBytes + 1, maxBytes);
201
+ // Inflation stopped at the cap; the full decoded size was never measured.
202
+ throw new DecompressedBodyTooLargeError(maxBytes + 1, maxBytes, "decoded_lower_bound");
177
203
  }
178
204
  throw err;
179
205
  }
@@ -198,7 +224,7 @@ export async function readBoundedJsonRequestBody(
198
224
  // Reject an honest oversized declaration before reading. Missing, malformed,
199
225
  // and dishonest declarations remain bounded by the streaming reader below.
200
226
  if (declaredLength !== null && declaredLength > maxBytes) {
201
- const error = new DecompressedBodyTooLargeError(declaredLength, maxBytes);
227
+ const error = new DecompressedBodyTooLargeError(declaredLength, maxBytes, "declared_wire");
202
228
  cancelStreamWithoutWaiting(req.body, error);
203
229
  throw error;
204
230
  }
@@ -211,7 +237,7 @@ export async function readBoundedJsonRequestBody(
211
237
  } finally {
212
238
  releaseReservation?.();
213
239
  }
214
- assertBodySizeWithinLimit(raw, maxBytes);
240
+ assertBodySizeWithinLimit(raw, maxBytes, "observed_wire_lower_bound");
215
241
  const releaseRaw = budget?.observeAcceptedRequestCopy(raw.byteLength);
216
242
  let releaseDecoded: (() => void) | undefined;
217
243
  let releaseText: (() => void) | undefined;