@bitkyc08/opencodex 2.28.0 → 2.29.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 (90) hide show
  1. package/README.md +9 -1
  2. package/gui/dist/assets/index-BNESwCzn.js +102 -0
  3. package/gui/dist/assets/index-CH7ncHCC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/base.ts +3 -1
  7. package/src/adapters/cursor/checkpoint-store.ts +303 -0
  8. package/src/adapters/cursor/discovery.ts +25 -0
  9. package/src/adapters/cursor/live-transport.ts +19 -2
  10. package/src/adapters/cursor/native-exec.ts +47 -2
  11. package/src/adapters/cursor/protobuf-request.ts +80 -23
  12. package/src/adapters/cursor/request-builder.ts +119 -5
  13. package/src/adapters/cursor/transport.ts +5 -0
  14. package/src/adapters/cursor/types.ts +13 -0
  15. package/src/adapters/cursor.ts +109 -3
  16. package/src/adapters/google-antigravity-replay.ts +31 -4
  17. package/src/adapters/google.ts +323 -38
  18. package/src/adapters/openai-chat.ts +19 -0
  19. package/src/adapters/openai-responses.ts +218 -37
  20. package/src/bridge.ts +38 -20
  21. package/src/claude/desktop-3p.ts +15 -6
  22. package/src/cli/agent.ts +44 -1
  23. package/src/cli/claude-agent-startup-sync.ts +73 -0
  24. package/src/cli/dispatch.ts +24 -1
  25. package/src/cli/ensure-desired-integrations.ts +152 -0
  26. package/src/cli/help.ts +1 -1
  27. package/src/cli/index.ts +40 -38
  28. package/src/cli/integrations.ts +1 -1
  29. package/src/cli/registry.ts +2 -2
  30. package/src/clients/config-export.ts +119 -20
  31. package/src/codex/affinity-debug.ts +162 -0
  32. package/src/codex/inject.ts +46 -14
  33. package/src/codex/journal.ts +22 -8
  34. package/src/config.ts +1 -0
  35. package/src/generated/compatibility-version.json +134 -66
  36. package/src/integrations/mutation-flight.ts +71 -0
  37. package/src/integrations/owned-refresh.ts +74 -0
  38. package/src/integrations/registry.ts +25 -2
  39. package/src/integrations/writer.ts +32 -1
  40. package/src/lab/public/signature.ts +25 -1
  41. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  42. package/src/lib/redact.ts +2 -2
  43. package/src/oauth/log.ts +3 -1
  44. package/src/providers/derive.ts +9 -0
  45. package/src/providers/fastwire.ts +24 -18
  46. package/src/providers/openai-tiers.ts +60 -1
  47. package/src/providers/registry.ts +14 -7
  48. package/src/providers/xai-responses-opt-in.ts +15 -0
  49. package/src/responses/compaction.ts +18 -0
  50. package/src/responses/custom-tool-compat.ts +70 -5
  51. package/src/responses/namespace-tool-compat.ts +356 -0
  52. package/src/responses/parser.ts +2 -2
  53. package/src/responses/provider-continuation.ts +98 -0
  54. package/src/responses/reasoning-replay-cache.ts +125 -7
  55. package/src/responses/spill-store.ts +6 -1
  56. package/src/responses/state.ts +11 -0
  57. package/src/router.ts +14 -0
  58. package/src/routing/compatibility/behavior.ts +1 -0
  59. package/src/server/auth-cors.ts +4 -0
  60. package/src/server/management/agent-settings-routes.ts +57 -9
  61. package/src/server/management/config-routes.ts +134 -15
  62. package/src/server/management/integration-routes.ts +8 -55
  63. package/src/server/management/model-routes.ts +23 -1
  64. package/src/server/management/provider-routes.ts +22 -0
  65. package/src/server/management/vision-sidecar-options.ts +18 -11
  66. package/src/server/management/web-search-sidecar-options.ts +120 -0
  67. package/src/server/responses/core.ts +588 -78
  68. package/src/server/responses/responses-field-backfill.ts +7 -11
  69. package/src/server/responses/terminal-guard.ts +22 -11
  70. package/src/server/responses-custom-tool-repair.ts +3 -1
  71. package/src/server/responses-reasoning-summary-rewrite.ts +7 -0
  72. package/src/server/responses-tool-search-repair.ts +64 -14
  73. package/src/sidecar/auth.ts +92 -0
  74. package/src/sidecar/candidates.ts +83 -0
  75. package/src/types/config.ts +26 -5
  76. package/src/types/provider.ts +18 -0
  77. package/src/types/request.ts +26 -0
  78. package/src/types.ts +1 -0
  79. package/src/usage/log.ts +2 -0
  80. package/src/vision/index.ts +8 -9
  81. package/src/web-search/backends.ts +108 -0
  82. package/src/web-search/exa-executor.ts +88 -0
  83. package/src/web-search/gemini-executor.ts +141 -0
  84. package/src/web-search/index.ts +139 -12
  85. package/src/web-search/loop.ts +45 -5
  86. package/src/web-search/parse.ts +34 -23
  87. package/src/web-search/sources.ts +60 -0
  88. package/src/web-search/xai-executor.ts +219 -0
  89. package/gui/dist/assets/index-D2sP-biU.js +0 -102
  90. package/gui/dist/assets/index-DQsMZzI5.css +0 -1
