@bitkyc08/opencodex 2.7.33 → 2.7.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +21 -10
  4. package/README.ru.md +1 -1
  5. package/README.zh-CN.md +1 -1
  6. package/gui/dist/assets/index-BkmJJgg6.js +52 -0
  7. package/gui/dist/assets/index-Sg-7L_oZ.css +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic.ts +13 -6
  11. package/src/adapters/cursor/discovery.ts +39 -4
  12. package/src/adapters/cursor/exec-policy.ts +11 -13
  13. package/src/adapters/cursor/live-transport.ts +22 -4
  14. package/src/adapters/cursor/protobuf-events.ts +140 -8
  15. package/src/adapters/cursor/protobuf-request.ts +15 -0
  16. package/src/adapters/cursor/request-builder.ts +10 -5
  17. package/src/adapters/cursor/transport.ts +3 -2
  18. package/src/adapters/cursor/types.ts +14 -0
  19. package/src/adapters/kiro-constants.ts +12 -0
  20. package/src/adapters/kiro-errors.ts +111 -2
  21. package/src/adapters/kiro-events.ts +154 -35
  22. package/src/adapters/kiro-retry.ts +116 -32
  23. package/src/adapters/kiro-tools.ts +30 -20
  24. package/src/adapters/kiro-wire.ts +47 -6
  25. package/src/adapters/kiro.ts +891 -228
  26. package/src/adapters/openai-chat.ts +12 -5
  27. package/src/adapters/openai-responses.ts +7 -2
  28. package/src/bridge.ts +109 -26
  29. package/src/claude/outbound.ts +27 -4
  30. package/src/cli/index.ts +1 -1
  31. package/src/codex/catalog.ts +375 -33
  32. package/src/combos/index.ts +3 -0
  33. package/src/combos/request.ts +4 -4
  34. package/src/combos/resolve.ts +2 -2
  35. package/src/combos/types.ts +104 -2
  36. package/src/config.ts +70 -1
  37. package/src/lib/eventstream-decoder.ts +9 -0
  38. package/src/oauth/index.ts +3 -1
  39. package/src/oauth/kiro-credentials.ts +48 -20
  40. package/src/oauth/login-cli.ts +2 -0
  41. package/src/providers/derive.ts +8 -0
  42. package/src/providers/kiro-models.ts +2 -2
  43. package/src/providers/openai-sidecar.ts +28 -1
  44. package/src/providers/registry.ts +39 -2
  45. package/src/responses/parser.ts +22 -10
  46. package/src/responses/schema.ts +1 -0
  47. package/src/responses/state.ts +50 -10
  48. package/src/router.ts +15 -3
  49. package/src/server/auth-cors.ts +7 -0
  50. package/src/server/claude-messages.ts +6 -0
  51. package/src/server/index.ts +6 -3
  52. package/src/server/management-api.ts +187 -43
  53. package/src/server/ports.ts +4 -2
  54. package/src/server/request-log.ts +3 -2
  55. package/src/server/responses-item-id-repair.ts +281 -0
  56. package/src/server/responses.ts +274 -73
  57. package/src/types.ts +109 -16
  58. package/src/update/job.ts +81 -1
  59. package/src/vision/describe.ts +2 -1
  60. package/src/web-search/executor.ts +2 -1
  61. package/src/web-search/loop.ts +9 -1
  62. package/src/web-search/progress-stream.ts +12 -10
  63. package/gui/dist/assets/index-D6Fcl4yM.css +0 -1
  64. package/gui/dist/assets/index-d63HMU0x.js +0 -52
@@ -1,12 +1,13 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
3
  import type { CatalogModel } from "../codex/catalog";
4
- import { invalidateCodexModelsCache, nativeModelRows } from "../codex/catalog";
4
+ import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../codex/catalog";
5
5
  import {
6
6
  DEFAULT_SUBAGENT_MODELS,
7
7
  codexAutoStartEnabled,
8
8
  hasOwnProvider,
9
9
  isValidProviderName,
10
+ multiAgentGuidanceEnabled,
10
11
  providerBaseUrlConfigError,
11
12
  providerHeadersConfigError,
12
13
  saveConfig,
@@ -813,21 +814,29 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
813
814
  ...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
814
815
  };
815
816
  });
