@bitkyc08/opencodex 2.20.0 → 2.22.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 (64) hide show
  1. package/AGENTS_INSTALL.md +32 -0
  2. package/README.md +1 -1
  3. package/gui/dist/assets/{index-DSK3S5HY.js → index-ClEcVlFO.js} +43 -17
  4. package/gui/dist/assets/index-DQsMZzI5.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic.ts +2 -28
  8. package/src/adapters/google.ts +31 -3
  9. package/src/adapters/openai-chat.ts +25 -8
  10. package/src/adapters/responses-tool-schema.ts +67 -0
  11. package/src/bridge.ts +15 -2
  12. package/src/claude/agents-inject.ts +2 -2
  13. package/src/claude/gateway-cache.ts +41 -4
  14. package/src/cli/claude.ts +1 -1
  15. package/src/cli/codex-log-guard-doctor.ts +103 -0
  16. package/src/cli/dispatch.ts +7 -1
  17. package/src/cli/help.ts +1 -1
  18. package/src/cli/models.ts +16 -6
  19. package/src/cli/observe.ts +38 -2
  20. package/src/cli/registry.ts +2 -1
  21. package/src/cli/v2.ts +34 -1
  22. package/src/codex/app-server-processes.ts +46 -26
  23. package/src/codex/catalog/effort.ts +49 -1
  24. package/src/codex/catalog/parsing.ts +64 -4
  25. package/src/codex/catalog/provider-fetch.ts +12 -0
  26. package/src/codex/catalog/sync.ts +14 -1
  27. package/src/codex/convergence.ts +2 -0
  28. package/src/codex/inject.ts +3 -3
  29. package/src/codex/log-guard/inspect.ts +506 -0
  30. package/src/codex/log-guard/lock.ts +150 -0
  31. package/src/codex/log-guard/maintenance.ts +403 -0
  32. package/src/codex/log-guard/path-safety.ts +39 -0
  33. package/src/codex/log-guard/policy.ts +44 -0
  34. package/src/codex/log-guard/processes.ts +205 -0
  35. package/src/codex/log-guard/protection.ts +489 -0
  36. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  37. package/src/codex/paths.ts +5 -0
  38. package/src/codex/plugins-doctor.ts +1 -1
  39. package/src/codex/project-config-warnings.ts +2 -2
  40. package/src/generated/compatibility-version.json +93 -45
  41. package/src/images/loop.ts +15 -5
  42. package/src/providers/antigravity-models.ts +11 -1
  43. package/src/providers/model-discovery.ts +94 -6
  44. package/src/providers/quota.ts +159 -0
  45. package/src/providers/registry.ts +20 -1
  46. package/src/providers/slug-codec.ts +29 -0
  47. package/src/responses/custom-tool-compat.ts +4 -1
  48. package/src/responses/parser.ts +7 -1
  49. package/src/responses/provider-opaque-metadata.ts +73 -0
  50. package/src/responses/schema.ts +6 -0
  51. package/src/router.ts +12 -4
  52. package/src/routing/capability.ts +32 -17
  53. package/src/server/auth-cors.ts +42 -6
  54. package/src/server/index.ts +1 -0
  55. package/src/server/management/agent-settings-routes.ts +20 -2
  56. package/src/server/management/context.ts +15 -0
  57. package/src/server/management/model-routes.ts +12 -3
  58. package/src/server/management/storage-log-guard-routes.ts +186 -0
  59. package/src/server/management-api.ts +3 -1
  60. package/src/server/responses/core.ts +13 -0
  61. package/src/server/system-env.ts +1 -1
  62. package/src/types.ts +24 -2
  63. package/src/web-search/loop.ts +21 -5
  64. package/gui/dist/assets/index-DF_UFrGS.css +0 -1
@@ -38,6 +38,11 @@ const REQUEST_TIMEOUT_MS = 8_000;
38
38
  export const QUOTA_RESPONSE_MAX_BYTES = 512 * 1024;
39
39
  const KIMI_CODE_BASE_URL = "https://api.kimi.com/coding/v1";
40
40
  const KIMI_CODE_USAGE_URL = `${KIMI_CODE_BASE_URL}/usages`;
