@bitkyc08/opencodex 2.49.0 → 2.50.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 (69) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  4. package/gui/dist/index.html +1 -1
  5. package/package.json +1 -1
  6. package/src/claude/inbound.ts +17 -5
  7. package/src/cli/account-api.ts +18 -3
  8. package/src/cli/account-auth.ts +8 -1
  9. package/src/cli/account-extended.ts +2 -1
  10. package/src/cli/account.ts +1 -0
  11. package/src/cli/capabilities.ts +15 -1
  12. package/src/cli/index.ts +5 -1
  13. package/src/cli/models-runtime.ts +8 -3
  14. package/src/cli/observe.ts +13 -3
  15. package/src/clients/config-export/zcode.ts +24 -0
  16. package/src/codex/account-runtime-state.ts +6 -1
  17. package/src/codex/account-store.ts +72 -9
  18. package/src/codex/account-usability.ts +3 -2
  19. package/src/codex/auth-api.ts +107 -23
  20. package/src/codex/auth-context.ts +21 -0
  21. package/src/codex/catalog/parsing.ts +23 -0
  22. package/src/codex/catalog/provider-fetch.ts +71 -2
  23. package/src/codex/catalog/sync.ts +14 -0
  24. package/src/codex/inject.ts +3 -2
  25. package/src/codex/quota-auto-refresh.ts +6 -1
  26. package/src/codex/quota.ts +54 -8
  27. package/src/combos/index.ts +2 -0
  28. package/src/combos/resolve.ts +52 -0
  29. package/src/config.ts +58 -0
  30. package/src/generated/compatibility-version.json +72 -60
  31. package/src/lib/errors.ts +8 -0
  32. package/src/lib/privacy.ts +25 -0
  33. package/src/oauth/health.ts +47 -12
  34. package/src/oauth/index.ts +46 -8
  35. package/src/oauth/token-guardian.ts +32 -6
  36. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  37. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  38. package/src/providers/quota.ts +15 -0
  39. package/src/providers/registry.ts +1 -1
  40. package/src/server/auth-cors.ts +6 -0
  41. package/src/server/chat-completions.ts +4 -4
  42. package/src/server/chat-native.ts +10 -1
  43. package/src/server/claude-messages.ts +5 -5
  44. package/src/server/images.ts +2 -2
  45. package/src/server/index.ts +25 -2
  46. package/src/server/management/logs-usage-routes.ts +4 -1
  47. package/src/server/management/model-rows.ts +16 -1
  48. package/src/server/management/oauth-account-routes.ts +6 -2
  49. package/src/server/management/provider-routes.ts +9 -2
  50. package/src/server/management/request-history-routes.ts +4 -2
  51. package/src/server/management/route-registry.ts +5 -4
  52. package/src/server/management/shared.ts +66 -3
  53. package/src/server/management-api.ts +1 -1
  54. package/src/server/request-decompress.ts +91 -3
  55. package/src/server/request-log.ts +10 -0
  56. package/src/server/responses/codex-ws-wire.ts +1 -1
  57. package/src/server/responses/compact.ts +8 -2
  58. package/src/server/responses/context-overflow.ts +11 -0
  59. package/src/server/responses/core.ts +144 -38
  60. package/src/server/responses/policy-fallback.ts +6 -2
  61. package/src/server/search.ts +2 -2
  62. package/src/service.ts +92 -7
  63. package/src/types/accounts.ts +18 -0
  64. package/src/types/config.ts +36 -0
  65. package/src/types/provider.ts +56 -0
  66. package/src/types.ts +4 -0
  67. package/src/web-search/ollama-executor.ts +127 -0
  68. package/src/web-search/passthrough-bridge.ts +761 -0
  69. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
@@ -4,7 +4,7 @@ import { parseCallbackInput } from "./callback-server";
4
4
  import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
5
5
  import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfig } from "../config";
6
6
  import { resolveProviderApiKey } from "../providers/key-store";
7
- import { maskEmail } from "../lib/privacy";
7
+ import { projectEmail } from "../lib/privacy";
8
8
  import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