816
- // Custom metadata wins when a live/static routed row resolves to the same Codex-facing slug.
817
- const customNamespaced = new Set(customModels.map(c => c.namespaced));
818
- const dedupedRouted = models.map(m => {
817
+ const publicModels = uniqueCatalogModelsForPublicList(models);
818
+ const comboNamespaced = new Set(
819
+ publicModels.filter(model => model.provider === "combo").map(catalogModelSlug),
820
+ );
821
+ const visibleCustomModels = customModels.filter(model => !comboNamespaced.has(model.namespaced));
822
+ // Custom metadata wins when a physical live/static row resolves to the same Codex-facing
823
+ // slug, while a combo keeps the same precedence it has in routing and /v1/models.
824
+ const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced));
825
+ const dedupedRouted = publicModels.map(m => {
819
826
  // Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms.
820
- const namespaced = routedSlug(m.provider, m.id);
821
- if (customNamespaced.has(namespaced)) return null;
827
+ const namespaced = catalogModelSlug(m);
828
+ if (m.provider !== "combo" && customNamespaced.has(namespaced)) return null;
822
829
  const contextCap = providerContextCap(config, m.provider);
823
830
  return {
824
831
  ...m,
825
832
  namespaced,
826
- disabled: [...disabled].some(stored => slugEquals(stored, m.provider, m.id)),
833
+ disabled: [...disabled].some(stored => (
834
+ stored === namespaced || slugEquals(stored, m.provider, m.id)
835
+ )),
827
836
  ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
828
837
  };
829
838
  }).filter(Boolean);
830
- return jsonResponse([...native, ...dedupedRouted, ...customModels]);
839
+ return jsonResponse([...native, ...dedupedRouted, ...visibleCustomModels]);
831
840
  }
832
841
 
833
842
  if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
@@ -1091,10 +1100,13 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1091
1100
  const nativeModels = listCatalogNativeSlugs()
1092
1101
  .filter(slug => !disabled.has(slug))
1093
1102
  .map(slug => ({ provider: "openai", model: slug, namespaced: slug }));
1094
- const routedModels = models
1095
- .map(m => ({ provider: m.provider, model: m.id, namespaced: routedSlug(m.provider, m.id) }))
1096
- .filter(m => ![...disabled].some(stored => slugEquals(stored, m.provider, m.model)));
1103
+ const routedModels = uniqueCatalogModelsForPublicList(models)
1104
+ .map(m => ({ provider: m.provider, model: m.id, namespaced: catalogModelSlug(m) }))
1105
+ .filter(m => ![...disabled].some(stored => (
1106
+ stored === m.namespaced || slugEquals(stored, m.provider, m.model)
1107
+ )));
1097
1108
  return jsonResponse({
1109
+ multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
1098
1110
  model: config.injectionModel ?? null,
1099
1111
  effort: config.injectionEffort ?? null,
1100
1112
  prompt: config.injectionPrompt ?? null,
@@ -1103,34 +1115,69 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1103
1115
  });
1104
1116
  }