@@ -0,0 +1,71 @@
1
+ import type { IntegrationClientId } from "./registry";
2
+
3
+ const INTEGRATION_MUTATION_JOIN_MS = 120_000;
4
+ export const INTEGRATION_MUTATION_TERMINAL_MS = 10 * 60_000;
5
+
6
+ interface IntegrationMutationFlight {
7
+ key: string;
8
+ startedAt: number;
9
+ promise: Promise<unknown>;
10
+ }
11
+
12
+ export class IntegrationMutationBusyError extends Error {
13
+ constructor(readonly clientId: IntegrationClientId) {
14
+ super("integration_mutation_busy");
15
+ }
16
+ }
17
+
18
+ const integrationMutationFlights = new Map<IntegrationClientId, IntegrationMutationFlight>();
19
+ let integrationMutationRunTestHook: ((operation: () => Promise<unknown>) => Promise<unknown>) | null = null;
20
+
21
+ /**
22
+ * One in-process flight per client, shared by explicit HTTP mutations and
23
+ * implicit catalog refreshes. Equal operations join; different semantics are
24
+ * busy instead of, for example, letting an explicit apply join a refresh-only
25
+ * no-op for an absent block.
26
+ */
27
+ export function runIntegrationMutationFlight<T>(
28
+ clientId: IntegrationClientId,
29
+ key: string,
30
+ now: () => number,
31
+ operation: () => Promise<T>,
32
+ ): Promise<T> {
33
+ const startedAt = now();
34
+ const current = integrationMutationFlights.get(clientId);
35
+ if (current) {
36
+ const age = startedAt - current.startedAt;
37
+ if (current.key === key && age < INTEGRATION_MUTATION_JOIN_MS) {
38
+ return current.promise as Promise<T>;
39
+ }
40
+ if (age <= INTEGRATION_MUTATION_TERMINAL_MS) {
41
+ return Promise.reject(new IntegrationMutationBusyError(clientId));
42
+ }
43
+ if (integrationMutationFlights.get(clientId) === current) {
44
+ integrationMutationFlights.delete(clientId);
45
+ }
46
+ }
47
+
48
+ const flight: IntegrationMutationFlight = {
49
+ key,
50
+ startedAt,
51
+ promise: Promise.resolve(),
52
+ };
53
+ const run = async (): Promise<unknown> => operation();
54
+ flight.promise = (integrationMutationRunTestHook
55
+ ? integrationMutationRunTestHook(run)
56
+ : run()
57
+ ).finally(() => {
58
+ if (integrationMutationFlights.get(clientId) === flight) {
59
+ integrationMutationFlights.delete(clientId);
60
+ }
61
+ });
62
+ integrationMutationFlights.set(clientId, flight);
63
+ return flight.promise as Promise<T>;
64
+ }
65
+
66
+ export function setIntegrationMutationFlightTestHook(
67
+ run: ((operation: () => Promise<unknown>) => Promise<unknown>) | null,
68
+ ): void {
69
+ integrationMutationRunTestHook = run;
70
+ integrationMutationFlights.clear();
71
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Refresh a file integration only when OpenCodex already owns its block.
3
+ *
4
+ * This is the safe bridge between an implicit operation such as `ocx sync`
5
+ * and the explicit integration writer. An implicit sync may update a block
6
+ * the user previously enabled, but it must never claim an unowned block,
7
+ * recreate one the user removed, or overwrite edits made after our write.
8
+ */
9
+ import type { ExportModel } from "../clients/config-export";
10
+ import type { OcxConfig } from "../types";
11
+ import type { IntegrationIO } from "./config-io";
12
+ import type { IntegrationClientId } from "./registry";
13
+ import { runIntegrationMutationFlight } from "./mutation-flight";
14
+ import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
15
+ import {
16
+ refreshIntegrationCoordinated,
17
+ type CoordinatedIntegrationOptions,
18
+ } from "./writer";
19
+
20
+ export interface OwnedIntegrationRefreshInput {
21
+ clientId: IntegrationClientId;
22
+ /** Lazy in `ocx sync`, so an unowned client does not even load the catalog. */
23
+ models: readonly ExportModel[] | (() => Promise<readonly ExportModel[]>);
24
+ config: OcxConfig;
25
+ port: number;
26
+ env?: NodeJS.ProcessEnv;
27
+ home?: string;
28
+ store?: IntegrationStateStore;
29
+ io?: IntegrationIO;
30
+ }
31
+
32
+ export interface OwnedIntegrationRefreshOutcome {
33
+ readonly client: IntegrationClientId;
34
+ readonly ok: boolean;
35
+ readonly changed?: boolean;
36
+ readonly reason?: string;
37
+ }
38
+
39
+ /**
40
+ * Returns `null` when the client has never been connected by OpenCodex.
41
+ * Callers use that distinction to report "left alone" rather than "skipped".
42
+ */
43
+ export async function refreshOwnedIntegration(
44
+ input: OwnedIntegrationRefreshInput,
45
+ options?: CoordinatedIntegrationOptions,
46
+ ): Promise<OwnedIntegrationRefreshOutcome | null> {
47
+ const store = input.store ?? createIntegrationStateStore();
48
+
49
+ // The ownership record is the user's durable opt-in. Do this check before
50
+ // reading or classifying the target so an implicit sync can never turn an
51
+ // unowned provider block into one of ours.
52
+ const record = store.readRecords()[input.clientId];
53
+ if (!record || record.clientId !== input.clientId) return null;
54
+
55
+ const { models: suppliedModels, ...rest } = input;
56
+ const models = typeof suppliedModels === "function"
57
+ ? await suppliedModels()
58
+ : suppliedModels;
59
+ const bound = { ...rest, models, store };
60
+ const result = await runIntegrationMutationFlight(
61
+ input.clientId,
62
+ "refresh",
63
+ input.io?.now ?? Date.now,
64
+ () => refreshIntegrationCoordinated(bound, options),
65
+ );
66
+ return result.ok
67
+ ? {
68
+ client: input.clientId,
69
+ ok: true,
70
+ changed: result.changed,
71
+ ...(result.state === "absent" ? { reason: result.message } : {}),
72
+ }
73
+ : { client: input.clientId, ok: false, reason: result.message };
74
+ }
@@ -27,6 +27,10 @@ import {
27
27
  opencodeGlobalConfigPath,
28
28
  openclawConfigPath,
29
29
  openclawHomeDir,
30
+ piAgentDir,
31
+ piConfigPath,
32
+ primeAgentDir,
33
+ primeConfigPath,
30
34
  zcodeConfigPath,
31
35
  zcodeHomeDir,
32
36
  type ExportClientId,
@@ -77,8 +81,18 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
77
81
  },
78
82
  pi: {
79
83
  id: "pi",
80
- configPath: (_env = process.env, home = homedir()) => join(home, ".pi", "agent", "models.json"),
81
- detectDir: (_env = process.env, home = homedir()) => join(home, ".pi"),
84
+ configPath: (env = process.env, home = homedir()) => piConfigPath(env, home),
85
+ /*
86
+ * Deliberately not `piAgentDir` unconditionally. Without an override the
87
+ * install signal stays `~/.pi`, which is what it has always been: narrowing
88
+ * it to `~/.pi/agent` would flip a user who has the former without the
89
+ * latter from installed to absent, and this change is about honoring the
90
+ * override, not about redefining detection. With an override there is no
91
+ * parent worth testing — the variable names the agent directory itself — so
92
+ * that directory becomes the signal.
93
+ */
94
+ detectDir: (env = process.env, home = homedir()) =>
95
+ env.PI_CODING_AGENT_DIR?.trim() ? piAgentDir(env, home) : join(home, ".pi"),
82
96
  },
83
97
  omp: {
84
98
  id: "omp",
@@ -120,12 +134,21 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
120
134
  id: "mcode",
121
135
  configPath: (env = process.env, home = homedir()) => mcodeConfigPath(env, home),
122
136
  detectDir: (env = process.env, home = homedir()) => mcodeHomeDir(env, home),
137
+ writerLock: { suffix: ".lock" },
123
138
  },
124
139
  zcode: {
125
140
  id: "zcode",
126
141
  configPath: (env = process.env, home = homedir()) => zcodeConfigPath(env, home),
127
142
  detectDir: (env = process.env, home = homedir()) => zcodeHomeDir(env, home),
128
143
  },
144
+ prime: {
145
+ id: "prime",
146
+ configPath: (env = process.env, home = homedir()) => primeConfigPath(env, home),
147
+ // The agent directory, not its parent: `PRIME_AGENT_CODING_AGENT_DIR` names
148
+ // that directory directly, so there is no parent to test when the override
149
+ // is set. Same choice as OMP, whose detect signal is `ompAgentDir`.
150
+ detectDir: (env = process.env, home = homedir()) => primeAgentDir(env, home),
151
+ },
129
152
  };
