@coseung2/opencodex 2.8.0-cs.16 → 2.8.0-cs.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/gui/dist/assets/{index-BZGMtkmp.js → index-Ch-99jy3.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/base.ts +12 -0
  5. package/src/adapters/kiro-calibration.ts +83 -0
  6. package/src/adapters/kiro-constants.ts +11 -2
  7. package/src/adapters/kiro-errors.ts +11 -0
  8. package/src/adapters/kiro-events.ts +19 -1
  9. package/src/adapters/kiro-thinking.ts +18 -2
  10. package/src/adapters/kiro-tools.ts +12 -3
  11. package/src/adapters/kiro.ts +300 -78
  12. package/src/adapters/openai-chat.ts +1 -42
  13. package/src/adapters/openai-responses.ts +93 -14
  14. package/src/adapters/xai-schema-analysis.ts +78 -0
  15. package/src/adapters/xai-tool-schema.ts +274 -0
  16. package/src/adapters/xai-web-search.ts +138 -0
  17. package/src/bridge.ts +61 -6
  18. package/src/codex/catalog/effort.ts +4 -2
  19. package/src/codex/catalog/metadata.ts +38 -9
  20. package/src/codex/catalog/parsing.ts +17 -2
  21. package/src/codex/catalog/provider-fetch.ts +9 -3
  22. package/src/codex/catalog/sync.ts +8 -5
  23. package/src/codex/data/upstream-models.json +169 -0
  24. package/src/grok/inject.ts +1 -1
  25. package/src/lib/token-estimate.ts +42 -38
  26. package/src/lib/translator-budget.ts +34 -0
  27. package/src/oauth/index.ts +10 -4
  28. package/src/oauth/kiro.ts +71 -6
  29. package/src/oauth/store.ts +3 -1
  30. package/src/oauth/types.ts +4 -0
  31. package/src/providers/derive.ts +7 -5
  32. package/src/providers/opencode-go-transport.ts +18 -0
  33. package/src/providers/registry.ts +34 -10
  34. package/src/providers/xai-transport.ts +10 -0
  35. package/src/responses/compaction.ts +8 -1
  36. package/src/responses/namespace-aliases.ts +56 -0
  37. package/src/responses/parser.ts +12 -0
  38. package/src/responses/reasoning-envelope.ts +9 -1
  39. package/src/responses/snapshot-policy.ts +108 -0
  40. package/src/responses/state.ts +23 -10
  41. package/src/responses/turn-termination.ts +108 -0
  42. package/src/responses/xai-custom-tool-compat.ts +237 -0
  43. package/src/server/grok-responses-snapshot-repair.ts +338 -0
  44. package/src/server/index.ts +2 -1
  45. package/src/server/relay-eager.ts +1 -0
  46. package/src/server/responses/core.ts +173 -13
  47. package/src/server/responses-image-gen-repair.ts +2 -2
  48. package/src/server/sse-payload-rewrite.ts +20 -3
  49. package/src/types.ts +10 -1
  50. package/src/usage/cost.ts +0 -0
  51. package/src/usage/expected-prices.ts +7 -0
  52. package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
package/src/oauth/kiro.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  type KiroImportDiagnostic,
28
28
  } from "./kiro-credentials";
29
29
  import { homedir } from "node:os";
30
+ import { KIRO_BUILDER_ID_SERVICE_PROFILE_ARN } from "../adapters/kiro-constants";
30
31
  import { getAccountSet, saveAccountCredential } from "./store";
31
32
 
32
33
  const DEFAULT_REGION = "us-east-1";
@@ -172,13 +173,37 @@ async function defaultKiroCliRunner(args: string[], signal?: AbortSignal): Promi
172
173
  }
173
174
  }
174
175
 