1105
1117
  if (url.pathname === "/api/injection-model" && req.method === "PUT") {
1106
- let body: { model?: unknown; effort?: unknown; prompt?: unknown };
1107
- try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
1118
+ let parsedBody: unknown;
1119
+ try { parsedBody = await req.json(); } catch {
1120
+ return jsonResponse({ error: "invalid JSON body" }, 400);
1121
+ }
1122
+ if (!parsedBody || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
1123
+ return jsonResponse({ error: "body must be a JSON object" }, 400);
1124
+ }
1125
+ const body = parsedBody as {
1126
+ multiAgentGuidanceEnabled?: unknown;
1127
+ model?: unknown;
1128
+ effort?: unknown;
1129
+ prompt?: unknown;
1130
+ };
1108
1131
  const { isCodexReasoningEffort } = await import("../reasoning-effort");
1109
- const model = typeof body.model === "string" && body.model.length > 0 ? body.model : undefined;
1110
- let effort = config.injectionEffort;
1111
- // `effort` key semantics: absent -> unchanged; null/"" -> clear; ladder value -> set;
1112
- // anything else -> 400. Clearing the model always clears the effort (it is meaningless alone).
1132
+
1133
+ let nextEnabled = config.multiAgentGuidanceEnabled;
1134
+ let nextModel = config.injectionModel;
1135
+ let nextEffort = config.injectionEffort;
1136
+ let nextPrompt = config.injectionPrompt;
1137
+
1138
+ if ("multiAgentGuidanceEnabled" in body) {
1139
+ if (typeof body.multiAgentGuidanceEnabled !== "boolean") {
1140
+ return jsonResponse({ error: "multiAgentGuidanceEnabled must be a boolean" }, 400);
1141
+ }
1142
+ nextEnabled = body.multiAgentGuidanceEnabled;
1143
+ }
1144
+ if ("model" in body) {
1145
+ if (body.model === null || body.model === "") nextModel = undefined;
1146
+ else if (typeof body.model === "string" && body.model.length > 0) nextModel = body.model;
1147
+ else return jsonResponse({ error: "model must be a non-empty string or null" }, 400);
1148
+ }
1113
1149
  if ("effort" in body) {
1114
- const requestedEffort = typeof body.effort === "string" && body.effort.length > 0 ? body.effort : undefined;
1115
- if (requestedEffort !== undefined && !isCodexReasoningEffort(requestedEffort)) {
1116
- return jsonResponse({ error: `unknown reasoning effort "${requestedEffort}"` }, 400);
1150
+ if (body.effort === null || body.effort === "") nextEffort = undefined;
1151
+ else if (typeof body.effort === "string" && isCodexReasoningEffort(body.effort)) {
1152
+ nextEffort = body.effort;
1153
+ } else {
1154
+ return jsonResponse({ error: `unknown reasoning effort "${String(body.effort)}"` }, 400);
1117
1155
  }
1118
- effort = requestedEffort;
1119
1156
  }
1120
- if (!model) effort = undefined;
1121
- if (model) config.injectionModel = model;
1122
- else delete config.injectionModel;
1123
- if (effort) config.injectionEffort = effort;
1124
- else delete config.injectionEffort;
1125
- // `prompt` key semantics mirror `effort`: absent -> unchanged; null/"" -> clear;
1126
- // non-empty string -> set (custom <multi_agent_mode> body, {{model}}/{{effort}}/{{roster}} placeholders).
1127
1157
  if ("prompt" in body) {
1128
- if (typeof body.prompt === "string" && body.prompt.trim().length > 0) config.injectionPrompt = body.prompt;
1129
- else if (body.prompt === null || body.prompt === "") delete config.injectionPrompt;
1158
+ if (typeof body.prompt === "string" && body.prompt.trim().length > 0) nextPrompt = body.prompt;
1159
+ else if (body.prompt === null || body.prompt === "") nextPrompt = undefined;
1130
1160
  else return jsonResponse({ error: "prompt must be a string or null" }, 400);
1131
1161
  }
1162
+ // Clearing the model always clears the effort (it is meaningless alone).
1163
+ if (!nextModel) nextEffort = undefined;
1164
+
1165
+ config.multiAgentGuidanceEnabled = nextEnabled;
1166
+ if (nextModel) config.injectionModel = nextModel;
1167
+ else delete config.injectionModel;
1168
+ if (nextEffort) config.injectionEffort = nextEffort;
1169
+ else delete config.injectionEffort;
1170
+ if (nextPrompt) config.injectionPrompt = nextPrompt;
1171
+ else delete config.injectionPrompt;
1172
+
1132
1173
  saveConfig(config);
1133
- return jsonResponse({ ok: true, model: config.injectionModel ?? null, effort: config.injectionEffort ?? null, prompt: config.injectionPrompt ?? null });
1174
+ return jsonResponse({
1175
+ ok: true,
1176
+ multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
1177
+ model: config.injectionModel ?? null,
1178
+ effort: config.injectionEffort ?? null,
1179
+ prompt: config.injectionPrompt ?? null,
1180
+ });
1134
1181
  }
1135
1182
 
1136
1183
  // Hard reasoning-effort caps (devlog/260710_subagent_effort_intercept): a global ceiling and a
@@ -1169,9 +1216,11 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1169
1216
  // Native gpt (passthrough) are also valid subagent picks — they're picker-visible models in the
1170
1217
  // catalog, just buried by priority. List them first so the user can feature them over routed.
1171
1218
  const { listCatalogNativeSlugs } = await import("../codex/catalog");
1172
- const visibleRouted = models
1173
- .filter(m => ![...disabled].some(stored => slugEquals(stored, m.provider, m.id)))
1174
- .map(m => routedSlug(m.provider, m.id));
1219
+ const visibleRouted = [...new Set(models
1220
+ .filter(m => ![...disabled].some(stored =>
1221
+ stored === catalogModelSlug(m) || slugEquals(stored, m.provider, m.id)
1222
+ ))
1223
+ .map(catalogModelSlug))];
1175
1224
  const available = [
1176
1225
  ...listCatalogNativeSlugs().filter(ns => !disabled.has(ns)),
1177
1226
  ...visibleRouted,
@@ -1229,6 +1278,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1229
1278
  tierModels: config.claudeCode?.tierModels ?? {},
1230
1279
  modelMap: config.claudeCode?.modelMap ?? {},
1231
1280
  systemEnv: config.claudeCode?.systemEnv === true,
1281
+ autoConnectSupported: process.platform === "darwin",
1232
1282
  maxContextTokens: config.claudeCode?.maxContextTokens ?? null,
1233
1283
  alwaysEnableEffort: config.claudeCode?.alwaysEnableEffort === true,
1234
1284
  autoContext: config.claudeCode?.autoContext !== false,
@@ -1692,12 +1742,15 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1692
1742
  }
1693
1743
 
1694
1744
  if (url.pathname === "/api/combos" && req.method === "GET") {
1695
- const { comboModelId, getCombo, listComboIds } = await import("../combos");
1696
- return jsonResponse({ combos: listComboIds(config).map(id => ({
1697
- id,
1698
- model: comboModelId(id),
1699
- ...getCombo(config, id)!,
1700
- })) });
1745
+ const { comboPublicModelId, getCombo, listComboIds } = await import("../combos");
1746
+ return jsonResponse({ combos: listComboIds(config).map(id => {
1747
+ const combo = getCombo(config, id)!;
1748
+ return {
1749
+ id,
1750
+ model: comboPublicModelId(id, combo),
1751
+ ...combo,
1752
+ };
1753
+ }) });
1701
1754
  }
1702
1755
 
1703
1756
  if (url.pathname === "/api/combos" && req.method === "PUT") {
@@ -1711,18 +1764,109 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
1711
1764
  return jsonResponse({ error: "id is required and must be a string" }, 400);
1712
1765
  }
1713
1766
  const id = body.id.trim();
1714
- const { comboConfigError, normalizeComboConfig, comboModelId, clearComboSelectionState, clearComboTargetCooldowns } = await import("../combos");
1767
+ let renameFrom: string | undefined;
1768
+ if (body.renameFrom !== undefined) {
1769
+ if (typeof body.renameFrom !== "string" || !body.renameFrom.trim()) {
1770
+ return jsonResponse({ error: "renameFrom must be a non-empty string" }, 400);
1771
+ }
1772
+ renameFrom = body.renameFrom.trim();
1773
+ if (renameFrom === id) {
1774
+ return jsonResponse({ error: "renameFrom must differ from id" }, 400);
1775
+ }
1776
+ if (!Object.hasOwn(config.combos ?? {}, renameFrom)) {
1777
+ return jsonResponse({ error: `combo "${renameFrom}" does not exist` }, 400);
1778
+ }
1779
+ if (Object.hasOwn(config.combos ?? {}, id)) {
1780
+ return jsonResponse({ error: `combo "${id}" already exists` }, 400);
1781
+ }
1782
+ }
1783
+ const {
1784
+ clearComboSelectionState,
1785
+ clearComboTargetCooldowns,
1786
+ comboConfigError,
1787
+ comboModelId,
1788
+ comboPublicModelId,
1789
+ normalizeComboConfig,
1790
+ } = await import("../combos");
1715
1791
  const error = comboConfigError(id, body.combo, config.providers, {
1716
1792
  requireEnabledTarget: true,
1793
+ combos: config.combos,
1794
+ excludeComboId: renameFrom ?? id,
1717
1795
  });
1718
1796
  if (error) return jsonResponse({ error }, 400);
1719
1797
  const normalized = normalizeComboConfig(body.combo as import("../types").OcxComboConfig);
1720
- config.combos = { ...(config.combos ?? {}), [id]: normalized };
1798
+ const stored: import("../types").OcxComboConfig = normalized.alias === null
1799
+ ? (({ alias: _alias, ...rest }) => rest)(normalized)
1800
+ : normalized;
1801
+ const sourceId = renameFrom ?? id;
1802
+ const previous = config.combos?.[sourceId];
1803
+ const oldPublicModel = previous ? comboPublicModelId(sourceId, previous) : null;
1804
+ const newPublicModel = comboPublicModelId(id, normalized);
1805
+ const nextCombos = { ...(config.combos ?? {}) };
1806
+ if (renameFrom) delete nextCombos[renameFrom];
1807
+ nextCombos[id] = stored;
1808
+ config.combos = nextCombos;
1809
+ let shouldSyncClaudeAgentDefs = false;
1810
+ const migratedModels = new Set<string>();
1811
+ if (oldPublicModel && oldPublicModel !== newPublicModel) {
1812
+ migratedModels.add(oldPublicModel);
1813
+ }
1814
+ if (renameFrom) migratedModels.add(comboModelId(renameFrom));
1815
+ if (migratedModels.size > 0) {
1816
+ const migrateReference = (model: string): string => (
1817
+ migratedModels.has(model) ? newPublicModel : model
1818
+ );
1819
+ const migrateAgentReference = (model: string): string => {
1820
+ const migrated = migrateReference(model);
1821
+ if (migrated !== model) shouldSyncClaudeAgentDefs = true;
1822
+ return migrated;
1823
+ };
1824
+ const migrateReferences = (models: string[]): string[] => [
1825
+ ...new Set(models.map(migrateReference)),
1826
+ ];
1827
+ if (config.disabledModels) {
1828
+ config.disabledModels = migrateReferences(config.disabledModels);
1829
+ }
1830
+ if (config.subagentModels) {
1831
+ config.subagentModels = [...new Set(config.subagentModels.map(migrateAgentReference))];
1832
+ }
1833
+ if (config.injectionModel && migratedModels.has(config.injectionModel)) {
1834
+ config.injectionModel = newPublicModel;
1835
+ }
1836
+ if (config.shadowCallIntercept?.model && migratedModels.has(config.shadowCallIntercept.model)) {
1837
+ config.shadowCallIntercept = {
1838
+ ...config.shadowCallIntercept,
1839
+ model: newPublicModel,
1840
+ };
1841
+ }
1842
+ if (config.claudeCode) {
1843
+ const claudeCode = { ...config.claudeCode };
1844
+ for (const field of ["model", "smallFastModel"] as const) {
1845
+ if (claudeCode[field]) claudeCode[field] = migrateAgentReference(claudeCode[field]);
1846
+ }
1847
+ if (claudeCode.tierModels) {
1848
+ claudeCode.tierModels = Object.fromEntries(
1849
+ Object.entries(claudeCode.tierModels).map(([tier, model]) => [tier, migrateAgentReference(model)]),
1850
+ );
1851
+ }
1852
+ if (claudeCode.modelMap) {
1853
+ claudeCode.modelMap = Object.fromEntries(
1854
+ Object.entries(claudeCode.modelMap).map(([source, model]) => [source, migrateAgentReference(model)]),
1855
+ );
1856
+ }
1857
+ config.claudeCode = claudeCode;
1858
+ }
1859
+ }
1721
1860
  saveConfig(config);
1722
1861
  clearComboSelectionState(id);
1723
1862
  clearComboTargetCooldowns(id);
1863
+ if (renameFrom) {
1864
+ clearComboSelectionState(renameFrom);
1865
+ clearComboTargetCooldowns(renameFrom);
1866
+ }
1724
1867
  await refreshCodexCatalogBestEffort();
1725
- return jsonResponse({ success: true, id, model: comboModelId(id), combo: normalized });
1868
+ if (shouldSyncClaudeAgentDefs) await syncClaudeAgentDefsBestEffort();
1869
+ return jsonResponse({ success: true, id, model: newPublicModel, combo: normalized });
1726
1870
  }