130
153
 
131
154
  export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
@@ -240,7 +240,7 @@ function preflight(input: IntegrationWriteInput) {
240
240
  return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } as const;
241
241
  }
242
242
 
243
- export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
243
+ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: boolean): WriteOutcome {
244
244
  const pre = preflight(input);
245
245
  if (pre.failed) return pre.failed;
246
246
  const { store, io, clientId, spec, exportSpec, configPath, before, parsed, contribution, record, classified } = pre;
@@ -271,6 +271,21 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
271
271
  ? `${configPath} holds a value where opencodex would have to write a section, so applying would replace it`
272
272
  : `${configPath} cannot be changed safely`);
273
273
  }
274
+ /*
275
+ * An implicit catalog sync is refresh-only. Keeping this decision inside the
276
+ * writer's one preflight closes the read-then-apply race where a user could
277
+ * remove the managed block after a caller classified it as stale and a
278
+ * normal apply would silently recreate it.
279
+ */
280
+ if (classified.state === "absent" && !allowAbsent) {
281
+ return {
282
+ ok: true,
283
+ changed: false,
284
+ state: "absent",
285
+ clientId,
286
+ message: "managed block is absent; refresh did not reconnect it",
287
+ };
288
+ }
274
289
  if (classified.state === "current") {
275
290
  return { ok: true, changed: false, state: "current", clientId, message: "already applied" };
276
291
  }