175
- async function readKiroCliIdentity(runner: KiroCliRunner, signal?: AbortSignal): Promise<{ email?: string }> {
176
+ /** Kiro profile ARN structure: arn:<partition>:codewhisperer:<region>:<account>:profile/<id> */
177
+ const KIRO_PROFILE_ARN_PATTERN = /^arn:[a-z0-9-]+:codewhisperer:[a-z0-9-]+:\d{12}:profile\/[A-Za-z0-9-]+$/;
178
+ const KIRO_PROFILE_ARN_MAX_LENGTH = 256;
179
+
180
+ function parseKiroProfileArn(value: unknown): string | undefined {
181
+ if (typeof value !== "string") return undefined;
182
+ const trimmed = value.trim();
183
+ if (trimmed.length === 0 || trimmed.length > KIRO_PROFILE_ARN_MAX_LENGTH) return undefined;
184
+ return KIRO_PROFILE_ARN_PATTERN.test(trimmed) ? trimmed : undefined;
185
+ }
186
+
187
+ function profileArnFromWhoami(parsed: Record<string, unknown>): string | undefined {
188
+ // Only narrowly-named documented-ish shapes; never invent an ARN (#993).
189
+ return parseKiroProfileArn(parsed.profileArn)
190
+ ?? parseKiroProfileArn(parsed.profile_arn)
191
+ ?? (parsed.profile && typeof parsed.profile === "object" && !Array.isArray(parsed.profile)
192
+ ? parseKiroProfileArn((parsed.profile as Record<string, unknown>).arn)
193
+ : undefined);
194
+ }
195
+
196
+ async function readKiroCliIdentity(runner: KiroCliRunner, signal?: AbortSignal): Promise<{ email?: string; profileArn?: string }> {
176
197
  try {
177
198
  const result = await runner(["whoami", "--format", "json"], signal);
178
199
  if (result.exitCode !== 0) return {};
179
- const parsed = JSON.parse(result.stdout) as { email?: unknown };
200
+ const parsed = JSON.parse(result.stdout) as Record<string, unknown>;
180
201
  const email = typeof parsed.email === "string" ? parsed.email.trim().toLowerCase() : "";
181
- return email && email.length <= 320 ? { email } : {};
202
+ const profileArn = profileArnFromWhoami(parsed);
203
+ return {
204
+ ...(email && email.length <= 320 ? { email } : {}),
205
+ ...(profileArn ? { profileArn } : {}),
206
+ };
182
207
  } catch {
183
208
  return {};
184
209
  }
@@ -191,6 +216,7 @@ function metadataFromImported(imported: ImportedKiroCredential): KiroOAuthMetada
191
216
  ...(imported.apiRegion ? { apiRegion: imported.apiRegion } : {}),
192
217
  ...(imported.clientId ? { clientId: imported.clientId } : {}),
193
218
  ...(imported.clientSecret ? { clientSecret: imported.clientSecret } : {}),
219
+ ...(imported.authType ? { authType: imported.authType } : {}),
194
220
  };
195
221
  return Object.keys(metadata).length > 0 ? metadata : undefined;
196
222
  }
