@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.
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +21 -10
- package/README.ru.md +1 -1
- package/README.zh-CN.md +1 -1
- package/gui/dist/assets/index-BkmJJgg6.js +52 -0
- package/gui/dist/assets/index-Sg-7L_oZ.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +13 -6
- package/src/adapters/cursor/discovery.ts +39 -4
- package/src/adapters/cursor/exec-policy.ts +11 -13
- package/src/adapters/cursor/live-transport.ts +22 -4
- package/src/adapters/cursor/protobuf-events.ts +140 -8
- package/src/adapters/cursor/protobuf-request.ts +15 -0
- package/src/adapters/cursor/request-builder.ts +10 -5
- package/src/adapters/cursor/transport.ts +3 -2
- package/src/adapters/cursor/types.ts +14 -0
- package/src/adapters/kiro-constants.ts +12 -0
- package/src/adapters/kiro-errors.ts +111 -2
- package/src/adapters/kiro-events.ts +154 -35
- package/src/adapters/kiro-retry.ts +116 -32
- package/src/adapters/kiro-tools.ts +30 -20
- package/src/adapters/kiro-wire.ts +47 -6
- package/src/adapters/kiro.ts +891 -228
- package/src/adapters/openai-chat.ts +12 -5
- package/src/adapters/openai-responses.ts +7 -2
- package/src/bridge.ts +109 -26
- package/src/claude/outbound.ts +27 -4
- package/src/cli/index.ts +1 -1
- package/src/codex/catalog.ts +375 -33
- package/src/combos/index.ts +3 -0
- package/src/combos/request.ts +4 -4
- package/src/combos/resolve.ts +2 -2
- package/src/combos/types.ts +104 -2
- package/src/config.ts +70 -1
- package/src/lib/eventstream-decoder.ts +9 -0
- package/src/oauth/index.ts +3 -1
- package/src/oauth/kiro-credentials.ts +48 -20
- package/src/oauth/login-cli.ts +2 -0
- package/src/providers/derive.ts +8 -0
- package/src/providers/kiro-models.ts +2 -2
- package/src/providers/openai-sidecar.ts +28 -1
- package/src/providers/registry.ts +39 -2
- package/src/responses/parser.ts +22 -10
- package/src/responses/schema.ts +1 -0
- package/src/responses/state.ts +50 -10
- package/src/router.ts +15 -3
- package/src/server/auth-cors.ts +7 -0
- package/src/server/claude-messages.ts +6 -0
- package/src/server/index.ts +6 -3
- package/src/server/management-api.ts +187 -43
- package/src/server/ports.ts +4 -2
- package/src/server/request-log.ts +3 -2
- package/src/server/responses-item-id-repair.ts +281 -0
- package/src/server/responses.ts +274 -73
- package/src/types.ts +109 -16
- package/src/update/job.ts +81 -1
- package/src/vision/describe.ts +2 -1
- package/src/web-search/executor.ts +2 -1
- package/src/web-search/loop.ts +9 -1
- package/src/web-search/progress-stream.ts +12 -10
- package/gui/dist/assets/index-D6Fcl4yM.css +0 -1
- 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
|
-
|
|
817
|
-
const
|
|
818
|
-
|
|
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 =
|
|
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 =>
|
|
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, ...
|
|
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:
|
|
1096
|
-
.filter(m => ![...disabled].some(stored =>
|
|
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
|
|
1107
|
-
try {
|
|
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
|
-
|
|
1110
|
-
let
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
-
|
|
1115
|
-
if (
|
|
1116
|
-
|
|
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)
|
|
1129
|
-
else if (body.prompt === null || body.prompt === "")
|
|
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({
|
|
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 =>
|
|
1174
|
-
|
|
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 {
|
|
1696
|
-
return jsonResponse({ combos: listComboIds(config).map(id =>
|
|
1697
|
-
id
|
|
1698
|
-
|
|
1699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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") {
|
package/src/server/ports.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
+
}
|