9
9
  import {
10
10
  OAuthMutationBusyError,
@@ -1781,19 +1781,54 @@ export function submitManualLoginCode(provider: string, input: string): { ok: tr
1781
1781
  return { ok: true };
1782
1782
  }
1783
1783
 
1784
- export interface OAuthAccountSummary { id: string; alias?: string; email?: string; active: boolean; needsReauth?: boolean; expiresAt?: number }
1784
+ export interface OAuthAccountSummary {
1785
+ id: string;
1786
+ alias?: string;
1787
+ email?: string;
1788
+ active: boolean;
1789
+ needsReauth?: boolean;
1790
+ expiresAt?: number;
1791
+ /**
1792
+ * Subscription tier, mirroring the field the OpenAI/Codex provider reports, so a consumer
1793
+ * weighting a multi-account pool by seat size needs no per-provider branching (#3777).
1794
+ *
1795
+ * Always present and explicitly `null` when the tier is unknown. The distinction matters:
1796
+ * an ABSENT key means the proxy is too old to report a tier at all, while `null` means this
1797
+ * version looked and upstream did not say. Omitting it would make those indistinguishable and
1798
+ * invite a consumer to assume a tier.
1799
+ *
1800
+ * Every OAuth provider reports `null` today. Anthropic's `/api/oauth/usage` returns quota
1801
+ * buckets only — `five_hour`, `seven_day`, the model-scoped weekly windows and `limits[]` —
1802
+ * and carries no subscription/tier field, and its token response carries none either. See
1803
+ * `fetchAnthropicUsageQuota` in `src/providers/quota.ts`.
1804
+ */
1805
+ plan: string | null;
1806
+ }
1785
1807
 
1786
- export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
1808
+ /**
1809
+ * Token-safe login state for one provider.
1810
+ *
1811
+ * `maskEmails` is an explicit boolean rather than a config read (#3859). This module must not
1812
+ * acquire a dependency on config I/O to answer a redaction question: the caller already holds
1813
+ * the config at its request boundary and resolves the policy there with `emailMaskingEnabled`.
1814
+ * The default masks, so every existing caller keeps today's behaviour.
1815
+ */
1816
+ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
1787
1817
  const cred = getCredential(provider);
1788
1818
  const st = loginState.get(provider);
1789
1819
  const set = getAccountSet(provider);
1790
1820
  const accounts: OAuthAccountSummary[] | undefined = set?.accounts.map(a => ({
1791
1821
  id: a.id,
1792
1822
  ...(a.alias ? { alias: a.alias } : {}),
1793
- email: maskEmail(a.credential.email) ?? undefined,
1823
+ email: projectEmail(a.credential.email, maskEmails) ?? undefined,
1794
1824
  active: a.id === set.activeAccountId,
1795
1825
  ...(a.needsReauth ? { needsReauth: true } : {}),
1796
1826
  expiresAt: a.credential.expires,
1827
+ // Explicitly null rather than omitted — see OAuthAccountSummary.plan. No OAuth provider
1828
+ // exposes a subscription tier today, so there is nothing truthful to put here; deriving one
1829
+ // from quota percentages is not possible, because they are normalized per account and a
1830
+ // half-consumed small seat is indistinguishable from a half-consumed large one.
1831
+ plan: null,
1797
1832
  }));
1798
1833
 
1799
1834
  // A stored credential counts as "logged in" when it exists and is not marked for
@@ -1805,7 +1840,7 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
1805
1840
  .find(a => a.id === set.activeAccountId)?.needsReauth === true;
1806
1841
  return {
1807
1842
  loggedIn: !!cred && !activeNeedsReauth,
1808
- email: maskEmail(cred?.email) ?? undefined,
1843
+ email: projectEmail(cred?.email, maskEmails) ?? undefined,
1809
1844
  source: cred?.source,
1810
1845
  error: st?.error,
1811
1846
  done: st?.done ?? false,
@@ -1813,10 +1848,13 @@ export function getLoginStatus(provider: string): { loggedIn: boolean; email?: s
1813
1848
  };
1814
1849
  }
1815
1850
 
1816
- /** Token-safe per-provider login state for the CLI `ocx status` logins section (no tokens, masked email). */
1817
- export function oauthLoginSummary(): Array<{ provider: string; loggedIn: boolean; email?: string }> {
1851
+ /**
1852
+ * Token-safe per-provider login state for the CLI `ocx status` logins section. Never tokens; the
1853
+ * email follows the operator's `privacy.maskEmails` policy, masked by default (#3859).
1854
+ */
1855
+ export function oauthLoginSummary(maskEmails = true): Array<{ provider: string; loggedIn: boolean; email?: string }> {
1818
1856
  return listOAuthProviders().map(provider => {
1819
- const status = getLoginStatus(provider);
1857
+ const status = getLoginStatus(provider, maskEmails);
1820
1858
  return { provider, loggedIn: status.loggedIn, ...(status.email ? { email: status.email } : {}) };
1821
1859
  });
1822
1860
  }
@@ -211,21 +211,30 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
211
211
  if (!cred) continue;
212
212
  const needsRefresh = cred.expiresAt <= nowMs + horizonMs;
213
213
  const needsWarmup = opts.codexWarmupEnabled
214
+ && !record.codexValidationPending
214
215
  && (record.lastCodexValidatedAt === undefined || nowMs - record.lastCodexValidatedAt > opts.codexWarmupMaxAgeSeconds * 1000);
215
216
  if (!needsRefresh && !needsWarmup) continue;
216
217
  const key = `codex:${id}`;
217
218
  if (inBackoff(key, nowMs)) { result.skippedBackoff.push(key); continue; }
219
+ // The generation this sweep is acting on. A successful refresh commits a new one, and a
220
+ // failure that follows belongs to THAT credential, so the fence has to move with it.
221
+ let observedGeneration = record.generation;
218
222
  tasks.push(async () => {
223
+ let warmupGeneration: number | undefined;
219
224
  try {
220
225
  const token = await getValidCodexToken(id);
226
+ observedGeneration = token.generation;
221
227
  if (needsRefresh) result.refreshed.push(key);
222
- if (needsWarmup) {
228
+ const current = readCodexAccountRecord(id);
229
+ if (needsWarmup && current?.credential && current.deletedAt == null
230
+ && !current.codexValidationPending && current.generation === token.generation) {
231
+ warmupGeneration = token.generation;
223
232
  await warmCodexAccount({
224
233
  accessToken: token.accessToken,
225
234
  chatgptAccountId: token.chatgptAccountId,
226
235
  model: opts.codexWarmupModel,
227
236
  });
228
- markCodexAccountValidated(id, Date.now());
237
+ markCodexAccountValidated(id, Date.now(), token.generation);
229
238
  result.warmed.push(key);
230
239
  }
231
240
  backoff.delete(key);
@@ -235,11 +244,28 @@ export async function guardianSweep(nowMs: number = Date.now()): Promise<Guardia
235
244
  result.skippedBackoff.push(key);
236
245
  return;
237
246
  }
238
- const permanent = err instanceof TokenRefreshError && (err.reason === "revoked" || err.reason === "expired");
239
- if (needsWarmup && !(err instanceof TokenRefreshError)) {
240
- markCodexAccountValidationFailed(id, codexWarmupFailureReason(err));
247
+ const terminal = err instanceof TokenRefreshError && (err.reason === "revoked" || err.reason === "expired")
248
+ ? err
249
+ : undefined;
250
+ if (terminal) {
251
+ // A revoked or expired refresh grant is the strongest terminal evidence there is, and
252
+ // it used to be the one class that never reached the record: the persisted-verdict
253
+ // branch below requires `needsWarmup`, which is false in the default configuration,
254
+ // and additionally excluded every TokenRefreshError. The verdict landed only in the
255
+ // in-memory backoff map, which no health surface reads and no restart survives, so the
256
+ // account kept its login-time "ok" while every request with it 401'd (#4120).
257
+ markCodexAccountValidationFailed(id, `refresh_${terminal.reason}`, {
258
+ expectedGeneration: observedGeneration,
259
+ terminal: true,
260
+ });
261
+ } else if (warmupGeneration !== undefined && !(err instanceof TokenRefreshError)) {
262
+ // warmupGeneration is set only once the warmup actually started against a record
263
+ // still at the token's generation, so it is a tighter fence than the pre-sweep read.
264
+ markCodexAccountValidationFailed(id, codexWarmupFailureReason(err), {
265
+ expectedGeneration: warmupGeneration,
266
+ });
241
267
  }
242
- recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, permanent, writerGeneration);
268
+ recordFailure(key, nowMs, opts.backoffBaseSeconds, opts.backoffMaxSeconds, terminal !== undefined, writerGeneration);
243
269
  result.failed.push(key);
244
270
  }
245
271
  });
@@ -0,0 +1,74 @@
1
+ import {
2
+ extractModelEnvelopeRows,
3
+ isValidModelDiscoveryModelId,
4
+ type ProviderModelItemsResult,
5
+ type ProviderModelsApiItem,
6
+ } from "./model-discovery";
7
+
8
+ const GOOGLE_MODEL_PREFIX = "models/";
9
+ const MAX_GENERATION_METHODS = 32;
10
+ const MAX_GENERATION_METHOD_LENGTH = 64;
11
+
12
+ /** Returns the value if it is a positive safe integer; otherwise undefined. */
13
+ function positiveSafeInteger(value: unknown): number | undefined {
14
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
15
+ ? value
16
+ : undefined;
17
+ }
18
+
19
+ /**
20
+ * Extracts and normalizes supported model items from a Google AI Studio
21
+ * /v1beta/models response payload.
22
+ *
23
+ * Validates the native models[] envelope, strips the 'models/' prefix, filters
24
+ * to rows supporting 'generateContent', maps input/output token limits, and
25
+ * resiliently skips toxic or malformed individual rows.
26
+ */
27
+ export function extractGoogleAiStudioModelItems(
28
+ value: unknown,
29
+ maxModels: number,
30
+ ): ProviderModelItemsResult {
31
+ const envelope = extractModelEnvelopeRows(value, maxModels, ["models"]);
32
+ if (!envelope.ok) return envelope;
33
+
34
+ const items: ProviderModelsApiItem[] = [];
35
+ const seen = new Set<string>();
36
+ for (const raw of envelope.rows) {
37
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
38
+ continue;
39
+ }
40
+ const name = Reflect.get(raw, "name");
41
+ const generationMethods = Reflect.get(raw, "supportedGenerationMethods");
42
+ if (!isValidModelDiscoveryModelId(name)) {
43
+ continue;
44
+ }
45
+ if (generationMethods === undefined) continue;
46
+ if (
47
+ !Array.isArray(generationMethods)
48
+ || generationMethods.length > MAX_GENERATION_METHODS
49
+ || generationMethods.some(method => typeof method !== "string" || method.length > MAX_GENERATION_METHOD_LENGTH)
50
+ ) {
51
+ continue;
52
+ }
53
+ if (!generationMethods.includes("generateContent")) continue;
54
+
55
+ const id = name.startsWith(GOOGLE_MODEL_PREFIX)
56
+ ? name.slice(GOOGLE_MODEL_PREFIX.length)
57
+ : name;
58
+ if (!isValidModelDiscoveryModelId(id) || seen.has(id)) continue;
59
+ seen.add(id);
60
+
61
+ const inputTokenLimit = positiveSafeInteger(Reflect.get(raw, "inputTokenLimit"));
62
+ const outputTokenLimit = positiveSafeInteger(Reflect.get(raw, "outputTokenLimit"));
63
+ items.push({
64
+ id,
65
+ owned_by: "google",
66
+ ...(inputTokenLimit !== undefined
67
+ ? { context_length: inputTokenLimit, max_input_tokens: inputTokenLimit }
68
+ : {}),
69
+ ...(outputTokenLimit !== undefined ? { max_output_tokens: outputTokenLimit } : {}),
70
+ });
71
+ }
72
+ return { ok: true, items, rawCount: envelope.rows.length };
73
+ }
74
+
@@ -7,6 +7,11 @@
7
7
  * bodies and may omit `Retry-After` / `X-RateLimit-*`; when those headers are
8
8
  * present they still take precedence. Distinct from the keyless desktop
9
9
  * ~200 requests / 5h quota documented on `opencode-free`.
10
+ *
11
+ * The same module also owns the keyless free-tier admission explanation (#4121):
12
+ * Zen rejects a request that carries no `x-opencode-session` header with
13
+ * `MissingSessionID` / "OpenCode's free tier can only be used in OpenCode".
14
+ * opencodex does not synthesize that header — see {@link enrichOpenCodeZenFreeTierMessage}.
10
15
  */
11
16
  import { validateClientRetryAfterHeader } from "../lib/retry-after";
12
17
  import { registryEntryForProviderDestination } from "./registry";
@@ -100,3 +105,73 @@ export function enrichOpenCodeZenRateLimitMessage(
100
105
  + paceHint
101
106
  );
102
107
  }
108
+
109
+ /**
110
+ * Zen's keyless free tier admits only OpenCode's own client. A request without an
111
+ * `x-opencode-session` header is refused with error type `MissingSessionID` and the
112
+ * message "OpenCode's free tier can only be used in OpenCode" (#4121).
113
+ *
114
+ * Presence of the header is the whole gate — any value clears it — so opencodex could
115
+ * pass by minting one. It does not. Fabricating a session identifier and a versioned
116
+ * `opencode/<version>` User-Agent is a claim to *be* the OpenCode client, and no upstream
117
+ * contract authorizes a third-party agent to make it; an HTTP 200 obtained that way is a
118
+ * bypassed admission check, not permission. Until OpenCode publishes a third-party
119
+ * integration path for this exact keyless tier, the supported route is the keyed
120
+ * `opencode-zen` provider.
121
+ *
122
+ * Two markers are matched because the two request surfaces expose different parts of the
123
+ * upstream envelope: the Responses path forwards the bounded raw body (which carries the
124
+ * `MissingSessionID` type), while the native Chat path forwards only the parsed message.
125
+ */
126
+ const OPENCODE_ZEN_FREE_TIER_LOCK_IN = /MissingSessionID|free tier can only be used in OpenCode/i;
127
+
128
+ /** Idempotence marker — the appended guidance must not stack across enrichment layers. */
129
+ const FREE_TIER_ENRICHMENT_MARKER = "does not send a fabricated OpenCode session header";
130
+
131
+ /** True when an upstream error body is Zen's keyless free-tier admission refusal. */
132
+ export function isOpenCodeZenFreeTierLockIn(message: string, upstreamErrorType?: string | null): boolean {
133
+ if (upstreamErrorType && OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(upstreamErrorType)) return true;
134
+ return OPENCODE_ZEN_FREE_TIER_LOCK_IN.test(message);
135
+ }
136
+
137
+ /**
138
+ * Replace a raw `MissingSessionID` passthrough with an explanation of the upstream
139
+ * restriction and the supported alternative. No-op for every other provider and every
140
+ * other error, and idempotent so layered enrichment cannot append it twice.
141
+ */
142
+ export function enrichOpenCodeZenFreeTierMessage(
143
+ message: string,
144
+ opts: {
145
+ providerName?: string;
146
+ baseUrl?: string;
147
+ adapter?: string;
148
+ /** Upstream `error.type`, when the caller parsed one out of the envelope. */
149
+ upstreamErrorType?: string | null;
150
+ },
151
+ ): string {
152
+ if (message.includes(FREE_TIER_ENRICHMENT_MARKER)) return message;
153
+ if (!isOpenCodeZenFreeTierLockIn(message, opts.upstreamErrorType)) return message;
154
+ if (!isOpenCodeZenRateLimitProvider(opts)) return message;
155
+ return (
156
+ `${message}`
157
+ + " OpenCode Zen's keyless free tier admits only OpenCode's own client: it refuses any"
158
+ + " request that arrives without an x-opencode-session header."
159
+ + ` opencodex ${FREE_TIER_ENRICHMENT_MARKER}, because presenting itself as the OpenCode`
160
+ + " client is a claim no upstream contract supports."
161
+ + " Use the keyed opencode-zen provider with an OpenCode Zen API key"
162
+ + " (https://opencode.ai/auth), or route this model through another provider."
163
+ + " Upstream terms: https://opencode.ai/docs/zen/."
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Single entry point for Zen upstream-error guidance on the Responses wire: short-window
169
+ * rate limits first, then the keyless free-tier admission refusal. Each layer is a no-op
170
+ * outside its own case, so the composition is safe for every other upstream failure.
171
+ */
172
+ export function enrichOpenCodeZenUpstreamMessage(
173
+ message: string,
174
+ opts: Parameters<typeof enrichOpenCodeZenRateLimitMessage>[1] & { upstreamErrorType?: string | null },
175
+ ): string {
176
+ return enrichOpenCodeZenFreeTierMessage(enrichOpenCodeZenRateLimitMessage(message, opts), opts);
177
+ }
@@ -1398,6 +1398,21 @@ function parseClaudeLimit(value: unknown): { label: string; percent: number; res
1398
1398
  /** Claude's OAuth usage endpoint, probed with ONE account's own bearer token. */
1399
1399
  const anthropicUsageInflight = new Map<string, Promise<ProviderQuota | null>>();
1400
1400
 
1401
+ /**
1402
+ * Anthropic per-credential usage.
1403
+ *
1404
+ * This endpoint reports quota only. Its body carries `five_hour`, `seven_day`, the
1405
+ * model-scoped weekly buckets (`seven_day_fable`/`_opus`/`_sonnet`) and a `limits` array,
1406
+ * and **no subscription or tier field** — nor does the OAuth token response, which yields only
1407
+ * `account.uuid` and `account.email_address` (`src/oauth/anthropic.ts`). That is why
1408
+ * `OAuthAccountSummary.plan` is `null` for Anthropic rather than populated here (#3777); it is
1409
+ * a missing upstream field, not an unfinished mapping.
1410
+ *
1411
+ * A tier must not be inferred from what is here. Percentages are normalized per account, so a
1412
+ * Max x5 seat at 50% is byte-identical to a Max x20 seat at 50%, and the presence of a
1413
+ * model-scoped window tracks entitlement rather than seat size. Populate `plan` only when
1414
+ * upstream returns the tier itself.
1415
+ */
1401
1416
  async function fetchAnthropicUsageQuota(accessToken: string): Promise<ProviderQuota | null> {
1402
1417
  const joinable = anthropicUsageInflight.get(accessToken);
1403
1418
  if (joinable) return joinable;
@@ -3018,7 +3018,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
3018
3018
  keyOptional: true,
3019
3019
  featured: true,
3020
3020
  liveModels: true,
3021
- note: "No key needed — public desktop tier. OpenCode currently advertises about 200 Big Pickle/free-model requests per 5 hours. The same Zen gateway can also short-window rate-limit free models at roughly 15-20 requests/minute, and may return generic 429s without Retry-After (opencodex synthesizes backoff only when that header is omitted). Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.",
3021
+ note: "No key needed, but OpenCode now gates this tier to its own client: Zen refuses any request that arrives without an x-opencode-session header (error type MissingSessionID, \"OpenCode's free tier can only be used in OpenCode\"). opencodex does not mint that header or claim an OpenCode client identity, because no upstream contract authorizes a third-party agent to present itself as OpenCode. Until OpenCode publishes a third-party integration path for the keyless tier, use the keyed opencode-zen provider instead (https://opencode.ai/auth). Quota figures for when the tier admitted a request: OpenCode advertises about 200 Big Pickle/free-model requests per 5 hours, and the same Zen gateway can short-window rate-limit free models at roughly 15-20 requests/minute, and may return generic 429s without Retry-After (opencodex synthesizes backoff only when that header is omitted). Free models are discovered live from Zen. Data use: per OpenCode's Zen docs (https://opencode.ai/docs/zen/), prompts sent to free models may be retained and used for training/improvement — do not send confidential material through this provider.",
3022
3022
  dashboardUrl: "https://opencode.ai",
3023
3023
  staticHeaders: {
3024
3024
  // Zen answers a bare runtime User-Agent (Bun/x.y.z) more aggressively than a client
@@ -6,6 +6,7 @@ import {
6
6
  codexAutoStartEnabled,
7
7
  modelPreferHostedToolsConfigError,
8
8
  providerModelCostsConfigError,
9
+ providerWebSearchBridgeConfigError,
9
10
  requestPacingConfigError,
10
11
  retryOn429PolicyConfigError,
11
12
  sanitizeModelCostsForDisplay,
@@ -650,6 +651,10 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
650
651
  if (requestPacingError) {
651
652
  return `provider ${JSON.stringify(redactSecretString(name))} ${requestPacingError}`;
652
653
  }
654
+ const webSearchBridgeError = providerWebSearchBridgeConfigError(raw.webSearchBridge);
655
+ if (webSearchBridgeError) {
656
+ return `provider ${JSON.stringify(redactSecretString(name))} ${webSearchBridgeError}`;
657
+ }
653
658
  const upstreamHttpVersionError = upstreamHttpVersionConfigError(raw.upstreamHttpVersion);
654
659
  if (upstreamHttpVersionError) {
655
660
  return `provider ${JSON.stringify(redactSecretString(name))} ${upstreamHttpVersionError}`;
@@ -847,6 +852,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
847
852
  xaiResponsesDefaultVersion: "runtime",
848
853
  supportsResponsesCustomTools: "editor",
849
854
  responsesSnapshotRepair: "editor",
855
+ webSearchBridge: "editor",
850
856
  reasoningEffortMap: "editor",
851
857
  modelReasoningEffortMap: "editor",
852
858
  reasoningWireFormat: "editor",
@@ -28,7 +28,7 @@ import { resolveWireProtocolOverride } from "./adapter-resolve";
28
28
  import { resolveOpenCodeGoTransport } from "../providers/opencode-go-transport";
29
29
  import { normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation";
30
30
  import type { OcxConfig } from "../types";
31
- import { readJsonRequestBody } from "./request-decompress";
31
+ import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
32
32
  import {
33
33
  addFinalRequestLog,
34
34
  httpStatusForRequestLogTerminal,
@@ -62,9 +62,9 @@ type Rec = Record<string, unknown>;
62
62
  function isRec(v: unknown): v is Rec {
63
63
  return !!v && typeof v === "object" && !Array.isArray(v);
64
64
  }
65
- async function readChatBody(req: Request, budget: TranslatorBudget): Promise<unknown> {
65
+ async function readChatBody(req: Request, budget: TranslatorBudget, maxBytes: number): Promise<unknown> {
66
66
  try {
67
- return await readJsonRequestBody(req, budget);
67
+ return await readJsonRequestBody(req, budget, maxBytes);
68
68
  } catch (err) {
69
69
  if (isTranslatorBudgetExceededError(err)) throw err;
70
70
  throw new ChatCompletionsRequestError(err instanceof Error && err.message ? err.message : "Invalid JSON body");
@@ -106,7 +106,7 @@ async function handleChatCompletionsWithBudget(
106
106
  ): Promise<Response> {
107
107
  let chatBody: Rec;
108
108
  try {
109
- const rawBody = await readChatBody(req, translatorBudget);
109
+ const rawBody = await readChatBody(req, translatorBudget, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
110
110
  assertChatCompletionsRoutingBody(rawBody);
111
111
  chatBody = rawBody;
112
112
  } catch (err) {
@@ -40,6 +40,7 @@ import {
40
40
  } from "../providers/key-failover";
41
41
  import { fastPolicyForModel } from "../providers/service-tier";
42
42
  import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../providers/api-key-selection";
43
+ import { enrichOpenCodeZenFreeTierMessage } from "../providers/opencode-zen-rate-limit";
43
44
  import type { OcxProviderTransport } from "../providers/xai-transport";
44
45
  import type { RouteResult } from "../router";
45
46
  import type { OcxConfig, OcxProviderConfig } from "../types";
@@ -438,12 +439,20 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
438
439
  && (isCyberPolicyCode(upstreamCode) || isCyberPolicyMessage(upstreamMessage))
439
440
  ? upstreamMessage
440
441
  : detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`;
442
+ // Zen's keyless free tier refuses the request outright rather than rate-limiting it, and
443
+ // the raw `MissingSessionID` tells a user nothing about why or what to do (#4121).
444
+ const clientMessage = enrichOpenCodeZenFreeTierMessage(message, {
445
+ providerName: route.providerName,
446
+ baseUrl: route.provider.baseUrl,
447
+ adapter: route.provider.adapter,
448
+ upstreamErrorType: upstreamType,
449
+ });
441
450
  const classified = classifyError(
442
451
  response.status,
443
452
  upstreamType ?? (response.status === 401 ? "authentication_error"
444
453
  : response.status === 429 ? "rate_limit_error"
445
454
  : response.status >= 500 ? "server_error" : "invalid_request_error"),
446
- message,
455
+ clientMessage,
447
456
  );
448
457
  if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) {
449
458
  classified.code = CYBER_POLICY_ERROR_CODE;
@@ -34,7 +34,7 @@ import { registryEntryForProviderDestination } from "../providers/registry";
34
34
  import { evidenceFromBody } from "../routing/request-evidence";
35
35
  import { resolveWireProtocolOverride } from "./adapter-resolve";
36
36
  import type { OcxConfig } from "../types";
37
- import { readJsonRequestBody } from "./request-decompress";
37
+ import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
38
38
  import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log";
39
39
  import { conversationIdFromClaudeMetadata, normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation";
40
40
  import { responseWithDeferredRequestLog } from "./relay";
@@ -129,9 +129,9 @@ function claudeInboundDisabled(config: OcxConfig): Response | null {
129
129
  return null;
130
130
  }
131
131
 
132
- async function readAnthropicBody(req: Request, budget: TranslatorBudget): Promise<unknown> {
132
+ async function readAnthropicBody(req: Request, budget: TranslatorBudget, maxBytes: number): Promise<unknown> {
133
133
  try {
134
- return await readJsonRequestBody(req, budget);
134
+ return await readJsonRequestBody(req, budget, maxBytes);
135
135
  } catch (err) {
136
136
  if (isTranslatorBudgetExceededError(err)) throw err;
137
137
  throw new AnthropicRequestError(err instanceof Error && err.message ? err.message : "Invalid JSON body");
@@ -655,7 +655,7 @@ async function handleClaudeMessagesWithBudget(
655
655
  let fastRow: ParsedFastRowId | null = null;
656
656
  let requestedModel = "";
657
657
  try {
658
- anthropicBody = await readAnthropicBody(req, translatorBudget);
658
+ anthropicBody = await readAnthropicBody(req, translatorBudget, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
659
659
  // Defensive [1m] strip (devlog 138): clients normally remove the context-variant
660
660
  // marker themselves; the 1M signal we act on is the anthropic-beta header.
661
661
  // Case-insensitive — the CLI matches /\[1m\]/i (audit 021 #7).
@@ -1154,7 +1154,7 @@ export async function handleClaudeCountTokens(
1154
1154
  let body: unknown;
1155
1155
  const translatorBudget = createTranslatorBudget();
1156
1156
  try {
1157
- body = await readAnthropicBody(req, translatorBudget);
1157
+ body = await readAnthropicBody(req, translatorBudget, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
1158
1158
  } catch (err) {
1159
1159
  if (err instanceof DesktopModelMappingUnavailableError) return desktopMappingUnavailableResponse(err);
1160
1160
  if (err instanceof AnthropicRequestError) return anthropicErrorResponse(400, err.message);
@@ -29,7 +29,7 @@ import { sidecarEnter } from "../lib/sidecar-tracker";
29
29
  import type { OcxConfig } from "../types";
30
30
  import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar";
31
31
  import { getProviderRegistryEntry } from "../providers/registry";
32
- import { readJsonRequestBody } from "./request-decompress";
32
+ import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress";
33
33
  import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
34
34
  import type { RequestLogContext } from "./request-log";
35
35
  import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
@@ -602,7 +602,7 @@ export async function handleImages(
602
602
  ): Promise<Response> {
603
603
  let body: unknown;
604
604
  try {
605
- body = await readJsonRequestBody(req);
605
+ body = await readJsonRequestBody(req, undefined, resolveInboundBodyLimitBytes(config.maxInboundBodyBytes));
606
606
  } catch (err) {
607
607
  return decodeRequestErrorResponse(err, "images");
608
608
  }
@@ -63,7 +63,11 @@ import { runModelRenameStartupMigration } from "../providers/model-rename-startu
63
63
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
64
64
  import { providerCodexAccountMode } from "../providers/registry";
65
65
  import type { StorageCleanupPolicy } from "../types";
66
- import { MAX_DECOMPRESSED_BODY_BYTES } from "./request-decompress";
66
+ import {
67
+ MAX_CONFIGURABLE_INBOUND_BODY_BYTES,
68
+ MIN_CONFIGURABLE_INBOUND_BODY_BYTES,
69
+ resolveInboundBodyLimitBytes,
70
+ } from "./request-decompress";
67
71
  import {
68
72
  CodexAccountCooldownError,
69
73
  cooldownErrorMessage,
@@ -1023,6 +1027,22 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1023
1027
  let loopbackServer: Server<WsData> | null = null;
1024
1028
  let managementIngressServer: Server<WsData> | null = null;
1025
1029
 
1030
+ // Resolved once, before any listener binds. The clamp is silent inside the resolver so it
1031
+ // stays pure and per-request cheap; the operator is told here instead, once, because a
1032
+ // config value that was quietly reduced is exactly the thing they would otherwise debug
1033
+ // against the wrong limit.
1034
+ const inboundBodyLimitBytes = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes);
1035
+ const requestedInboundBodyLimit = config.maxInboundBodyBytes;
1036
+ if (requestedInboundBodyLimit !== undefined
1037
+ && requestedInboundBodyLimit > 0
1038
+ && requestedInboundBodyLimit !== inboundBodyLimitBytes) {
1039
+ console.warn(
1040
+ `[server] maxInboundBodyBytes=${requestedInboundBodyLimit} is outside the supported range `
1041
+ + `[${MIN_CONFIGURABLE_INBOUND_BODY_BYTES}, ${MAX_CONFIGURABLE_INBOUND_BODY_BYTES}]; `
1042
+ + `using ${inboundBodyLimitBytes} bytes.`,
1043
+ );
1044
+ }
1045
+
1026
1046
  type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management";
1027
1047
  function ingressForServer(requestServer: Server<WsData>): ServerIngress {
1028
1048
  if (requestServer === loopbackServer) return "unauthenticated-loopback";
@@ -1042,7 +1062,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1042
1062
  userCostOverlayReconciler = startUserCostOverlayReconciler({ liveConfig: config });
1043
1063
  const serveOptions = {
1044
1064
  idleTimeout: 255,
1045
- maxRequestBodySize: MAX_DECOMPRESSED_BODY_BYTES,
1065
+ // Bun rejects an oversized body before `fetch` runs, so the listener has to be raised
1066
+ // with the admission limit or the opt-in would do nothing. Fixed at bind time: a live
1067
+ // `maxInboundBodyBytes` edit needs a restart, which the config doc states.
1068
+ maxRequestBodySize: inboundBodyLimitBytes,
1046
1069
  async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
1047
1070
  const ingress = ingressForServer(requestServer);
1048
1071
  // The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
@@ -115,7 +115,10 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
115
115
  }
116
116
  const all = getRequestLogEntries();
117
117
  const total = filteredRequestLogCount(all, url.searchParams);
118
- const logs = filterRequestLogs(all, url.searchParams).map(requestLogDto);
118
+ // Not point-free: requestLogDto takes an options object second, and Array.map would pass the
119
+ // element INDEX into it. An explicit arrow keeps the default (decode rate included) and is
120
+ // what /api/logs wants; /api/request-history opts out at its own call sites.
121
+ const logs = filterRequestLogs(all, url.searchParams).map(entry => requestLogDto(entry));
119
122
  const poll = selectRequestLogPoll(logs, url.searchParams, cursor);
120
123
  return jsonResponse({
121
124
  timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
@@ -147,10 +147,25 @@ export async function listManagementModelRows(
147
147
  };
148
148
  });
149
149
  const publicModels = uniqueCatalogModelsForPublicList(models);
150
+ // Custom rows below are REBUILT from config.customModels rather than spread from a
151
+ // CatalogModel, so every field gather computed for the same slug has to be carried across by
152
+ // hand. Without this a custom model whose provider is out of credit would be the one row on
153
+ // the page that never shows as inactive (#1711), because the gather-derived row it replaces
154
+ // is dropped by the slug dedup below.
155
+ const quotaInactiveByNamespaced = new Map(
156
+ publicModels
157
+ .filter(model => model.quotaInactiveReason !== undefined)
158
+ .map(model => [catalogModelSlug(model), model.quotaInactiveReason!] as const),
159
+ );
150
160
  const comboNamespaced = new Set(
151
161
  publicModels.filter(model => model.provider === "combo").map(catalogModelSlug),
152
162
  );
153
- const visibleCustomModels = customModels.filter(model => !comboNamespaced.has(model.namespaced));
163
+ const visibleCustomModels = customModels
164
+ .filter(model => !comboNamespaced.has(model.namespaced))
165
+ .map(model => {
166
+ const quotaInactiveReason = quotaInactiveByNamespaced.get(model.namespaced);
167
+ return quotaInactiveReason ? { ...model, quotaInactiveReason } : model;
168
+ });
154
169
  // Custom metadata wins when a physical live/static row resolves to the same Codex-facing
155
170
  // slug, while a combo keeps the same precedence it has in routing and /v1/models.
156
171
  const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced));
@@ -25,6 +25,7 @@ import {
25
25
  } from "../../oauth";
26
26
  import { OAuthMutationBusyError, removeCredential } from "../../oauth/store";
27
27
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
28
+ import { emailMaskingEnabled } from "../../lib/privacy";
28
29
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
29
30
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
30
31
  import { deriveProviderPresets } from "../../providers/derive";
@@ -233,7 +234,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
233
234
  if (url.pathname === "/api/oauth/status" && req.method === "GET") {
234
235
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
235
236
  if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
236
- const status = getLoginStatus(provider);
237
+ // Resolved here, at the request boundary that already holds the config, and passed down.
238
+ // getLoginStatus stays free of config I/O. This route does not re-mask afterwards: it
239
+ // consumes the already-projected status rather than redacting a second time.
240
+ const status = getLoginStatus(provider, emailMaskingEnabled(config));
237
241
  return jsonResponse(status);
238
242
  }
239
243
 
@@ -269,7 +273,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
269
273
  } = await import("../../oauth/health");
270
274
  const projectAccounts = () => {
271
275
  const set = getAccountSet(provider);
272
- const current = getLoginStatus(provider);
276
+ const current = getLoginStatus(provider, emailMaskingEnabled(config));
273
277
  return {
274
278
  activeAccountId: current.activeAccountId ?? null,
275
279
  accounts: (current.accounts ?? []).map(summary => {