@@ -352,6 +367,15 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
352
367
  });
353
368
  }
354
369
 
370
+ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
371
+ return applyOrRefreshIntegration(input, true);
372
+ }
373
+
374
+ /** Refresh an owned stale block, but never create or reconnect an absent one. */
375
+ export function refreshIntegration(input: IntegrationWriteInput): WriteOutcome {
376
+ return applyOrRefreshIntegration(input, false);
377
+ }
378
+
355
379
  export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
356
380
  const pre = preflight(input);
357
381
  if (pre.failed) return pre.failed;
@@ -627,6 +651,13 @@ export function applyIntegrationCoordinated(
627
651
  return coordinatedWrite(input, applyIntegration, options);
628
652
  }
629
653
 
654
+ export function refreshIntegrationCoordinated(
655
+ input: IntegrationWriteInput,
656
+ options?: CoordinatedIntegrationOptions,
657
+ ): Promise<WriteOutcome> {
658
+ return coordinatedWrite(input, refreshIntegration, options);
659
+ }
660
+
630
661
  export function disableIntegrationCoordinated(
631
662
  input: IntegrationWriteInput,
632
663
  options?: CoordinatedIntegrationOptions,
@@ -52,6 +52,22 @@ function publisherForPrivateKey(privateKeyPem: string): PublicPublisherV1 {
52
52
  };
53
53
  }
54
54
 