@@ -251,14 +277,34 @@ async function oauthCredentialFromImported(
251
277
  runner: KiroCliRunner,
252
278
  signal?: AbortSignal,
253
279
  ): Promise<OAuthCredentials> {
254
- const identity = imported.source === "sqlite" ? await readKiroCliIdentity(runner, signal) : {};
255
- const metadata = metadataFromImported(imported);
280
+ let identity: { email?: string; profileArn?: string } = {};
281
+ if (imported.source === "sqlite") {
282
+ identity = await readKiroCliIdentity(runner, signal);
283
+ // Session-switch race (#993 review): another process may have switched the
284
+ // active Kiro CLI session between the SQLite read and whoami. Accept
285
+ // whoami's identity only when the session token STILL matches the import —
286
+ // refresh token, or access token when refresh is absent.
287
+ if (identity.profileArn !== undefined || identity.email !== undefined) {
288
+ const current = readKiroCliSqliteCredential();
289
+ const importedKey = imported.refresh || imported.access;
290
+ const currentKey = current ? current.refresh || current.access : "";
291
+ if (!current || currentKey !== importedKey) identity = {};
292
+ }
293
+ }
294
+ // Builder ID imports often lack a profileArn in SQLite; whoami against the
295
+ // SAME active CLI session can supply it (#993). Imported stays authoritative.
296
+ const resolvedProfileArn = imported.profileArn ?? identity.profileArn;
297
+ const metadata: KiroOAuthMetadata | undefined = (() => {
298
+ const base = metadataFromImported(imported) ?? {};
299
+ if (resolvedProfileArn && !base.profileArn) base.profileArn = resolvedProfileArn;
300
+ return Object.keys(base).length > 0 ? base : undefined;
301
+ })();
256
302
  return {
257
303
  access: imported.access,
258
304
  refresh: imported.refresh,
259
305
  expires: imported.expires,
260
306
  source: imported.source === "json" ? "credential-file" : "local-cli",
261
- ...(imported.profileArn ? { accountId: imported.profileArn } : {}),
307
+ ...(resolvedProfileArn ? { accountId: resolvedProfileArn } : {}),
262
308
  ...(identity.email ? { email: identity.email } : {}),
263
309
  ...(metadata ? { kiro: metadata } : {}),
264
310
  };
@@ -429,6 +475,25 @@ export function resolveKiroProfileArn(account?: Pick<KiroOAuthMetadata, "profile
429
475
  return readImportedKiroCredential()?.profileArn;
430
476
  }
431
477
 
478
+ /** Request profile and envelope choice are one decision; a fallback is never account identity. */
479
+ export function resolveKiroRequestProfile(
480
+ account?: Pick<KiroOAuthMetadata, "profileArn" | "authType">,
481
+ ): { profileArn: string | undefined; builderIdFallback: boolean } {
482
+ const own = resolveKiroProfileArn(account);
483
+ if (own) return { profileArn: own, builderIdFallback: false };
484
+ // An explicitly selected account must never borrow a different active local CLI account.
485
+ const authType = account === undefined ? readImportedKiroCredential()?.authType : account.authType;
486
+ return authType === "aws_sso_oidc"
487
+ ? { profileArn: KIRO_BUILDER_ID_SERVICE_PROFILE_ARN, builderIdFallback: true }
488
+ : { profileArn: undefined, builderIdFallback: false };
489
+ }
490
+
491
+ export function resolveKiroRequestProfileArn(
492
+ account?: Pick<KiroOAuthMetadata, "profileArn" | "authType">,
493
+ ): string | undefined {
494
+ return resolveKiroRequestProfile(account).profileArn;
495
+ }
496
+
432
497
  async function kiroTokenRefreshError(response: Response): Promise<KiroTokenRefreshError> {
433
498
  let oauthError: string | undefined;
434
499
  try {
@@ -247,13 +247,15 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null {
247
247
  const apiRegion = clean(kiro.apiRegion, 64);
248
248
  const clientId = clean(kiro.clientId, 4096);
249
249
  const clientSecret = clean(kiro.clientSecret, 4096);
250
- if (profileArn || ssoRegion || apiRegion || clientId || clientSecret) {
250
+ const authType = kiro.authType === "aws_sso_oidc" || kiro.authType === "kiro_desktop" ? kiro.authType : undefined;
251
+ if (profileArn || ssoRegion || apiRegion || clientId || clientSecret || authType) {
251
252
  normalized.kiro = {
252
253
  ...(profileArn ? { profileArn } : {}),
253
254
  ...(ssoRegion ? { ssoRegion } : {}),
254
255
  ...(apiRegion ? { apiRegion } : {}),
255
256
  ...(clientId ? { clientId } : {}),
256
257
  ...(clientSecret ? { clientSecret } : {}),
258
+ ...(authType ? { authType } : {}),
257
259
  };
258
260
  }
259
261
  }
@@ -1,6 +1,8 @@
1
1
  /** Minimal OAuth types, ported from jawcode packages/ai/src/utils/oauth/types.ts. */
2
2
  export type OAuthCredentialSource = "oauth" | "local-cli" | "credential-file" | "environment" | "manual";
3
3
 
4
+ export type KiroCredentialAuthType = "kiro_desktop" | "aws_sso_oidc";
5
+
4
6
  /** Account-scoped Kiro data required for refresh and request routing. */
5
7
  export interface KiroOAuthMetadata {
6
8
  profileArn?: string;
@@ -8,6 +10,8 @@ export interface KiroOAuthMetadata {
8
10
  apiRegion?: string;
9
11
  clientId?: string;
10
12
  clientSecret?: string;
13
+ /** Non-secret request-routing signal; never substitute for an account profile. */
14
+ authType?: KiroCredentialAuthType;
11
15
  }
12
16
 
13
17
  export type OAuthCredentials = {
@@ -239,15 +239,17 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
239
239
  if (!prov.models && seed.models) prov.models = [...seed.models];
240
240
  if (prov.liveModels === undefined && seed.liveModels !== undefined) prov.liveModels = seed.liveModels;
241
241
  if (prov.contextWindow === undefined && seed.contextWindow !== undefined) prov.contextWindow = seed.contextWindow;
242
- if (!prov.modelContextWindows && seed.modelContextWindows) prov.modelContextWindows = { ...seed.modelContextWindows };
242
+ if (seed.modelContextWindows) prov.modelContextWindows = { ...seed.modelContextWindows, ...prov.modelContextWindows };
243
243
  if (seed.modelInputModalities) prov.modelInputModalities = fillRecordOfArrays(seed.modelInputModalities, prov.modelInputModalities);
244
244
  if (prov.defaultMaxOutputTokens === undefined && seed.defaultMaxOutputTokens !== undefined) prov.defaultMaxOutputTokens = seed.defaultMaxOutputTokens;
245
- if (!prov.modelMaxOutputTokens && seed.modelMaxOutputTokens) prov.modelMaxOutputTokens = { ...seed.modelMaxOutputTokens };
245
+ if (seed.modelMaxOutputTokens) prov.modelMaxOutputTokens = { ...seed.modelMaxOutputTokens, ...prov.modelMaxOutputTokens };
246
246
  if (!prov.reasoningEfforts && seed.reasoningEfforts) prov.reasoningEfforts = [...seed.reasoningEfforts];
247
- if (!prov.modelReasoningEfforts && seed.modelReasoningEfforts) prov.modelReasoningEfforts = cloneRecordOfArrays(seed.modelReasoningEfforts);
248
- if (!prov.modelDefaultReasoningEfforts && seed.modelDefaultReasoningEfforts) prov.modelDefaultReasoningEfforts = { ...seed.modelDefaultReasoningEfforts };
247
+ // One customized model must not hide the registry's knowledge of every other
248
+ // model. Fill per key on new maps; explicit empty ladders remain authoritative.
249
+ if (seed.modelReasoningEfforts) prov.modelReasoningEfforts = fillRecordOfArrays(seed.modelReasoningEfforts, prov.modelReasoningEfforts);
250
+ if (seed.modelDefaultReasoningEfforts) prov.modelDefaultReasoningEfforts = { ...seed.modelDefaultReasoningEfforts, ...prov.modelDefaultReasoningEfforts };
249
251
  if (!prov.reasoningEffortMap && seed.reasoningEffortMap) prov.reasoningEffortMap = { ...seed.reasoningEffortMap };
250
- if (!prov.modelReasoningEffortMap && seed.modelReasoningEffortMap) prov.modelReasoningEffortMap = cloneNestedRecord(seed.modelReasoningEffortMap);
252
+ if (seed.modelReasoningEffortMap) prov.modelReasoningEffortMap = { ...cloneNestedRecord(seed.modelReasoningEffortMap), ...cloneNestedRecord(prov.modelReasoningEffortMap ?? {}) };
251
253
  if (!prov.noVisionModels && seed.noVisionModels) prov.noVisionModels = [...seed.noVisionModels];
252
254
  if (!prov.noReasoningModels && seed.noReasoningModels) prov.noReasoningModels = [...seed.noReasoningModels];
253
255
  if (!prov.noTemperatureModels && seed.noTemperatureModels) prov.noTemperatureModels = [...seed.noTemperatureModels];
@@ -4,6 +4,24 @@ import { registryEntryForProviderDestination } from "./registry";
4
4
 
5
5
  export const OPENCODE_GO_SESSION_HEADER = "x-opencode-session";
6
6
 
7
+ const MUSE_RESPONSE_MODELS = new Set(["muse-spark-1.2-contributor", "muse-spark-1.3-contributor"]);
8
+ const MUSE_RESPONSE_URLS = new Set([
9
+ "https://opencode.ai/zen/v1/responses",
10
+ "https://opencode.ai/zen/go/v1/responses",
11
+ ]);
12
+
13
+ /** Compatibility belongs to the exact model AND effective destination, not a provider label. */
14
+ export function isOpenCodeMuseResponses(modelId: unknown, responseUrl: string): boolean {
15
+ if (typeof modelId !== "string" || !MUSE_RESPONSE_MODELS.has(modelId.trim().toLowerCase())) return false;
16
+ try {
17
+ const url = new URL(responseUrl);
18
+ if (url.username || url.password || url.search || url.hash) return false;
19
+ return MUSE_RESPONSE_URLS.has(`${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`);
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
7
25
  function hasHeaderCaseInsensitive(
8
26
  headers: Record<string, string> | undefined,
9
27
  name: string,
@@ -29,7 +29,12 @@ export type InboundWire = "responses" | "chat" | "anthropic";
29
29
  * A per-model wire default: a bare string applies to every inbound, while the object
30
30
  * form applies only to the listed inbound protocols.
31
31
  */
32
- export type ModelWireDefault = string | { wire: string; inbound: readonly InboundWire[] };
32
+ export type ModelWireDefault = string | {
33
+ wire: string;
34
+ inbound: readonly InboundWire[];
35
+ /** Optional auth-mode gate for providers whose subscription and API-key products use different wires. */
36
+ authModes?: readonly ProviderAuthKind[];
37
+ };
33
38
 
34
39
  export type ProviderModelDiscoveryScalar = string | number | boolean;
35
40
 
@@ -700,6 +705,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
700
705
  // transport returns 400 ("Multi Agent requests are not allowed on chat completions").
701
706
  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"],
702
707
  defaultModel: "grok-4.5",
708
+ // Grok's subscription gateway exposes 4.6/4.5 natively on Responses. Scope the default to
709
+ // OAuth Codex traffic: API-key and Chat/Anthropic callers keep their existing wire, while an
710
+ // explicit modelAdapters override still wins over this registry-only default.
711
+ modelWireDefaults: {
712
+ "grok-4.6": { wire: "openai-responses", inbound: ["responses"], authModes: ["oauth"] },
713
+ "grok-4.5": { wire: "openai-responses", inbound: ["responses"], authModes: ["oauth"] },
714
+ },
703
715
  // Vision lineup per docs.x.ai model-capabilities/images/understanding: the grok-4.x chat
704
716
  // models accept image input (JPEG/PNG, URL or base64). Without this the catalog leaves
705
717
  // inputModalities undefined, and deriveComboCatalogModel defaults an undefined member to
@@ -824,16 +836,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
824
836
  featured: true,
825
837
  dashboardUrl: "https://platform.openai.com/api-keys",
826
838
  defaultModel: "gpt-5.5",
827
- models: ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS],
839
+ models: ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, "gpt-6-astra"],
828
840
  liveModels: true,
829
- modelContextWindows: OPENAI_API_GPT56_CONTEXT_WINDOWS,
830
- modelMaxInputTokens: OPENAI_API_GPT56_MAX_INPUT_TOKENS,
841
+ // API limits differ from the Codex-login Astra pin. Keep the public API ladder
842
+ // separate: low..max is documented, while the native catalog also carries ultra.
843
+ modelContextWindows: { ...OPENAI_API_GPT56_CONTEXT_WINDOWS, "gpt-6-astra": 1_050_000 },
844
+ modelMaxInputTokens: { ...OPENAI_API_GPT56_MAX_INPUT_TOKENS, "gpt-6-astra": 922_000 },
845
+ modelMaxOutputTokens: { "gpt-6-astra": 128_000 },
831
846
  modelInputModalities: Object.fromEntries(
832
- ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS].map(id => [id, ["text", "image"]]),
833
- ),
834
- modelReasoningEfforts: Object.fromEntries(
835
- [...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS].map(id => [id, OPENAI_API_GPT56_REASONING_EFFORTS]),
847
+ ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, "gpt-6-astra"].map(id => [id, ["text", "image"]]),
836
848
  ),
849
+ modelReasoningEfforts: {
850
+ ...Object.fromEntries([...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS].map(id => [id, OPENAI_API_GPT56_REASONING_EFFORTS])),
851
+ "gpt-6-astra": ["low", "medium", "high", "xhigh", "max"],
852
+ },
837
853
  virtualModels: OPENAI_API_GPT56_VIRTUAL_MODELS,
838
854
  },
839
855
  {
@@ -871,11 +887,15 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
871
887
  },
872
888
  modelContextWindows: {
873
889
  "kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW,
890
+ "muse-spark-1.3-contributor": 1_048_576,
891
+ "muse-spark-1.2-contributor": 1_048_576,
874
892
  [OPENCODE_OX_ALPHA_FREE_MODEL]: OX_ALPHA_CONTEXT_WINDOW,
875
893
  [DEEPSEEK_VISION_PREVIEW_MODEL]: 1_048_576,
876
894
  },
877
895
  modelInputModalities: {
878
896
  "kimi-k3": ["text", "image"],
897
+ "muse-spark-1.3-contributor": ["text", "image"],
898
+ "muse-spark-1.2-contributor": ["text", "image"],
879
899
  [OPENCODE_OX_ALPHA_FREE_MODEL]: ["text", "image"],
880
900
  [DEEPSEEK_VISION_PREVIEW_MODEL]: ["text", "image"],
881
901
  },
@@ -1799,8 +1819,12 @@ export function providerModelWireDefault(
1799
1819
  if (!entry?.modelWireDefaults || !providerMatchesRegistryTransport(id, provider)) return undefined;
1800
1820
  const declared = entry.modelWireDefaults[modelId.trim().toLowerCase()];
1801
1821
  if (declared === undefined) return undefined;
1802
- // A bare string applies to every inbound; the object form only to the listed ones.
1803
- if (typeof declared !== "string" && !declared.inbound.includes(inbound)) return undefined;
1822
+ // A bare string applies to every inbound/auth mode; the object form may narrow either.
1823
+ if (typeof declared !== "string") {
1824
+ if (!declared.inbound.includes(inbound)) return undefined;
1825
+ const authMode = provider.authMode ?? entry.authKind;
1826
+ if (declared.authModes && !declared.authModes.includes(authMode)) return undefined;
1827
+ }
1804
1828
  const wire = typeof declared === "string" ? declared : declared.wire;
1805
1829
  return wire !== undefined && allowedWires.has(wire) ? wire : undefined;
1806
1830
  }
@@ -22,6 +22,16 @@ export const XAI_GROK_COMPATIBILITY = {
22
22
  export const XAI_GROK_CLIENT_VERSION = XAI_GROK_COMPATIBILITY.version;
23
23
  export const XAI_CONV_ID_HEADER = XAI_GROK_COMPATIBILITY.headers.conversationId;
24
24
 
25
+ /** Both xAI Responses hosts share the same request dialect. */
26
+ export function isXaiResponsesDestination(provider: Pick<OcxProviderConfig, "baseUrl">): boolean {
27
+ try {
28
+ const hostname = new URL(provider.baseUrl).hostname.toLowerCase();
29
+ return hostname === "api.x.ai" || hostname === "cli-chat-proxy.grok.com";
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
25
35
  export type OcxProviderTransport = OcxProviderConfig & {
26
36
  /** Request executor used only at runtime; never persisted. */
27
37
  fetch?: typeof globalThis.fetch;
@@ -105,7 +105,14 @@ export function buildCompactV1Output(userMessages: string[], summary: string): R
105
105
  remaining -= msg.length;
106
106
  } else {
107
107
  // Budget partially covers this older message: keep its tail (most recent context) and stop.
108
- selected.push(msg.slice(msg.length - remaining));
108
+ let tailStart = msg.length - remaining;
109
+ // Never start the retained tail on a lone LOW surrogate: the pair's
110
+ // other half would be lost and encoding substitutes U+FFFD.
111
+ if (tailStart > 0 && tailStart < msg.length) {
112
+ const first = msg.charCodeAt(tailStart);
113
+ if (first >= 0xdc00 && first <= 0xdfff) tailStart += 1;
114
+ }
115
+ selected.push(msg.slice(tailStart));
109
116
  break;
110
117
  }
111
118
  }
@@ -0,0 +1,56 @@
1
+ import { namespacedToolName, type OcxTool } from "../types";
2
+ import type { TranslatorBudget } from "../lib/translator-budget";
3
+
4
+ export interface NamespacedToolIdentity { namespace: string; name: string }
5
+ type ToolIdentity = Pick<OcxTool, "namespace" | "name">;
6
+
7
+ function sameIdentity(left: ToolIdentity, right: ToolIdentity): boolean {
8
+ return (left.namespace ?? "") === (right.namespace ?? "") && left.name === right.name;
9
+ }
10
+
11
+ /**
12
+ * Resolve all ownership before publishing aliases. Dots may occur in both halves,
13
+ * and a flat declaration can own a spelling which looks namespaced. Neither the
14
+ * declaration order nor an ambiguous dotted name may choose a different tool.
15
+ */
16
+ export function declaredNamespaceAliases(
17
+ tools: readonly ToolIdentity[],
18
+ budget?: TranslatorBudget,
19
+ ): Map<string, NamespacedToolIdentity> {
20
+ const owners = new Map<string, ToolIdentity | null>();
21
+ const candidates = new Set<string>();
22
+ let temporaryBytes = 0;
23
+ const claim = (spelling: string, tool: ToolIdentity, candidate: boolean) => {
24
+ if (!owners.has(spelling)) {
25
+ const bytes = Buffer.byteLength(spelling) + Buffer.byteLength(tool.name)
26
+ + Buffer.byteLength(tool.namespace ?? "") + 64;
27
+ budget?.chargeRetained(bytes, { kind: "request_copies" });
28
+ temporaryBytes += bytes;
29
+ owners.set(spelling, tool);
30
+ } else {
31
+ const owner = owners.get(spelling);
32
+ if (owner && !sameIdentity(owner, tool)) owners.set(spelling, null);
33
+ }
34
+ if (candidate) candidates.add(spelling);
35
+ };
36
+ try {
37
+ for (const tool of tools) claim(namespacedToolName(tool.namespace, tool.name), tool, !!tool.namespace);
38
+ for (const tool of tools) {
39
+ if (!tool.namespace || tool.namespace.includes("__") || tool.name.includes("__")) continue;
40
+ claim(`${tool.namespace}.${tool.name}`, tool, true);
41
+ }
42
+ const aliases = new Map<string, NamespacedToolIdentity>();
43
+ for (const spelling of candidates) {
44
+ const owner = owners.get(spelling);
45
+ if (!owner?.namespace) continue;
46
+ budget?.chargeRetained(
47
+ Buffer.byteLength(spelling) + Buffer.byteLength(owner.namespace) + Buffer.byteLength(owner.name),
48
+ { kind: "request_copies" },
49
+ );
50
+ aliases.set(spelling, { namespace: owner.namespace, name: owner.name });
51
+ }
52
+ return aliases;
53
+ } finally {
54
+ budget?.releaseRetained(temporaryBytes, { kind: "request_copies" });
55
+ }
56
+ }
@@ -425,6 +425,18 @@ export function parseRequest(body: unknown): OcxParsedRequest {
425
425
  : null;
426
426
  const thinkingText = envelope?.txt || text;
427
427
 
428
+ // Kiro reasoning round-trip: a krc-only item carries nothing renderable — it is provider
429
+ // state for the assistant turn that ALREADY closed, because Kiro emits its
430
+ // reasoningContentEvent at the END of a turn (after content AND tool calls, verified
431
+ // against kiro-cli 2.14.1/2.16.0). Folding it into the FOLLOWING turn like ordinary
432
+ // reasoning would attach turn N's blob to turn N+1, so attach it backwards instead. With
433
+ // no assistant turn to own it the blob is dropped rather than mis-paired.
434
+ if (envelope?.krc && thinkingText.length === 0) {
435
+ const previous = messages[messages.length - 1];
436
+ if (previous?.role === "assistant") previous.kiroRedactedReasoning = envelope.krc;
437
+ continue;
438
+ }
439
+
428
440
  // Native/non-ocxr1 encrypted-only reasoning is opaque here. Do not create a detached
429
441
  // assistant turn or invent replayable plaintext/signatures from the encrypted payload.
430
442
  if (thinkingText.length > 0) {
@@ -24,6 +24,12 @@ export interface ReasoningEnvelope {
24
24
  * so replay needs it even though the visible summary was suppressed.
25
25
  */
26
26
  txt?: string;
27
+ /**
28
+ * Kiro `reasoningContentEvent.redactedContent`: a KMS-encrypted reasoning blob that is opaque to
29
+ * the proxy. Kiro's own CLI replays it on the matching `assistantResponseMessage` to preserve
30
+ * model reasoning across turns, so it round-trips here the same way a signature does.
31
+ */
32
+ krc?: string;
27
33
  }
28
34
 
29
35
  export function encodeReasoningEnvelope(envelope: ReasoningEnvelope): string {
@@ -45,7 +51,9 @@ export function decodeReasoningEnvelope(encryptedContent: string): ReasoningEnve
45
51
  }
46
52
  const txt = (parsed as { txt?: unknown }).txt;
47
53
  if (typeof txt === "string" && txt.length > 0) envelope.txt = txt;
48
- return envelope.sig || envelope.red || envelope.txt ? envelope : null;
54
+ const krc = (parsed as { krc?: unknown }).krc;
55
+ if (typeof krc === "string" && krc.length > 0) envelope.krc = krc;
56
+ return envelope.sig || envelope.red || envelope.txt || envelope.krc ? envelope : null;
49
57
  } catch {
50
58
  return null;
51
59
  }
@@ -0,0 +1,108 @@
1
+ import { lstatSync, realpathSync, statSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import {
4
+ forgetHardenedSecretPath,
5
+ hardenSecretPathAsync,
6
+ windowsSecretAclApplies,
7
+ } from "../lib/windows-secret-acl";
8
+
9
+ const BASE_DEBOUNCE_MS = 2_000;
10
+ const SCALE_FROM_BYTES = 1024 * 1024;
11
+ const MAX_DEBOUNCE_MS = 30_000;
12
+
13
+ /** Large snapshots trade a bounded hard-kill recovery window for fewer atomic replacements. */
14
+ export function responseSnapshotDebounceMs(bytes: number): number {
15
+ if (!Number.isFinite(bytes) || bytes <= SCALE_FROM_BYTES) return BASE_DEBOUNCE_MS;
16
+ return Math.min(MAX_DEBOUNCE_MS, Math.round(BASE_DEBOUNCE_MS * bytes / SCALE_FROM_BYTES));
17
+ }
18
+
19
+ export interface ResponseSnapshotWriteMetrics {
20
+ writes: number;
21
+ unchangedSkips: number;
22
+ bytesWritten: number;
23
+ lastSnapshotBytes: number;
24
+ debounceMs: number;
25
+ }
26
+
27
+ type SnapshotWrite = (path: string, payload: string) => Promise<void>;
28
+
29
+ /**
30
+ * Memoize only a digest, size and resolved target, never another copy of the snapshot.
31
+ * The state store owns the single-flight writer gate; this class does not queue writes.
32
+ */
33
+ export class ResponseSnapshotWriter {
34
+ private digest: string | null = null;
35
+ private target: string | null = null;
36
+ private bytes = 0;
37
+ private writes = 0;
38
+ private unchangedSkips = 0;
39
+ private bytesWritten = 0;
40
+
41
+ constructor(private readonly write: SnapshotWrite) {}
42
+
43
+ metrics(): ResponseSnapshotWriteMetrics {
44
+ return {
45
+ writes: this.writes,
46
+ unchangedSkips: this.unchangedSkips,
47
+ bytesWritten: this.bytesWritten,
48
+ lastSnapshotBytes: this.bytes,
49
+ debounceMs: responseSnapshotDebounceMs(this.bytes),
50
+ };
51
+ }
52
+
53
+ reset(): void {
54
+ this.digest = null;
55
+ this.target = null;
56
+ this.bytes = 0;
57
+ this.writes = 0;
58
+ this.unchangedSkips = 0;
59
+ this.bytesWritten = 0;
60
+ }
61
+
62
+ private async diskMatches(path: string, payload: string, bytes: number): Promise<boolean> {
63
+ try {
64
+ const before = lstatSync(path);
65
+ // Atomic replacement would replace a symlink or break a hardlink. An optimization must
66
+ // not silently preserve either, even if it currently resolves to identical bytes.
67
+ if (!before.isFile() || before.nlink !== 1 || before.size !== bytes) return false;
68
+ if (realpathSync(path) !== this.target) return false;
69
+ if (!windowsSecretAclApplies()) {
70
+ if ((before.mode & 0o777) !== 0o600) return false;
71
+ if ((statSync(dirname(path)).mode & 0o777) !== 0o700) return false;
72
+ } else {
73
+ // An ordinary atomic write hardens a new temp. A skipped write must still use the
74
+ // required publication policy, not a potentially stale pathname-only success memo.
75
+ forgetHardenedSecretPath(path);
76
+ try { await hardenSecretPathAsync(path, { required: true }); }
77
+ finally { forgetHardenedSecretPath(path); }
78
+ }
79
+ // The digest records our last write, not the current disk contents. A same-size edit,
80
+ // a second process or a deleted snapshot must never turn into a false cache hit.
81
+ if (await Bun.file(path).text() !== payload) return false;
82
+ const after = lstatSync(path);
83
+ return after.isFile() && after.nlink === 1 && after.dev === before.dev
84
+ && after.ino === before.ino && after.size === before.size
85
+ && after.mtimeMs === before.mtimeMs && realpathSync(path) === this.target;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ async persist(path: string, payload: string): Promise<void> {
92
+ const bytes = Buffer.byteLength(payload, "utf8");
93
+ const digest = Bun.hash(payload).toString(36);
94
+ if (this.digest === digest && this.bytes === bytes && await this.diskMatches(path, payload, bytes)) {
95
+ this.unchangedSkips += 1;
96
+ return;
97
+ }
98
+ await this.write(path, payload);
99
+ // Publish the memo only after a successful atomic write. A failed write keeps the previous
100
+ // fingerprint, which is harmless because every prospective skip rechecks the disk.
101
+ this.digest = digest;
102
+ this.bytes = bytes;
103
+ this.writes += 1;
104
+ this.bytesWritten += bytes;
105
+ try { this.target = realpathSync(path); }
106
+ catch { this.target = null; }
107
+ }
108
+ }
@@ -4,6 +4,7 @@ import { isDeepStrictEqual } from "node:util";
4
4
  import { atomicWriteFileAsync, getConfigDir } from "../config";
5
5
  import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
6
6
  import type { OcxProviderContinuationState } from "../types";
7
+ import { ResponseSnapshotWriter } from "./snapshot-policy";
7
8
  import {
8
9
  deleteResponseSpill,
9
10
  noteStubSwapForTest,
@@ -17,7 +18,6 @@ import {
17
18
 
18
19
  const MAX_STORED_RESPONSES = 1_000;
19
20
  const RESPONSE_TTL_MS = 60 * 60 * 1_000;
20
- const SNAPSHOT_DEBOUNCE_MS = 2_000;
21
21
  /** In-memory high-water byte cap across all entries. Forced store:false retention (kiro/cursor
22
22
  * continuation chains) stores the full expanded input each turn — ~quadratic bytes per chain —
23
23
  * so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
@@ -69,6 +69,17 @@ let oldestResidentId: string | undefined;
69
69
  let oldestResidentAt: number | null = null;
70
70
  let byteCapOverride: number | null = null;
71
71
  let stateRevision = 0;
72
+ const snapshotWriter = new ResponseSnapshotWriter(async (path, payload) => {
73
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
74
+ try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
75
+ await atomicWriteFileAsync(path, payload);
76
+ });
77
+
78
+ /** Scalar-only observation; does not load, prune or retain snapshot contents. */
79
+ export function responseSnapshotMetricsForTests() {
80
+ return snapshotWriter.metrics();
81
+ }
82
+
72
83
  const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0 };
73
84
  const admissionCounters = { directSpills: 0, oversizedDrops: 0, snapshotOversizedRefusals: 0 };
74
85
 
@@ -573,14 +584,14 @@ function ensureLoaded(): void {
573
584
 
574
585
  type SnapshotWriteOutcome = "stable" | "unstable" | "failed";
575
586
 
576
- async function writeBoundedSnapshot(path: string): Promise<SnapshotWriteOutcome> {
587
+ async function writeBoundedSnapshot(path: string, attemptLimit: number): Promise<SnapshotWriteOutcome> {
577
588
  // Serialize writers so concurrent flush + debounce cannot race on temps / ACL (#612).
578
589
  const previous = persistGate;
579
590
  let release!: () => void;
580
591
  persistGate = new Promise<void>(resolve => { release = resolve; });
581
592
  await previous;
582
593
  try {
583
- for (let attempt = 0; attempt < MAX_SNAPSHOT_REWRITE_ATTEMPTS; attempt += 1) {
594
+ for (let attempt = 0; attempt < attemptLimit; attempt += 1) {
584
595
  const revision = stateRevision;
585
596
  const entries: Array<[string, unknown]> = [];
586
597
  let total = 0;
@@ -602,9 +613,7 @@ async function writeBoundedSnapshot(path: string): Promise<SnapshotWriteOutcome>
602
613
  entries.push(persistEntry);
603
614
  }
604
615
  entries.reverse();
605
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
606
- try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
607
- await atomicWriteFileAsync(path, JSON.stringify({ version: 2, states: entries }));
616
+ await snapshotWriter.persist(path, JSON.stringify({ version: 2, states: entries }));
608
617
  persistAttemptHookForTests?.();
609
618
  if (revision === stateRevision) return "stable";
610
619
  }
@@ -627,7 +636,7 @@ function schedulePersistAt(path: string, replace = false): void {
627
636
  if (persistTimer && !replace) return;
628
637
  if (persistTimer) clearTimeout(persistTimer);
629
638
  pendingPersistPath = path;
630
- persistTimer = setTimeout(() => { void persistNow(path); }, SNAPSHOT_DEBOUNCE_MS);
639
+ persistTimer = setTimeout(() => { void persistNow(path); }, snapshotWriter.metrics().debounceMs);
631
640
  (persistTimer as { unref?: () => void }).unref?.();
632
641
  }
633
642
 
@@ -637,12 +646,15 @@ async function persistNow(path: string, awaitFollowUp = false): Promise<void> {
637
646
  persistTimer = null;
638
647
  }
639
648
  pendingPersistPath = null;
640
- let outcome = await writeBoundedSnapshot(path);
649
+ // Background traffic gets one write per debounce, not four immediate full-file rewrites.
650
+ // Explicit shutdown flushes retain the existing bounded stabilization contract.
651
+ const attemptLimit = awaitFollowUp ? MAX_SNAPSHOT_REWRITE_ATTEMPTS : 1;
652
+ let outcome = await writeBoundedSnapshot(path, attemptLimit);
641
653
  if (outcome === "unstable" && awaitFollowUp) {
642
654
  if (persistTimer) clearTimeout(persistTimer);
643
655
  persistTimer = null;
644
656
  pendingPersistPath = null;
645
- outcome = await writeBoundedSnapshot(path);
657
+ outcome = await writeBoundedSnapshot(path, attemptLimit);
646
658
  }
647
659
  if (outcome === "stable") drainPendingSpillUnlinks();
648
660
  else if (outcome === "unstable" && !awaitFollowUp) schedulePersistAt(path, true);
@@ -957,7 +969,7 @@ export function rememberResponseState(
957
969
  schedulePersist();
958
970
  }
959
971
 
960
- /** Test-only persistence churn hook; invoked after each atomic snapshot rewrite. */
972
+ /** Test-only persistence churn hook; invoked after each write/unchanged-validation attempt. */
961
973
  export function setResponseStatePersistAttemptHookForTests(hook: (() => void) | null): void {
962
974
  persistAttemptHookForTests = hook;
963
975
  }
@@ -986,6 +998,7 @@ export function clearResponseStateMemoryForTests(): void {
986
998
  oldestResidentId = undefined;
987
999
  oldestResidentAt = null;
988
1000
  stateRevision = 0;
1001
+ snapshotWriter.reset();
989
1002
  pendingSpillUnlinks.length = 0;
990
1003
  spillCounters.writes = 0;
991
1004
  spillCounters.writeFailures = 0;