41
+ const COMMAND_CODE_BASE_URL = "https://api.commandcode.ai";
42
+ const COMMAND_CODE_WHOAMI_URL = `${COMMAND_CODE_BASE_URL}/alpha/whoami`;
43
+ const COMMAND_CODE_CREDITS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/credits`;
44
+ const COMMAND_CODE_SUBSCRIPTIONS_URL = `${COMMAND_CODE_BASE_URL}/alpha/billing/subscriptions`;
45
+ const COMMAND_CODE_USAGE_URL = `${COMMAND_CODE_BASE_URL}/alpha/usage/summary`;
41
46
  const A6API_BASE_URL = "https://api.a6api.com";
42
47
  const OPENCODE_GO_BASE_URL = "https://opencode.ai/zen/go/v1";
43
48
  const OPENCODE_GO_USAGE_URL = `${OPENCODE_GO_BASE_URL}/usage`;
@@ -1510,6 +1515,12 @@ function isCanonicalKimiCodeBaseUrl(baseUrl: string): boolean {
1510
1515
  return normalizedBaseUrl(baseUrl) === KIMI_CODE_BASE_URL;
1511
1516
  }
1512
1517
 
1518
+ function isCanonicalCommandCodeBaseUrl(baseUrl: string): boolean {
1519
+ const normalized = normalizedBaseUrl(baseUrl);
1520
+ // OAuth preset points at the API root; the Provider-API preset at /provider/v1.
1521
+ return normalized === COMMAND_CODE_BASE_URL || normalized === `${COMMAND_CODE_BASE_URL}/provider/v1`;
1522
+ }
1523
+
1513
1524
  /** Prefer the nested `data` shell when the outer object is only an envelope. */
1514
1525
  function unwrapKimiQuotaPayload(value: unknown): Record<string, unknown> | null {
1515
1526
  const body = asRecord(value);
@@ -1631,6 +1642,145 @@ async function fetchKimiQuota(provider: string, config: OcxProviderConfig): Prom
1631
1642
  return quota ? report(provider, "kimi:usages", quota) : null;
1632
1643
  }
1633
1644
 
1645
+ /**
1646
+ * Command Code rolling window: `{ cap, used, resetAt }` off /alpha/billing/credits,
1647
+ * normalized to a percent with an optional reset timestamp.
1648
+ */
1649
+ function parseCommandCodeWindow(value: unknown): { percent: number; resetAt?: number } | null {
1650
+ const row = asRecord(value);
1651
+ if (!row) return null;
1652
+ const cap = toFiniteNumber(row.cap);
1653
+ const used = toFiniteNumber(row.used);
1654
+ if (cap === undefined || used === undefined || cap <= 0 || used < 0) return null;
1655
+ const percent = normalizePercent((used / cap) * 100);
1656
+ if (percent === undefined) return null;
1657
+ const resetAt = quotaResetAt(row);
1658
+ return { percent, ...(resetAt !== undefined ? { resetAt } : {}) };
1659
+ }
1660
+
1661
+ /** Soft-fail GET returning a parsed record, or null when unavailable. */
1662
+ async function fetchCommandCodeJson(url: string, bearer: string): Promise<Record<string, unknown> | null> {
1663
+ try {
1664
+ const response = await fetch(url, {
1665
+ headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
1666
+ redirect: "error",
1667
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1668
+ });
1669
+ if (!response.ok) return null;
1670
+ return asRecord(await readQuotaJson(response));
1671
+ } catch {
1672
+ return null;
1673
+ }
1674
+ }
1675
+
1676
+ /**
1677
+ * Soft-fail period spend (used) against the remaining credit pools → creditsUsd.
1678
+ * Period scoping: `since=<currentPeriodStart>` keeps spend aligned with the
1679
+ * pools' billing cycle, and `currentPeriodEnd` becomes expiresAt.
1680
+ */
1681
+ async function fetchCommandCodeSpend(
1682
+ bearer: string,
1683
+ credits: Record<string, unknown> | null,
1684
+ orgQuery: string,
1685
+ ): Promise<ProviderQuotaCreditsUsd | undefined> {
1686
+ if (!credits) return undefined;
1687
+ const subscriptionBody = await fetchCommandCodeJson(`${COMMAND_CODE_SUBSCRIPTIONS_URL}${orgQuery}`, bearer);
1688
+ const subscription = asRecord(subscriptionBody?.data) ?? subscriptionBody;
1689
+ const periodStart = typeof subscription?.currentPeriodStart === "string" ? subscription.currentPeriodStart.trim() : "";
1690
+ // Unscoped /usage/summary is lifetime spend; mixing it with current-cycle
1691
+ // remaining pools produces a wrong percent. Omit creditsUsd until a period exists.
1692
+ if (!periodStart) return undefined;
1693
+ const sinceQuery = `${orgQuery ? "&" : "?"}since=${encodeURIComponent(periodStart)}`;
1694
+ const expiresAt = normalizeResetAt(subscription?.currentPeriodEnd);
1695
+ const summaryBody = await fetchCommandCodeJson(`${COMMAND_CODE_USAGE_URL}${orgQuery}${sinceQuery}`, bearer);
1696
+ const summary = asRecord(summaryBody?.data) ?? summaryBody;
1697
+ const used = toFiniteNumber(summary?.totalCost) ?? toFiniteNumber(summary?.totalMonthlyCredits);
1698
+ if (used === undefined || used < 0) return undefined;
1699
+ const pools = [credits.monthlyCredits, credits.purchasedCredits, credits.freeCredits]
1700
+ .map(value => toFiniteNumber(value))
1701
+ .filter((value): value is number => value !== undefined);
1702
+ // Field presence is what separates a real balance from absent data: an exhausted
1703
+ // all-zero account still reports remaining=0, while no remaining-credit field at
1704
+ // all means there is nothing to meter.
1705
+ if (pools.length === 0) return undefined;
1706
+ const remaining = pools.reduce((sum, value) => sum + Math.max(0, value ?? 0), 0);
1707
+ const limit = used + remaining;
1708
+ const percent = normalizePercent(limit > 0 ? (used / limit) * 100 : 0);
1709
+ // Purchased credits roll over past the subscription period end, so an expiry is
1710
+ // only truthful when the aggregate contains no non-expiring purchased pool.
1711
+ const purchased = toFiniteNumber(credits.purchasedCredits) ?? 0;
1712
+ return percent === undefined
1713
+ ? undefined
1714
+ : {
1715
+ used,
1716
+ limit,
1717
+ remaining,
1718
+ percent,
1719
+ ...(expiresAt !== undefined && purchased <= 0 ? { expiresAt } : {}),
1720
+ };
1721
+ }
1722
+
1723
+ /** OAuth access token or ACTIVE Provider-API key for the Command Code quota probe. */
1724
+ async function resolveCommandCodeQuotaBearer(config: OcxProviderConfig): Promise<string | null> {
1725
+ if (config.authMode === "oauth") {
1726
+ try {
1727
+ return await getValidAccessToken("command-code");
1728
+ } catch {
1729
+ return null;
1730
+ }
1731
+ }
1732
+ // ACTIVE key only: a quota bar for a different account than the one routing
1733
+ // requests is a wrong meter, not a helpful one.
1734
+ return resolveEnvValue(config.apiKey)?.trim() || null;
1735
+ }
1736
+
1737
+ /**
1738
+ * Command Code `GET /alpha/billing/credits` — the same Bearer surface the CLI's
1739
+ * usage view uses (windowLimits.fiveHour / windowLimits.weekly), plus soft
1740
+ * whoami (team orgId scoping) and subscription-scoped spend for creditsUsd.
1741
+ */
1742
+ async function fetchCommandCodeQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
1743
+ // Never release credentials to a user-edited or lookalike provider host.
1744
+ if (!isCanonicalCommandCodeBaseUrl(config.baseUrl)) return null;
1745
+ const bearer = await resolveCommandCodeQuotaBearer(config);
1746
+ if (!bearer) return null;
1747
+ const whoamiBody = await fetchCommandCodeJson(COMMAND_CODE_WHOAMI_URL, bearer);
1748
+ const whoami = asRecord(whoamiBody?.data) ?? whoamiBody;
1749
+ const org = asRecord(whoami?.org);
1750
+ const orgId = typeof org?.id === "string" && org.id.trim() ? org.id.trim() : null;
1751
+ const orgQuery = orgId ? `?orgId=${encodeURIComponent(orgId)}` : "";
1752
+ const response = await fetch(`${COMMAND_CODE_CREDITS_URL}${orgQuery}`, {
1753
+ headers: { Accept: "application/json", Authorization: `Bearer ${bearer}` },
1754
+ redirect: "error",
1755
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
1756
+ });
1757
+ if (!response.ok) {
1758
+ return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
1759
+ ? TERMINAL_QUOTA_FAILURE
1760
+ : null;
1761
+ }
1762
+ const raw = asRecord(await readQuotaJson(response));
1763
+ const body = asRecord(raw?.data) ?? raw;
1764
+ const credits = asRecord(body?.credits);
1765
+ const limits = asRecord(body?.windowLimits);
1766
+ if (!credits && !limits) return null;
1767
+ const fiveHour = parseCommandCodeWindow(limits?.fiveHour);
1768
+ const weekly = parseCommandCodeWindow(limits?.weekly);
1769
+ const creditsUsd = await fetchCommandCodeSpend(bearer, credits, orgQuery);
1770
+ return report(provider, "command-code:credits", {
1771
+ ...(fiveHour ? {
1772
+ fiveHourPercent: fiveHour.percent,
1773
+ ...(fiveHour.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
1774
+ } : {}),
1775
+ ...(weekly ? {
1776
+ weeklyPercent: weekly.percent,
1777
+ ...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}),
1778
+ } : {}),
1779
+ ...(creditsUsd ? { creditsUsd } : {}),
1780
+ updatedAt: Date.now(),
1781
+ });
1782
+ }
1783
+
1634
1784
  /** Cursor included usage via api2.cursor.sh (Bearer from OAuth) — unofficial, may change. */
1635
1785
  async function fetchCursorQuota(provider: string): Promise<ProviderQuotaReport | null> {
1636
1786
  let accessToken: string;
@@ -1919,6 +2069,15 @@ async function maybeFetchProviderQuota(
1919
2069
  if (provider.authMode === "key" && isCanonicalKimiCodeBaseUrl(provider.baseUrl)) {
1920
2070
  return fetchKimiQuota(name, provider);
1921
2071
  }
2072
+ // OAuth account login or Provider-API key only; forward/local modes carry no
2073
+ // credential of ours on the canonical host.
2074
+ if (provider.authMode === "oauth" && name === "command-code") {
2075
+ return fetchCommandCodeQuota(name, provider);
2076
+ }
2077
+ if ((provider.authMode ?? "key") === "key" && name === "commandcode"
2078
+ && isCanonicalCommandCodeBaseUrl(provider.baseUrl)) {
2079
+ return fetchCommandCodeQuota(name, provider);
2080
+ }
1922
2081
  if ((provider.authMode ?? "key") === "key" && name === "opencode-go") {
1923
2082
  return fetchOpenCodeGoQuota(name, provider);
1924
2083
  }
@@ -80,6 +80,11 @@ interface ProviderModelDiscoverySharedSpec {
80
80
  maxResponseBytes?: number;
81
81
  /** Optional lower raw-row ceiling; the process-wide hard ceiling still wins. */
82
82
  maxModels?: number;
83
+ /**
84
+ * If a valid extracted id starts with this prefix, strip it and re-validate the remainder.
85
+ * Empty/invalid remainders skip that row only.
86
+ */
87
+ stripIdPrefix?: string;
83
88
  }
84
89
 
85
90
  type ProviderModelDiscoveryLocation =
@@ -1977,7 +1982,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1977
1982
  // 260710 GLM-5.2 context and path-specific ids: Tier-2 evidence in
1978
1983
  // devlog/_plan/260710_provider_hardening/002_research_cn.md.
1979
1984
  // 260814: glm-5.3 / glm-5.3[1m] added per docs.z.ai/devpack/latest-model, which lists them as
1980
- // Coding Plan ids on this same endpoint. Capabilities mirror 5.2 until Z.AI publishes tables.
1985
+ // Coding Plan ids on this same endpoint.
1986
+ // 260815: docs.z.ai/guides/llm/glm-5.3 now publishes the capability table (thinking, streaming,
1987
+ // function calling, caching, structured output) and a 128K output budget, recorded here as the
1988
+ // exact 131_072 every other source in this repo uses for that model. Coding Plan pricing stays
1989
+ // unpublished, so no cost entry is asserted.
1981
1990
  {
1982
1991
  id: "zai", label: "Z.AI — GLM Coding Plan", baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat", authKind: "key",
1983
1992
  dashboardUrl: "https://z.ai/manage-apikey/apikey-list", defaultModel: "glm-5.3",
@@ -1988,6 +1997,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1988
1997
  modelSuffixBracketStrip: true,
1989
1998
  noVisionModels: ZAI_GLM_5X_MODELS,
1990
1999
  modelReasoningEfforts: ZAI_GLM_5X_REASONING_EFFORTS,
2000
+ modelDefaultReasoningEfforts: Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, "max"])),
2001
+ modelMaxOutputTokens: Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, 131_072])),
1991
2002
  modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_5X_MODELS.map(id => [id, true])),
1992
2003
  preserveReasoningContentModels: ZAI_GLM_5X_MODELS,
1993
2004
  },
@@ -2461,6 +2472,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2461
2472
  // Cloudflare Workers AI: OpenAI-compatible endpoint. The base URL contains {account_id}
2462
2473
  // which must be resolved by the user at setup time. Model IDs use the @cf/ prefix.
2463
2474
  // Live-verified 2026-07-21 against https://developers.cloudflare.com/workers-ai/models/
2475
+ // Official search is sibling to /ai/v1 (GET .../ai/models/search?format=openrouter).
2464
2476
  id: "cloudflare-workers-ai", label: "Cloudflare Workers AI",
2465
2477
  baseUrl: "https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1",
2466
2478
  adapter: "openai-chat", authKind: "key", freeTier: true,
@@ -2475,6 +2487,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
2475
2487
  "@cf/zai-org/glm-5.2",
2476
2488
  "@cf/mistralai/mistral-small-3.1-24b-instruct",
2477
2489
  ],
2490
+ liveModels: true,
2491
+ modelDiscovery: {
2492
+ path: "../models/search",
2493
+ query: { format: "openrouter", per_page: "1000" },
2494
+ stripIdPrefix: "workers-ai/",
2495
+ maxModels: 256,
2496
+ },
2478
2497
  note: "Workers AI · Free tier included · Account ID required in base URL",
2479
2498
  },
2480
2499
  // FREEZE 2026-07-10: /models was auth-gated under key login. OAuth device-flow + copilot_internal
@@ -29,6 +29,20 @@ export function encodeRoutedModelId(id: string): string {
29
29
  return id.includes("/") ? id.replaceAll("/", SLUG_ALIAS_SEPARATOR) : id;
30
30
  }
31
31
 
32
+ /**
33
+ * True when `modelId` shares a Codex-facing encoded form with a different known id.
34
+ * That collision is what makes `provider/openai-gpt-5.5` decode to native `openai-gpt-5.5`
35
+ * while a custom `openai/gpt-5.5` row is still visible.
36
+ */
37
+ export function encodedModelIdCollides(modelId: string, knownIds: Iterable<string>): boolean {
38
+ const encoded = encodeRoutedModelId(modelId);
39
+ for (const id of knownIds) {
40
+ if (id === modelId) continue;
41
+ if (encodeRoutedModelId(id) === encoded) return true;
42
+ }
43
+ return false;
44
+ }
45
+
32
46
  /** Codex-facing routed slug: exactly one "/" — `<provider>/<encoded id>`. */
33
47
  export function routedSlug(provider: string, id: string): string {
34
48
  return `${provider}/${encodeRoutedModelId(id)}`;
@@ -51,6 +65,21 @@ export function decodeRoutedModelId(requested: string, knownIds: Iterable<string
51
65
  return aliasMatch ?? requested;
52
66
  }
53
67
 
68
+ /**
69
+ * Decode a Codex-facing id, but fail when a custom slash id and another known id
70
+ * share the same encoded form. Write-time checks cannot cover a later live cache.
71
+ */
72
+ export function decodeRoutedModelIdOrThrow(requested: string, knownIds: Iterable<string>): string {
73
+ const ids = [...knownIds];
74
+ const encodedRequested = encodeRoutedModelId(requested);
75
+ const matches = new Set<string>();
76
+ for (const id of ids) {
77
+ if (id === requested || encodeRoutedModelId(id) === encodedRequested) matches.add(id);
78
+ }
79
+ if (matches.size > 1) throw new Error(`ambiguous model id "${requested}"`);
80
+ return decodeRoutedModelId(requested, ids);
81
+ }
82
+
54
83
  /** Does a stored config slug name this routed model, in either raw or encoded form? */
55
84
  export function slugEquals(stored: string, provider: string, id: string): boolean {
56
85
  return stored === `${provider}/${id}` || stored === routedSlug(provider, id);
@@ -70,6 +70,9 @@ function rewriteForUpstream(
70
70
  || isPlainObject(value.format)
71
71
  || isPlainObject(value.parameters);
72
72
  if (!isDefinition) return { ...rest, type: "function" };
73
+ const inputDescription = value.name === "exec"
74
+ ? "JavaScript source for unified exec. Use await tools.exec_command(...) for shell commands and text(...) to return textual output; do not provide a bare shell command."
75
+ : "Raw input for this client-executed custom tool.";
73
76
  return {
74
77
  ...rest,
75
78
  type: "function",
@@ -78,7 +81,7 @@ function rewriteForUpstream(
78
81
  properties: {
79
82
  input: {
80
83
  type: "string",
81
- description: "Raw input for this client-executed custom tool.",
84
+ description: inputDescription,
82
85
  },
83
86
  },
84
87
  required: ["input"],
@@ -12,6 +12,7 @@ import type {
12
12
  } from "../types";
13
13
  import { namespacedToolName } from "../types";
14
14
  import { responsesRequestSchema } from "./schema";
15
+ import { providerMetadataFromResponsesFunctionCall } from "./provider-opaque-metadata";
15
16
  import { compactionItemToText } from "./compaction";
16
17
  import { previousResponseReplayPrefixLength } from "./state";
17
18
  import { decodeReasoningEnvelope } from "./reasoning-envelope";
@@ -498,7 +499,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
498
499
  }
499
500
 
500
501
  if (effectiveType === "function_call") {
501
- const call = item as { id?: string; call_id: string; name: string; arguments?: string; namespace?: string };
502
+ const call = item as { id?: string; call_id: string; name: string; arguments?: string; namespace?: string; extra_content?: unknown };
502
503
  // Tolerate empty/non-JSON arguments (e.g. a no-arg tool call serialized as "") instead of
503
504
  // throwing — a single poisoned history item would otherwise 400 every subsequent turn.
504
505
  let args: Record<string, unknown> = {};
@@ -519,6 +520,11 @@ export function parseRequest(body: unknown): OcxParsedRequest {
519
520
  type: "toolCall", id: call.call_id, name: call.name, arguments: args,
520
521
  ...(call.namespace ? { namespace: call.namespace } : {}),
521
522
  };
523
+ // Provider-opaque metadata (e.g. a Gemini thought signature) travels with the call so a
524
+ // history-replayed or previous_response_id turn rebuilds the same signed part instead of
525
+ // depending on the same-process replay cache (issue #1735).
526
+ const providerMetadata = providerMetadataFromResponsesFunctionCall(call);
527
+ if (providerMetadata) toolCall.providerMetadata = providerMetadata;
522
528
  assistantHolderWithReasoning().content.push(toolCall);
523
529
  continue;
524
530
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Provider-opaque tool-call metadata across the Responses boundary (issue #1735).
3
+ *
4
+ * Gemini issues a `thoughtSignature` on the exact part that carries a function call, and the
5
+ * next request is only valid if that signature comes back on the part rebuilt from that same
6
+ * call. Every synthetic loop in this proxy (web search, images, continuation replay) tears a
7
+ * tool call down into id/name/arguments and builds a fresh one, which silently dropped the
8
+ * signature and left only the same-process replay cache to paper over it. History replay and
9
+ * `previous_response_id` had no cache to fall back on.
10
+ *
11
+ * This module is the single seam where that metadata crosses into and out of the Responses
12
+ * wire, so a loop that rebuilds a call only has to carry one field instead of knowing about
13
+ * any provider. Values are treated as opaque: never parsed, merged, re-encoded, or synthesized.
14
+ */
15
+ import type { OcxProviderOpaqueToolCallMetadata } from "../types";
16
+
17
+ /** Wire shape: `extra_content.google.thought_signature` on a Responses function_call item. */
18
+ interface ResponsesExtraContent {
19
+ google?: { thought_signature?: unknown };
20
+ }
21
+
22
+ function isObj(value: unknown): value is Record<string, unknown> {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value);
24
+ }
25
+
26
+ /**
27
+ * Same ceiling the Antigravity replay cache already enforces on a stored signature. An opaque
28
+ * token this large is not a real signature, and accepting it would let a caller push unbounded
29
+ * state through history replay.
30
+ */
31
+ const MAX_SIGNATURE_BYTES = 64 * 1024;
32
+
33
+ function isCarryableSignature(value: unknown): value is string {
34
+ if (typeof value !== "string" || value.length === 0) return false;
35
+ // Cheap length pre-check: UTF-8 is at most 3 bytes per UTF-16 code unit for the BMP, so this
36
+ // skips the encode for the overwhelmingly common short case.
37
+ if (value.length <= MAX_SIGNATURE_BYTES / 3) return true;
38
+ return Buffer.byteLength(value, "utf8") <= MAX_SIGNATURE_BYTES;
39
+ }
40
+
41
+ /** Read provider metadata off an inbound Responses function_call item. */
42
+ export function providerMetadataFromResponsesFunctionCall(
43
+ item: { extra_content?: unknown } | undefined,
44
+ ): OcxProviderOpaqueToolCallMetadata | undefined {
45
+ const extra = item?.extra_content;
46
+ if (!isObj(extra)) return undefined;
47
+ const google = (extra as ResponsesExtraContent).google;
48
+ if (!isObj(google)) return undefined;
49
+ const signature = google.thought_signature;
50
+ if (!isCarryableSignature(signature)) return undefined;
51
+ return { google: { thoughtSignature: signature } };
52
+ }
53
+
54
+ /** Serialize provider metadata onto an outbound Responses function_call item. */
55
+ export function responsesExtraContentFromProviderMetadata(
56
+ metadata: OcxProviderOpaqueToolCallMetadata | undefined,
57
+ ): { extra_content: { google: { thought_signature: string } } } | undefined {
58
+ const signature = metadata?.google?.thoughtSignature;
59
+ if (!isCarryableSignature(signature)) return undefined;
60
+ return { extra_content: { google: { thought_signature: signature } } };
61
+ }
62
+
63
+ /**
64
+ * Copy metadata for a rebuilt tool call. A signature belongs to one specific part, so a loop
65
+ * that fans one model response into several calls must copy per call and never share or merge.
66
+ */
67
+ export function cloneProviderOpaqueToolCallMetadata(
68
+ metadata: OcxProviderOpaqueToolCallMetadata | undefined,
69
+ ): OcxProviderOpaqueToolCallMetadata | undefined {
70
+ const signature = metadata?.google?.thoughtSignature;
71
+ if (!isCarryableSignature(signature)) return undefined;
72
+ return { google: { thoughtSignature: signature } };
73
+ }
@@ -64,6 +64,12 @@ const functionCallItemSchema = z.object({
64
64
  name: z.string().min(1),
65
65
  namespace: z.string().optional(),
66
66
  arguments: z.string().optional(),
67
+ // Provider-opaque metadata that must survive the round trip verbatim (issue #1735). The shape
68
+ // is bounded on purpose: only the one nested key we round-trip is modeled, so an unexpected
69
+ // payload cannot ride through as arbitrary passthrough state.
70
+ extra_content: z.object({
71
+ google: z.object({ thought_signature: z.string().optional() }).optional(),
72
+ }).optional(),
67
73
  });
68
74
  const functionCallOutputItemSchema = z.object({
69
75
  type: z.literal("function_call_output"),
package/src/router.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  OPENAI_API_PROVIDER_ID,
25
25
  OPENAI_CODEX_PROVIDER_ID,
26
26
  } from "./providers/openai-tiers";
27
- import { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec";
27
+ import { decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec";
28
28
  import { getStaleCached } from "./codex/model-cache";
29
29
  import { codexAccountNamespaceEntries } from "./codex/account-namespaces";
30
30
  import {
@@ -87,9 +87,14 @@ const MODEL_PROVIDER_PATTERNS: Array<{ providerNames: string[]; prefixes: string
87
87
  * last-known-good live /models cache (may be empty on a cold start; decode then passes
88
88
  * unknown ids through unchanged for an honest upstream error).
89
89
  */
90
- export function knownModelIdsForProvider(provName: string, prov: OcxProviderConfig): string[] {
90
+ export function knownModelIdsForProvider(
91
+ provName: string,
92
+ prov: OcxProviderConfig,
93
+ config?: Pick<OcxConfig, "customModels">,
94
+ ): string[] {
91
95
  const ids = new Set<string>();
92
96
  for (const id of prov.models ?? []) ids.add(id);
97
+ if (prov.defaultModel) ids.add(prov.defaultModel);
93
98
  const registry = providerMatchesRegistryTransportWithStaticGuards(provName, prov)
94
99
  ? PROVIDER_REGISTRY.find(entry => entry.id === provName)
95
100
  : undefined;
@@ -108,6 +113,9 @@ export function knownModelIdsForProvider(provName: string, prov: OcxProviderConf
108
113
  for (const id of Object.keys(map ?? {})) ids.add(id);
109
114
  }
110
115
  for (const cached of getStaleCached(provName) ?? []) ids.add(cached.id);
116
+ for (const model of config?.customModels ?? []) {
117
+ if (model.provider === provName && model.modelId) ids.add(model.modelId);
118
+ }
111
119
  return [...ids];
112
120
  }
113
121
 
@@ -610,7 +618,7 @@ function routeModelInternal(
610
618
  if (hasOwnProvider(config.providers, provName)) {
611
619
  const prov = config.providers[provName];
612
620
  if (prov.disabled === true) throw new Error(`Provider is disabled: ${provName}`);
613
- const known = knownModelIdsForProvider(provName, prov);
621
+ const known = knownModelIdsForProvider(provName, prov, config);
614
622
  // Self-namespaced native id — the vendor segment equals the provider id, so the FULL ref is
615
623
  // itself a known model (e.g. orcarouter/auto). Route it whole instead of stripping to the
616
624
  // remainder, which would send a bare `auto` the upstream cannot resolve.
@@ -622,7 +630,7 @@ function routeModelInternal(
622
630
  return routeResult(
623
631
  provName,
624
632
  prov,
625
- decodeRoutedModelId(modelId.slice(slash + 1), known),
633
+ decodeRoutedModelIdOrThrow(modelId.slice(slash + 1), known),
626
634
  "explicit-provider",
627
635
  "explicit-provider-namespace",
628
636
  );
@@ -26,11 +26,12 @@ import { statSync } from "node:fs";
26
26
  import type { RouteCapabilityEvidence } from "./trace";
27
27
 
28
28
  type CatalogModelRow = {
29
+ /** Exact provider/native-id identity, from the provenance block. */
29
30
  provider: string;
30
31
  id: string;
32
+ /** Only values a real source asserted; never a strict-parser default. */
31
33
  contextWindow?: number;
32
34
  inputModalities?: string[];
33
- reasoningEfforts?: string[];
34
35
  capabilities?: string[];
35
36
  };
36
37
 
@@ -52,23 +53,33 @@ function cachedCatalogModels(): CatalogModelRow[] {
52
53
  const catalog = readCatalog(path);
53
54
  const models = catalog?.models;
54
55
  if (!Array.isArray(models)) return [];
55
- const rows = models
56
- .filter((model): model is Record<string, unknown> & { id: string; provider: string } =>
57
- typeof model === "object" && model !== null && typeof model.id === "string" && typeof model.provider === "string")
58
- .map(model => ({
59
- provider: model.provider,
60
- id: model.id,
61
- ...(typeof model.contextWindow === "number" ? { contextWindow: model.contextWindow } : {}),
62
- ...(Array.isArray(model.inputModalities)
63
- ? { inputModalities: model.inputModalities.filter((value): value is string => typeof value === "string") }
56
+ // Read ONLY `opencodex_capability_provenance` (written by
57
+ // applyCatalogModelMetadata). The row's own `context_window` and
58
+ // `input_modalities` always exist because ensureStrictCatalogFields fills them
59
+ // with compatibility defaults for Codex's strict parser, so reading them would
60
+ // turn "nobody asserted anything" into a confident `image: false` and a
61
+ // fabricated 128000 — the opposite of this module's contract. A row without
62
+ // provenance contributes nothing.
63
+ const rows = models.flatMap((model): CatalogModelRow[] => {
64
+ if (typeof model !== "object" || model === null) return [];
65
+ const provenance = (model as Record<string, unknown>).opencodex_capability_provenance;
66
+ if (typeof provenance !== "object" || provenance === null) return [];
67
+ const source = provenance as Record<string, unknown>;
68
+ if (typeof source.provider !== "string" || typeof source.model_id !== "string") return [];
69
+ return [{
70
+ provider: source.provider,
71
+ id: source.model_id,
72
+ ...(typeof source.context_window === "number" && source.context_window > 0
73
+ ? { contextWindow: source.context_window }
64
74
  : {}),
65
- ...(Array.isArray(model.reasoningEfforts)
66
- ? { reasoningEfforts: model.reasoningEfforts.filter((value): value is string => typeof value === "string") }
75
+ ...(Array.isArray(source.input_modalities)
76
+ ? { inputModalities: source.input_modalities.filter((value): value is string => typeof value === "string") }
67
77
  : {}),
68
- ...(Array.isArray(model.capabilities)
69
- ? { capabilities: model.capabilities.filter((value): value is string => typeof value === "string") }
78
+ ...(Array.isArray(source.capabilities)
79
+ ? { capabilities: source.capabilities.filter((value): value is string => typeof value === "string") }
70
80
  : {}),
71
- }));
81
+ }];
82
+ });
72
83
  catalogCache = { path, mtimeMs, rows };
73
84
  return rows;
74
85
  } catch {
@@ -177,13 +188,17 @@ export function candidateCapabilityEvidence(
177
188
  // override.
178
189
  const tools = capabilities.includes("tools")
179
190
  || isNative
180
- || (catalogRow === undefined && provider !== undefined && TOOL_CAPABLE_ADAPTERS.has(provider.adapter))
191
+ // The adapter protocol is positive evidence on its own. This was once gated
192
+ // on `catalogRow === undefined`, which was only safe while the catalog lookup
193
+ // never matched anything: once it matches, a row that simply does not
194
+ // enumerate "tools" would silently revoke tool support for every openai-chat
195
+ // and anthropic candidate.
196
+ || (provider !== undefined && TOOL_CAPABLE_ADAPTERS.has(provider.adapter))
181
197
  || provider?.parallelToolCalls === true
182
198
  || undefined;
183
199
 
184
200
  const reasoningEfforts = provider?.modelReasoningEfforts?.[modelId]
185
201
  ?? registryEntry?.modelReasoningEfforts?.[modelId]
186
- ?? catalogRow?.reasoningEfforts
187
202
  ?? (isNative ? nativeReasoningEfforts(modelId) : undefined);
188
203
 
189
204
  const tierSupport = provider
@@ -140,17 +140,53 @@ export function browserSecurityHeaders(): Record<string, string> {
140
140
  };
141
141
  }
142
142
 
143
+ /**
144
+ * Baseline data-plane request headers. ChatGPT-Account-Id is required for browser/Electron
145
+ * ChatGPT & Codex App voice preflights (direct forward auth matches the bearer to this account
146
+ * id). The OpenAI-Alpha .. X-OAI-Attestation block covers GPT-Live voice protocol headers
147
+ * relayed by the /v1/live call-create path.
148
+ */
149
+ const STATIC_ALLOWED_REQUEST_HEADERS =
150
+ "Content-Type, Authorization, X-OpenCodex-API-Key, X-Api-Key, Anthropic-Version, Anthropic-Beta, ChatGPT-Account-Id, OpenAI-Alpha, X-Session-Id, Session-Id, Thread-Id, Originator, X-OAI-Attestation";
151
+
152
+ /**
153
+ * A fixed allow-list cannot enumerate vendor telemetry headers: the OpenAI and Anthropic
154
+ * browser SDKs send `X-Stainless-*` describing runtime and retry state, and the browser blocks
155
+ * the real request when the preflight omits even one of them (#1773).
156
+ *
157
+ * Echo what an already-allowed origin asked for, and fall back to the static list otherwise.
158
+ * The echo is deliberately gated on the origin check that ran first: this widens which headers
159
+ * an admitted caller may send, never which origins are admitted, and it grants nothing to an
160
+ * origin that would have been rejected anyway. Authentication is unchanged — the preflight
161
+ * itself carries no credential and produces no auth or account-pool side effect.
162
+ */
163
+ function allowedRequestHeaders(req?: Request): string {
164
+ const requested = req?.headers.get("Access-Control-Request-Headers")?.trim();
165
+ if (!requested) return STATIC_ALLOWED_REQUEST_HEADERS;
166
+ const seen = new Set(STATIC_ALLOWED_REQUEST_HEADERS.split(",").map(h => h.trim().toLowerCase()));
167
+ const extra: string[] = [];
168
+ for (const raw of requested.split(",")) {
169
+ const name = raw.trim();
170
+ // Header names are case-insensitive on the wire, so normalize before de-duplicating;
171
+ // echo the caller's spelling for the ones we add.
172
+ if (!name || seen.has(name.toLowerCase())) continue;
173
+ seen.add(name.toLowerCase());
174
+ extra.push(name);
175
+ }
176
+ return extra.length === 0 ? STATIC_ALLOWED_REQUEST_HEADERS : `${STATIC_ALLOWED_REQUEST_HEADERS}, ${extra.join(", ")}`;
177
+ }
178
+
143
179
  export function corsHeaders(req?: Request, config?: RequestPolicyView): Record<string, string> {
144
180
  const origin = req?.headers.get("Origin");
145
- const allowOrigin = origin && req && config && isAllowedRequestOrigin(req, config) ? origin : _corsOrigin;
181
+ const originAllowed = Boolean(origin && req && config && isAllowedRequestOrigin(req, config));
182
+ const allowOrigin = originAllowed && origin ? origin : _corsOrigin;
146
183
  return {
147
184
  "Access-Control-Allow-Origin": allowOrigin,
148
185
  "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
149
- // ChatGPT-Account-Id is required for browser/Electron ChatGPT & Codex App voice preflights
150
- // (direct forward auth matches the bearer to this account id). The OpenAI-Alpha .. X-OAI-Attestation
151
- // block covers GPT-Live voice protocol headers relayed by the /v1/live call-create path.
152
- "Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key, X-Api-Key, Anthropic-Version, Anthropic-Beta, ChatGPT-Account-Id, OpenAI-Alpha, X-Session-Id, Session-Id, Thread-Id, Originator, X-OAI-Attestation",
153
- "Vary": "Origin",
186
+ "Access-Control-Allow-Headers": allowedRequestHeaders(originAllowed ? req : undefined),
187
+ // A response that varies by the request's headers must say so, or a shared cache can
188
+ // replay one client's allow-list to a client that asked for different headers.
189
+ "Vary": "Origin, Access-Control-Request-Headers",
154
190
  ...browserSecurityHeaders(),
155
191
  };
156
192
  }
@@ -988,6 +988,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
988
988
  providerContextCap(config, OPENAI_CODEX_PROVIDER_ID),
989
989
  accountNativeSlugs,
990
990
  accountNativeSlugsBySelector,
991
+ config.keepNativeChatGptOnV1 === true,
991
992
  );
992
993
  return jsonResponse({
993
994
  models: applyNativeVisibility(