55
+ /**
56
+ * The bounded errno-shaped code from a failed ACL harden, or null when the cause
57
+ * carries none.
58
+ *
59
+ * Only the code is allowed into the message. `hardenSecretPath` already sanitizes
60
+ * its own diagnostic prose, but this error is what reaches a CI log, so what
61
+ * crosses that boundary is re-checked here rather than trusted: an errno code has
62
+ * no separator, no lowercase and a bounded length, and therefore cannot carry the
63
+ * key pathname or the username component inside it.
64
+ */
65
+ function aclFailureCode(error: unknown): string | null {
66
+ if (!(error instanceof Error) || !("code" in error)) return null;
67
+ const code = (error as NodeJS.ErrnoException).code;
68
+ return typeof code === "string" && /^[A-Z][A-Z0-9_]{1,30}$/.test(code) ? code : null;
69
+ }
70
+
55
71
  function requirePublisherKeyAcl(path: string, timeoutMemoKey = path): void {
56
72
  privateRegularFileSize(path, PRIVATE_KEY_FILE_OPTIONS);
57
73
  let hardened: { ok: boolean };
@@ -63,9 +79,17 @@ function requirePublisherKeyAcl(path: string, timeoutMemoKey = path): void {
63
79
  hardened = { ok: false };
64
80
  }
65
81
  if (!hardened.ok) {
82
+ // Name the cause in the message, not only on `cause`. Every harden failure
83
+ // reaches a CI log as this one string, and the three that occur there need
84
+ // different fixes: ETIMEDOUT is the budget, EACLIDENTITY is the effective-SID
85
+ // lookup, EICACLS is icacls refusing the path. A message identical across all
86
+ // three cannot be acted on without a Windows box to re-run it under (#2152).
87
+ const code = aclFailureCode(hardeningError);
66
88
  const failure = new PublicEvidenceValidationError(
67
89
  "public_publisher_key_unsafe",
68
- "public publisher key ACL hardening did not complete",
90
+ code
91
+ ? `public publisher key ACL hardening did not complete (${code})`
92
+ : "public publisher key ACL hardening did not complete",
69
93
  );
70
94
  if (hardeningError !== undefined) {
71
95
  (failure as Error & { cause?: unknown }).cause = hardeningError;
@@ -12,7 +12,7 @@ const CLOSED_KEYS = new Set([
12
12
  "reasoning.supported", "reasoning.efforts", "reasoning.defaultEffort", "reasoning.effortMap", "reasoning.wireFormat",
13
13
  "reasoning.summaryMode", "reasoning.replayMode", "reasoning.splitMode", "reasoning.toggleMode", "reasoning.budgetMode",
14
14
  "tools.choiceRestrictions", "tools.parallel", "tools.hostedPreference", "tools.customFreeform", "tools.builtinNameEscaping",
15
- "cache.forwarding", "cache.retention", "anthropic.eofPolicy",
15
+ "cache.forwarding", "cache.retention", "anthropic.eofPolicy", "openai-chat.eofPolicy",
16
16
  "google.mode", "google.projectFingerprint", "google.locationFingerprint",
17
17
  "openrouter.order", "openrouter.only", "openrouter.allowFallbacks",
18
18
  "sidecars.vision", "sidecars.webSearch",
package/src/lib/redact.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export const REDACTED_SECRET = "[REDACTED]";
2
2
 
3
- const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn)$/i;
3
+ const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;
4
4
 
5
5
  /**
6
6
  * Colon-labelled credential headers echoed back inside an error body
@@ -33,7 +33,7 @@ const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-
33
33
  // Every letter position also accepts \u0001, the placeholder the fold emits for
34
34
  // an unresolved HTML named reference: `author&ii;zation` is the label with one
35
35
  // character we cannot name, and that is still the label.
36
- const CREDENTIAL_HEADER_LABEL_RAW = "x-api-key|x-goog-api-key|x-amz-security-token|api[_-]?key|apiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token";
36
+ const CREDENTIAL_HEADER_LABEL_RAW = "x-api-key|x-goog-api-key|x-amz-security-token|api[_-]?key|apiKey|exa[_-]?api[_-]?key|exaApiKey|access[_-]?token|accessToken|refresh[_-]?token|refreshToken|id[_-]?token|client[_-]?secret|clientSecret|authorization|proxy-authorization|cookie|set-cookie|password|secret|token";
37
37
 
38
38
  const CREDENTIAL_HEADER_LABEL = CREDENTIAL_HEADER_LABEL_RAW
39
39
  .replace(/(?<![\[\\])([A-Za-z])(?![\]\-])/g, "[$1\u0001]");
package/src/oauth/log.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/oauth/log.ts
2
2
  import { maskAccountId } from "../lib/privacy";
3
+ import { redactSecretString } from "../lib/redact";
3
4
 
4
5
  /** Normalize camelCase / snake_case / kebab-case field names before secret checks. */
5
6
  function normalizeFieldKey(key: string): string {
@@ -20,6 +21,7 @@ const FORBIDDEN_NORMALIZED = new Set([
20
21
  "id_token",
21
22
  "client_secret",
22
23
  "oauth_code",
24
+ "code_verifier",
23
25
  "clientsecret",
24
26
  ]);
25
27
 
@@ -44,5 +46,5 @@ export function logOAuthEvent(
44
46
  if (value === undefined) continue;
45
47
  parts.push(`${key}=${String(value)}`);
46
48
  }
47
- console.info(parts.join(" "));
49
+ console.info(redactSecretString(parts.join(" ")));
48
50
  }
@@ -47,6 +47,7 @@ export interface DerivedKeyLoginProvider {
47
47
  thinkingToggleModels?: string[];
48
48
  thinkingBudgetModels?: string[];
49
49
  escapeBuiltinToolNames?: boolean;
50
+ openaiChatEofTolerance?: boolean;
50
51
  googleMode?: "ai-studio" | "vertex" | "cloud-code-assist";
51
52
  project?: string;
52
53
  location?: string;
@@ -247,6 +248,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
247
248
  ...(entry.parallelToolCalls !== undefined ? { parallelToolCalls: entry.parallelToolCalls } : {}),
248
249
  ...(entry.promptCacheKey !== undefined ? { promptCacheKey: entry.promptCacheKey } : {}),
249
250
  ...(entry.chatServiceTier !== undefined ? { chatServiceTier: entry.chatServiceTier } : {}),
251
+ ...(entry.openaiChatEofTolerance !== undefined ? { openaiChatEofTolerance: entry.openaiChatEofTolerance } : {}),
250
252
  ...(entry.responsesPath !== undefined ? { responsesPath: entry.responsesPath } : {}),
251
253
  ...(entry.statelessResponses !== undefined ? { statelessResponses: entry.statelessResponses } : {}),
252
254
  ...(entry.requiresAdjacentResponsesToolResults !== undefined
@@ -306,6 +308,7 @@ export function deriveKeyLoginMap(): Record<string, DerivedKeyLoginProvider> {
306
308
  ...(entry.thinkingToggleModels ? { thinkingToggleModels: [...entry.thinkingToggleModels] } : {}),
307
309
  ...(entry.thinkingBudgetModels ? { thinkingBudgetModels: [...entry.thinkingBudgetModels] } : {}),
308
310
  ...(entry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: entry.escapeBuiltinToolNames } : {}),
311
+ ...(entry.openaiChatEofTolerance !== undefined ? { openaiChatEofTolerance: entry.openaiChatEofTolerance } : {}),
309
312
  ...(entry.googleMode ? { googleMode: entry.googleMode } : {}),
310
313
  ...(entry.project ? { project: entry.project } : {}),
311
314
  ...(entry.location ? { location: entry.location } : {}),
@@ -461,6 +464,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
461
464
  if (prov.parallelToolCalls === undefined && seed.parallelToolCalls !== undefined) prov.parallelToolCalls = seed.parallelToolCalls;
462
465
  if (prov.promptCacheKey === undefined && seed.promptCacheKey !== undefined) prov.promptCacheKey = seed.promptCacheKey;
463
466
  if (prov.chatServiceTier === undefined && seed.chatServiceTier !== undefined) prov.chatServiceTier = seed.chatServiceTier;
467
+ if (prov.openaiChatEofTolerance === undefined && seed.openaiChatEofTolerance !== undefined) {
468
+ prov.openaiChatEofTolerance = seed.openaiChatEofTolerance;
469
+ }
464
470
  // Fill-only: a hand-edited path must survive, and a config saved before the registry
465
471
  // learned this route still gets backfilled.
466
472
  if (prov.responsesPath === undefined && seed.responsesPath !== undefined) prov.responsesPath = seed.responsesPath;
@@ -474,6 +480,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
474
480
  prov.fastWire = cloneFastWire(entry.fastWire);
475
481
  }
476
482
  if (prov.supportsServiceTier === undefined && entry.supportsServiceTier !== undefined) prov.supportsServiceTier = entry.supportsServiceTier;
483
+ if (prov.supportsOpenAiWebSearchToolFields === undefined && entry.supportsOpenAiWebSearchToolFields !== undefined) {
484
+ prov.supportsOpenAiWebSearchToolFields = entry.supportsOpenAiWebSearchToolFields;
485
+ }
477
486
  if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent;
478
487
  applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries);
479
488
  applyServiceTierModelDefaults(prov, serviceTierModelDefaultsFor(entry, prov));
@@ -143,34 +143,40 @@ function resolvePolicyAdapter(
143
143
  inbound: InboundWire,
144
144
  ): { adapter: string; hardPinned: boolean; forwardCallerServiceTier?: boolean } {
145
145
  // Hard pins and configured overrides deliberately use the same exact-key semantics as
146
- // resolveWireProtocolOverride(). Registry defaults alone normalize ids at their boundary.
146
+ // resolveWireProtocolOverride(). Registry route policy normalizes ids at its boundary.
147
147
  const hardPin = Object.hasOwn(authority.hardPins, modelId)
148
148
  ? authority.hardPins[modelId]
149
149
  : undefined;
150
150
  if (typeof hardPin === "string") return { adapter: hardPin, hardPinned: true };
151
151
  if (authority.modelWireOverrideAllowed) {
152
+ const registryDefault = MODEL_ADAPTER_OVERRIDE_ALLOWED.has(authority.providerAdapter)
153
+ ? registryDefaultForModel(
154
+ authority.registryWireDefaults,
155
+ modelId,
156
+ inbound,
157
+ authority.providerAuthMode,
158
+ )
159
+ : undefined;
152
160
  const configured = Object.hasOwn(authority.modelAdapters, modelId)
153
161
  ? authority.modelAdapters[modelId]
154
162
  : undefined;
155
163
  if (typeof configured === "string" && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured)) {
156
- return { adapter: configured, hardPinned: false };
164
+ return {
165
+ adapter: configured,
166
+ hardPinned: false,
167
+ ...(registryDefault?.forwardCallerServiceTier === false
168
+ ? { forwardCallerServiceTier: false }
169
+ : {}),
170
+ };
157
171
  }
158
- if (MODEL_ADAPTER_OVERRIDE_ALLOWED.has(authority.providerAdapter)) {
159
- const registryDefault = registryDefaultForModel(
160
- authority.registryWireDefaults,
161
- modelId,
162
- inbound,
163
- authority.providerAuthMode,
164
- );
165
- if (registryDefault !== undefined) {
166
- return {
167
- adapter: registryDefault.adapter,
168
- hardPinned: false,
169
- ...(registryDefault.forwardCallerServiceTier !== undefined
170
- ? { forwardCallerServiceTier: registryDefault.forwardCallerServiceTier }
171
- : {}),
172
- };
173
- }
172
+ if (registryDefault !== undefined) {
173
+ return {
174
+ adapter: registryDefault.adapter,
175
+ hardPinned: false,
176
+ ...(registryDefault.forwardCallerServiceTier !== undefined
177
+ ? { forwardCallerServiceTier: registryDefault.forwardCallerServiceTier }
178
+ : {}),
179
+ };
174
180
  }
175
181
  }
176
182
  return { adapter: authority.providerAdapter, hardPinned: false };
@@ -1,5 +1,6 @@
1
1
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types";
2
2
  import { OPENAI_PROVIDER_TIER_VERSION } from "../types";
3
+ import { openaiResponsesUrl } from "../adapters/openai-responses-url";
3
4
  import { MAX_COST4_RATE } from "../usage/expected-prices";
4
5
 
5
6
  export const OPENAI_CODEX_PROVIDER_ID = "openai";
@@ -36,7 +37,35 @@ export function isCanonicalOpenAiForwardProvider(provider: OcxProviderConfig): b
36
37
  && normalizedBaseUrl(provider.baseUrl) === CODEX_FORWARD_BASE_URL;
37
38
  }
38
39
 
39
- const OPENAI_API_BASE_URL = "https://api.openai.com/v1";
40
+ const OPENAI_API_ORIGIN = "https://api.openai.com";
41
+ const OPENAI_API_BASE_URL = `${OPENAI_API_ORIGIN}/v1`;
42
+ const OPENAI_API_RESPONSES_URL = `${OPENAI_API_BASE_URL}/responses`;
43
+
44
+ /**
45
+ * The Responses endpoint the adapter would actually POST key-auth traffic to, normalized.
46
+ *
47
+ * Mirrors the adapter's own construction (`src/adapters/openai-responses.ts`): a configured
48
+ * `responsesPath` is appended to the base verbatim, and only the default branch runs the
49
+ * `/v1/responses` suffix normalization. Classifying on the base URL alone would call
50
+ * `baseUrl: "https://api.openai.com"` with `responsesPath: "/other"` official even though that
51
+ * request never reaches the official Responses endpoint.
52
+ */
53
+ function resolvedResponsesEndpoint(provider: OcxProviderConfig): string | undefined {
54
+ try {
55
+ const raw = provider.responsesPath === undefined
56
+ ? openaiResponsesUrl(provider.baseUrl)
57
+ : `${provider.baseUrl.replace(/\/$/, "")}${provider.responsesPath}`;
58
+ return normalizedBaseUrl(raw);
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ function isOfficialOpenAiResponsesDestination(provider: OcxProviderConfig): boolean {
65
+ // Exact normalized URL keeps lookalike/suffix hosts out of this set: `api.openai.com.evil.test`
66
+ // resolves to its own origin, never to the official one.
67
+ return resolvedResponsesEndpoint(provider) === OPENAI_API_RESPONSES_URL;
68
+ }
40
69
 
41
70
  /**
42
71
  * Whether this provider can serve `POST /responses/compact`. The canonical ChatGPT
@@ -54,6 +83,36 @@ export function supportsNativeResponsesCompactEndpoint(
54
83
  && normalizedBaseUrl(provider.baseUrl) === OPENAI_API_BASE_URL;
55
84
  }
56
85
 
86
+ /**
87
+ * Whether this destination is an OpenAI-operated Responses backend — the canonical ChatGPT Codex
88
+ * surface or the official OpenAI API.
89
+ *
90
+ * Deliberately not keyed on `authMode === "forward"`: a noncanonical forward provider does not
91
+ * receive the caller's credentials (see the forward-header gate in the Responses adapter), so
92
+ * forward auth says nothing about which backend is on the other end.
93
+ */
94
+ export function isOpenAiOperatedResponsesDestination(provider: OcxProviderConfig): boolean {
95
+ if (isCanonicalOpenAiForwardProvider(provider)) return true;
96
+ return provider.adapter === "openai-responses"
97
+ && isOfficialOpenAiResponsesDestination(provider);
98
+ }
99
+
100
+ /**
101
+ * Whether this destination can decode a native (non-`ocx1:`) compaction blob.
102
+ *
103
+ * Only the backend that minted a blob can decode it. `authMode: "forward"` alone is not a signal:
104
+ * the adapter forwards caller credentials only to the canonical ChatGPT Codex surface, while a
105
+ * noncanonical forward provider receives no caller credentials and may point at any backend.
106
+ *
107
+ * Relay only to an OpenAI-operated destination or a destination whose operator explicitly opts in.
108
+ * Keyed by destination rather than provider id: a blob's issuer is the URL that produced it, not the
109
+ * local config key a replay travels under.
110
+ */
111
+ export function destinationDecodesNativeCompactionBlob(provider: OcxProviderConfig): boolean {
112
+ return isOpenAiOperatedResponsesDestination(provider)
113
+ || provider.decodesNativeCompactionBlobs === true;
114
+ }
115
+
57
116
  export interface OpenAiTierMigrationProjection {
58
117
  config: OcxConfig;
59
118
  changed: boolean;
@@ -222,6 +222,8 @@ export interface ProviderRegistryEntry {
222
222
  * (and the canonical openai seed comparison keeps its exact key set).
223
223
  */
224
224
  supportsServiceTier?: boolean;
225
+ /** Registry default for OpenAI extended hosted web_search field support. */
226
+ supportsOpenAiWebSearchToolFields?: boolean;
225
227
  /** Registry default for exact model service-tier capability; explicit config keys win. */
226
228
  modelSupportsServiceTier?: Record<string, boolean>;
227
229
  /**
@@ -266,6 +268,8 @@ export interface ProviderRegistryEntry {
266
268
  * `supportsServiceTier`, which governs the Responses wire.
267
269
  */
268
270
  chatServiceTier?: boolean;
271
+ /** OpenAI Chat EOF policy for gateways that omit terminal frames after complete tool calls. */
272
+ openaiChatEofTolerance?: boolean;
269
273
  autoToolChoiceOnlyModels?: string[];
270
274
  preserveReasoningContentModels?: string[];
271
275
  requiresReasoningPlaceholderModels?: string[];
@@ -291,7 +295,7 @@ export type ProviderConfigSeed = Pick<
291
295
  | "modelMaxInputTokens" | "defaultMaxOutputTokens" | "modelMaxOutputTokens"
292
296
  | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "reasoningWireFormat"
293
297
  | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels"
294
- | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames"
298
+ | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "requiresReasoningPlaceholderModels" | "reasoningSplitModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" | "openaiChatEofTolerance"
295
299
  | "googleMode" | "project" | "location" | "headers"
296
300
  >;
297
301
 
@@ -1006,6 +1010,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1006
1010
  featured: true,
1007
1011
  oauthId: "xai",
1008
1012
  jawcodeBundle: "xai",
1013
+ supportsOpenAiWebSearchToolFields: false,
1009
1014
  note: "Log in with your Grok account",
1010
1015
  // Parallel tool calls: officially supported and default-on per docs.x.ai function-calling
1011
1016
  // (verified 260709, devlog/_plan/260709_parallel_tool_calls). Streamed calls arrive whole
@@ -1023,19 +1028,18 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1023
1028
  // grok-4.5; the reasoning ladder does not — 4.6 adds the documented xhigh rung.
1024
1029
  models: ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
1025
1030
  defaultModel: "grok-4.5",
1026
- // The current Grok CLI catalog declares both subscription models as native Responses
1027
- // backends. Keep API-key and translated Chat/Anthropic callers on their existing wire;
1028
- // Codex Responses traffic can relay xAI's SSE as it arrives instead of waiting for the
1029
- // Chat Completions compatibility stream to flush at the end of a reasoning turn.
1031
+ // Keep Codex Responses callers on the compatibility Chat wire until xAI can replay
1032
+ // opaque reasoning continuation and compaction state across later turns. The scoped
1033
+ // declaration also keeps caller-owned service tiers off the OAuth subscription route.
1030
1034
  modelWireDefaults: {
1031
1035
  "grok-4.6": {
1032
- wire: "openai-responses",
1036
+ wire: "openai-chat",
1033
1037
  inbound: ["responses"],
1034
1038
  authModes: ["oauth"],
1035
1039
  forwardCallerServiceTier: false,
1036
1040
  },
1037
1041
  "grok-4.5": {
1038
- wire: "openai-responses",
1042
+ wire: "openai-chat",
1039
1043
  inbound: ["responses"],
1040
1044
  authModes: ["oauth"],
1041
1045
  forwardCallerServiceTier: false,
@@ -1276,6 +1280,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1276
1280
  id: "opencode-go", label: "opencode go", adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1",
1277
1281
  authKind: "key", featured: true, dashboardUrl: "https://opencode.ai/auth", defaultModel: "kimi-k2.7-code",
1278
1282
  jawcodeBundle: "opencode-go", note: "GLM, DeepSeek, Kimi, Qwen, MiMo…",
1283
+ // Zen Go can close a Chat stream after a fully assembled function call without sending
1284
+ // finish_reason or [DONE] (#2260). The adapter still rejects incomplete argument JSON.
1285
+ openaiChatEofTolerance: true,
1279
1286
  /* [Decision Log]
1280
1287
  - 목적과 의도: Route GPT 5.6 Luna to the Responses endpoint that OpenCode Go documents for that exact model.
1281
1288
  - 기존 구현 및 제약 조건: The provider is mixed-wire but its provider-wide `openai-chat` adapter sent Luna to `/chat/completions`; explicit user `modelAdapters` entries must remain authoritative.
@@ -0,0 +1,15 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+
3
+ export const XAI_RESPONSES_OPT_IN_MODELS = ["grok-4.6", "grok-4.5"] as const;
4
+
5
+ export type XaiResponsesOptInState = boolean | "mixed";
6
+
7
+ /** Derived dashboard/API state for the two modelAdapters entries owned by the xAI opt-in. */
8
+ export function xaiResponsesOptInState(provider: OcxProviderConfig): XaiResponsesOptInState {
9
+ const enabled = XAI_RESPONSES_OPT_IN_MODELS.map(
10
+ model => provider.modelAdapters?.[model] === "openai-responses",
11
+ );
12
+ if (enabled.every(Boolean)) return true;
13
+ if (enabled.some(Boolean)) return "mixed";
14
+ return false;
15
+ }
@@ -33,6 +33,24 @@ export const SUMMARY_PREFIX = "Another language model started to solve this prob
33
33
 
34
34
  export const OPAQUE_COMPACTION_NOTE = "[earlier conversation was compacted; the summary is stored in a format this model cannot read]";
35
35
 
36
+ /**
37
+ * Item types in the compact wire family. Each carries an `encrypted_content` blob the client
38
+ * replays verbatim on every later turn, and the minting backend verifies it is unmodified.
39
+ *
40
+ * Keep this the only enumeration: a copy that listed just `compaction` let the response-side
41
+ * field backfill synthesize ids into the other two, which the client then replayed as "modified
42
+ * from the compact response".
43
+ */
44
+ const COMPACTION_ITEM_TYPES: ReadonlySet<string> = new Set([
45
+ "compaction",
46
+ "compaction_summary",
47
+ "context_compaction",
48
+ ]);
49
+
50
+ export function isCompactionItemType(type: unknown): boolean {
51
+ return typeof type === "string" && COMPACTION_ITEM_TYPES.has(type);
52
+ }
53
+
36
54
  export function encodeCompactionSummary(summary: string): string {
37
55
  return OCX_COMPACTION_PREFIX + Buffer.from(summary, "utf-8").toString("base64");
38
56
  }