1727
1871
 
1728
1872
  if (url.pathname === "/api/combos" && req.method === "DELETE") {
@@ -73,14 +73,16 @@ export async function findAvailablePort(
73
73
  ): Promise<number> {
74
74
  const preferRetryMs = opts.preferRetryMs ?? 0;
75
75
  const allowEphemeral = opts.allowEphemeralFallback !== false;
76
- if (preferRetryMs > 0) {
76
+ // Port 0 asks the OS to select an ephemeral port. Resolve it to that concrete
77
+ // port here so callers never persist or advertise an unusable `:0` endpoint.
78
+ if (preferredPort > 0 && preferRetryMs > 0) {
77
79
  if (await waitForPortAvailable(preferredPort, hostname, {
78
80
  timeoutMs: preferRetryMs,
79
81
  intervalMs: opts.preferRetryIntervalMs ?? 50,
80
82
  })) {
81
83
  return preferredPort;
82
84
  }
83
- } else if (await isPortAvailable(preferredPort, hostname)) {
85
+ } else if (preferredPort > 0 && (await isPortAvailable(preferredPort, hostname))) {
84
86
  return preferredPort;
85
87
  }
86
88
 
@@ -35,6 +35,8 @@ export interface RequestLogContext {
35
35
  firstOutputMs?: number;
36
36
  surface?: "claude";
37
37
  requestedModel?: string;
38
+ /** Internal structural combo identity; omitted from RequestLogEntry/JSONL. */
39
+ comboId?: string;
38
40
  requestedEffort?: string;
39
41
  requestedServiceTier?: string;
40
42
  requestedSpeedLabel?: string;
@@ -564,8 +566,7 @@ export function addFinalRequestLog(
564
566
  recoveryKinds: [...attempt.recoveryKinds],
565
567
  ...(attempt.usage ? { usage: { ...attempt.usage } } : {}),
566
568
  }));
567
- const isCombo = (logCtx.requestedModel ?? "").startsWith("combo/")
568
- && (attempts?.length ?? 0) > 0;
569
+ const isCombo = logCtx.comboId !== undefined && (attempts?.length ?? 0) > 0;
569
570
  const aggregate = isCombo ? aggregateAttemptUsage(attempts ?? []) : null;
570
571
  const loggedUsage = aggregate?.usage ?? existing.usage;
571
572
  const usageStatus = aggregate?.status ?? existing.status;
@@ -0,0 +1,281 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ResponsesItemIdRepairConfig } from "../types";
3
+
4
+ type RepairableItemType = "message" | "reasoning";
5
+
6
+ interface ResponsesItemIdRepairState {
7
+ readonly repairMissingTerminalIds: boolean;
8
+ readonly placeholders: Record<RepairableItemType, ReadonlySet<string>>;
9
+ readonly outputIds: Record<RepairableItemType, Map<number, string>>;
10
+ readonly scope: string;
11
+ }
12
+
13
+ const REPAIRABLE_PREFIXES: Record<RepairableItemType, string> = {
14
+ message: "msg_",
15
+ reasoning: "rs_",
16
+ };
17
+
18
+ const ITEM_ID_EVENT_TYPES: Readonly<Record<string, RepairableItemType>> = {
19
+ "response.content_part.added": "message",
20
+ "response.content_part.done": "message",
21
+ "response.output_text.annotation.added": "message",
22
+ "response.output_text.delta": "message",
23
+ "response.output_text.done": "message",
24
+ "response.refusal.delta": "message",
25
+ "response.refusal.done": "message",
26
+ "response.reasoning_summary_part.added": "reasoning",
27
+ "response.reasoning_summary_part.done": "reasoning",
28
+ "response.reasoning_summary_text.delta": "reasoning",
29
+ "response.reasoning_summary_text.done": "reasoning",
30
+ "response.reasoning_text.delta": "reasoning",
31
+ "response.reasoning_text.done": "reasoning",
32
+ };
33
+
34
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
35
+ return !!value && typeof value === "object" && !Array.isArray(value);
36
+ }
37
+
38
+ function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: string } | null {
39
+ const match = buffer.match(/\r?\n\r?\n/);
40
+ if (!match || match.index === undefined) return null;
41
+ return {
42
+ block: buffer.slice(0, match.index),
43
+ delimiter: match[0],
44
+ rest: buffer.slice(match.index + match[0].length),
45
+ };
46
+ }
47
+
48
+ function sseDataPayload(block: string): string | null {
49
+ const data: string[] = [];
50
+ for (const line of block.split(/\r?\n/)) {
51
+ if (!line.startsWith("data:")) continue;
52
+ const value = line.slice(5);
53
+ data.push(value.startsWith(" ") ? value.slice(1) : value);
54
+ }
55
+ return data.length > 0 ? data.join("\n") : null;
56
+ }
57
+
58
+ function replaceSseDataPayload(block: string, payload: string): string {
59
+ const newline = block.includes("\r\n") ? "\r\n" : "\n";
60
+ const lines = block.split(/\r?\n/);
61
+ const rewritten: string[] = [];
62
+ let replaced = false;
63
+ for (const line of lines) {
64
+ if (!line.startsWith("data:")) {
65
+ rewritten.push(line);
66
+ continue;
67
+ }
68
+ if (!replaced) {
69
+ rewritten.push(`data: ${payload}`);
70
+ replaced = true;
71
+ }
72
+ }
73
+ return replaced ? rewritten.join(newline) : block;
74
+ }
75
+
76
+ function asOutputIndex(value: unknown): number | null {
77
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
78
+ }
79
+
80
+ function repairableItemType(item: Record<string, unknown>): RepairableItemType | null {
81
+ return item.type === "message" || item.type === "reasoning" ? item.type : null;
82
+ }
83
+
84
+ function mintCanonicalId(type: RepairableItemType, scope: string, outputIndex: number): string {
85
+ return `${REPAIRABLE_PREFIXES[type]}ocx_${scope}_${outputIndex}`;
86
+ }
87
+
88
+ function createRepairState(config: ResponsesItemIdRepairConfig): ResponsesItemIdRepairState {
89
+ return {
90
+ repairMissingTerminalIds: config.repairMissingTerminalIds === true,
91
+ placeholders: {
92
+ message: new Set(config.message ?? []),
93
+ reasoning: new Set(config.reasoning ?? []),
94
+ },
95
+ outputIds: {
96
+ message: new Map<number, string>(),
97
+ reasoning: new Map<number, string>(),
98
+ },
99
+ scope: randomUUID().replace(/-/g, ""),
100
+ };
101
+ }
102
+
103
+ function rememberMappedId(
104
+ state: ResponsesItemIdRepairState,
105
+ outputIndex: number,
106
+ item: Record<string, unknown>,
107
+ ): string | null {
108
+ const type = repairableItemType(item);
109
+ if (!type) return null;
110
+ const existing = state.outputIds[type].get(outputIndex);
111
+ if (existing) return existing;
112
+ const rawId = typeof item.id === "string" ? item.id : undefined;
113
+ if (!rawId) return null;
114
+ const mapped = state.placeholders[type].has(rawId)
115
+ ? mintCanonicalId(type, state.scope, outputIndex)
116
+ : state.repairMissingTerminalIds
117
+ ? rawId
118
+ : null;
119
+ if (!mapped) return null;
120
+ state.outputIds[type].set(outputIndex, mapped);
121
+ return mapped;
122
+ }
123
+
124
+ function rewriteOutputItem(
125
+ state: ResponsesItemIdRepairState,
126
+ outputIndex: number,
127
+ item: Record<string, unknown>,
128
+ ): { item: Record<string, unknown>; changed: boolean } {
129
+ const mapped = rememberMappedId(state, outputIndex, item);
130
+ if (!mapped) return { item, changed: false };
131
+ const currentId = typeof item.id === "string" ? item.id : undefined;
132
+ if (currentId === mapped) return { item, changed: false };
133
+ if (currentId === undefined && !state.repairMissingTerminalIds) return { item, changed: false };
134
+ return { item: { ...item, id: mapped }, changed: true };
135
+ }
136
+
137
+ function rewriteItemIdField(
138
+ state: ResponsesItemIdRepairState,
139
+ event: Record<string, unknown>,
140
+ outputIndex: number,
141
+ ): { event: Record<string, unknown>; changed: boolean } {
142
+ const eventType = typeof event.type === "string" ? ITEM_ID_EVENT_TYPES[event.type] : undefined;
143
+ if (!eventType) return { event, changed: false };
144
+ const mapped = state.outputIds[eventType].get(outputIndex);
145
+ if (!mapped) return { event, changed: false };
146
+ const currentId = typeof event.item_id === "string" ? event.item_id : undefined;
147
+ if (currentId === mapped) return { event, changed: false };
148
+ if (currentId === undefined && !state.repairMissingTerminalIds) return { event, changed: false };
149
+ return { event: { ...event, item_id: mapped }, changed: true };
150
+ }
151
+
152
+ function rewriteResponseSnapshot(
153
+ state: ResponsesItemIdRepairState,
154
+ response: Record<string, unknown>,
155
+ ): { response: Record<string, unknown>; changed: boolean } {
156
+ if (!Array.isArray(response.output)) return { response, changed: false };
157
+ let changed = false;
158
+ const output = response.output.map((item, outputIndex) => {
159
+ if (!isPlainObject(item)) return item;
160
+ const rewritten = rewriteOutputItem(state, outputIndex, item);
161
+ changed = changed || rewritten.changed;
162
+ return rewritten.item;
163
+ });
164
+ return changed ? { response: { ...response, output }, changed: true } : { response, changed: false };
165
+ }
166
+
167
+ function repairEventPayload(
168
+ payload: string,
169
+ state: ResponsesItemIdRepairState,
170
+ ): string {
171
+ let event: unknown;
172
+ try {
173
+ event = JSON.parse(payload);
174
+ } catch {
175
+ return payload;
176
+ }
177
+ if (!isPlainObject(event)) return payload;
178
+
179
+ let changed = false;
180
+ let nextEvent = event;
181
+ const outputIndex = asOutputIndex(event.output_index);
182
+ if (outputIndex !== null && isPlainObject(event.item)) {
183
+ const rewritten = rewriteOutputItem(state, outputIndex, event.item);
184
+ if (rewritten.changed) {
185
+ nextEvent = { ...nextEvent, item: rewritten.item };
186
+ changed = true;
187
+ }
188
+ }
189
+ if (outputIndex !== null) {
190
+ const rewritten = rewriteItemIdField(state, nextEvent, outputIndex);
191
+ if (rewritten.changed) {
192
+ nextEvent = rewritten.event;
193
+ changed = true;
194
+ }
195
+ }
196
+ if (isPlainObject(event.response)) {
197
+ const rewritten = rewriteResponseSnapshot(state, event.response);
198
+ if (rewritten.changed) {
199
+ nextEvent = { ...nextEvent, response: rewritten.response };
200
+ changed = true;
201
+ }
202
+ }
203
+ return changed ? JSON.stringify(nextEvent) : payload;
204
+ }
205
+
206
+ /**
207
+ * [Decision Log]
208
+ * - 목적과 의도: 일부 openai-responses 호환 게이트웨이가 재사용/누락하는 message·reasoning item id를
209
+ * downstream SSE에서만 선택적으로 보정해 Codex Desktop 카드 상관관계를 안정화한다.
210
+ * - 기존 구현 및 제약 조건: 기본 passthrough는 바이트 단위 그대로 relay되고, local replay 상태는 raw
211
+ * upstream 응답을 기억한다. function_call id / call_id는 upstream 의미가 있으므로 절대 바꾸면 안 된다.
212
+ * - 검토한 주요 대안: 모든 passthrough SSE를 항상 재작성하기, raw inspect 분기까지 함께 재작성하기,
213
+ * function_call 포함 전체 item id를 정규화하기.
214
+ * - 선택한 방식: provider-local opt-in 설정이 있을 때만 client-facing SSE 분기에 한정해 exact
215
+ * message/reasoning placeholder id와 missing terminal id를 item type + output_index 기준으로 보정하고,
216
+ * event-level item_id는 명시적인 message/reasoning lifecycle allowlist에서만 바꾼다.
217
+ * - 다른 대안 대신 이 방식을 선택한 이유: disabled-by-default byte-for-byte passthrough를 유지하면서,
218
+ * previous_response_id replay는 raw upstream snapshot을 계속 사용해 synthetic id가 upstream으로
219
+ * 역류하지 않게 막을 수 있다.
220
+ * - 장점, 단점 및 영향: 기본 경로는 변하지 않는다. malformed stream이 output_index를 다른 item
221
+ * type에 재사용해도 function_call id/call_id는 보존된다. opt-in 게이트웨이는 sequential streams에서도
222
+ * 고유한 canonical id를 얻지만, 보정이 필요한 경우에만 JS stream 재작성 비용을 지불한다.
223
+ */
224
+ export function relaySseWithResponsesItemIdRepair(
225
+ body: ReadableStream<Uint8Array>,
226
+ config: ResponsesItemIdRepairConfig,
227
+ ): ReadableStream<Uint8Array> {
228
+ const reader = body.getReader();
229
+ const decoder = new TextDecoder();
230
+ const encoder = new TextEncoder();
231
+ const state = createRepairState(config);
232
+ let buffer = "";
233
+
234
+ const emitProcessedBlocks = (
235
+ controller: ReadableStreamDefaultController<Uint8Array>,
236
+ flushFinal = false,
237
+ ): void => {
238
+ let next: { block: string; delimiter: string; rest: string } | null;
239
+ while ((next = nextSseBlock(buffer))) {
240
+ buffer = next.rest;
241
+ const payload = sseDataPayload(next.block);
242
+ const repairedPayload = payload ? repairEventPayload(payload, state) : undefined;
243
+ const block = payload && repairedPayload !== undefined && repairedPayload !== payload
244
+ ? replaceSseDataPayload(next.block, repairedPayload)
245
+ : next.block;
246
+ controller.enqueue(encoder.encode(block + next.delimiter));
247
+ }
248
+ if (flushFinal && buffer.length > 0) {
249
+ const payload = sseDataPayload(buffer);
250
+ const repairedPayload = payload ? repairEventPayload(payload, state) : undefined;
251
+ const block = payload && repairedPayload !== undefined && repairedPayload !== payload
252
+ ? replaceSseDataPayload(buffer, repairedPayload)
253
+ : buffer;
254
+ controller.enqueue(encoder.encode(block));
255
+ buffer = "";
256
+ }
257
+ };
258
+
259
+ return new ReadableStream<Uint8Array>({
260
+ async pull(controller) {
261
+ const { done, value } = await reader.read();
262
+ if (done) {
263
+ buffer += decoder.decode();
264
+ emitProcessedBlocks(controller, true);
265
+ controller.close();
266
+ return;
267
+ }
268
+ buffer += decoder.decode(value, { stream: true });
269
+ emitProcessedBlocks(controller);
270
+ },
271
+ cancel(reason) {
272
+ reader.cancel(reason).catch(() => {});
273
+ },
274
+ });
275
+ }
276
+
277
+ export function hasResponsesItemIdRepair(config: ResponsesItemIdRepairConfig | undefined): boolean {
278
+ return config?.repairMissingTerminalIds === true
279
+ || (config?.message?.length ?? 0) > 0
280
+ || (config?.reasoning?.length ?? 0) > 0;
281
+ }