codex-copilot-dx 0.6.13 → 0.6.15
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.md +9 -6
- package/package.json +1 -1
- package/scripts/benchmark.mjs +74 -0
- package/src/adapter.mjs +6 -2
- package/src/anthropic-handler.mjs +38 -2
- package/src/anthropic.mjs +77 -24
- package/src/config.mjs +38 -3
- package/src/copilot-responses-compat.mjs +73 -0
- package/src/copilot-responses-policy.mjs +78 -0
- package/src/doctor.mjs +1 -2
- package/src/model-cache.mjs +36 -6
- package/src/observability.mjs +13 -1
- package/src/pm-studio-relay.mjs +6 -3
- package/src/profile-model-runtime.mjs +87 -13
- package/src/profile-routing.mjs +45 -7
- package/src/response-history.mjs +20 -3
- package/src/responses-bridge.mjs +44 -7
- package/src/responses-compaction.mjs +1 -50
- package/src/responses-handler.mjs +42 -9
- package/src/responses-proxy.mjs +118 -36
- package/src/responses-request.mjs +98 -105
- package/src/route-plan.mjs +51 -0
- package/src/startup.mjs +8 -2
- package/src/stream.mjs +27 -13
package/src/model-cache.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
export const MODEL_CACHE_SOFT_TTL_MS = 2 * 60 * 60 * 1000;
|
|
6
|
+
export const MODEL_CACHE_HARD_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
6
7
|
const MODEL_CACHE_PROFILES = new Set(["codex", "claude"]);
|
|
7
8
|
|
|
8
9
|
export function modelCachePath(home = os.homedir(), profile = "codex") {
|
|
@@ -23,40 +24,69 @@ export function isValidModelList(models) {
|
|
|
23
24
|
return modelData(models) !== null;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
function normalizedTtl(value, fallback) {
|
|
28
|
+
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadModelCacheEntry({
|
|
27
32
|
home = os.homedir(),
|
|
28
33
|
profile = "codex",
|
|
29
34
|
credentialFingerprint = "",
|
|
30
|
-
|
|
35
|
+
softTtlMs = MODEL_CACHE_SOFT_TTL_MS,
|
|
36
|
+
hardTtlMs,
|
|
37
|
+
maxAgeMs,
|
|
31
38
|
now = Date.now,
|
|
32
39
|
} = {}) {
|
|
33
40
|
try {
|
|
34
41
|
const parsed = JSON.parse(fs.readFileSync(modelCachePath(home, profile), "utf8"));
|
|
35
42
|
const savedAt = Date.parse(parsed.saved_at);
|
|
36
|
-
if (!Number.isFinite(savedAt)
|
|
43
|
+
if (!Number.isFinite(savedAt)) return null;
|
|
37
44
|
const expectedFingerprint = String(credentialFingerprint || "").trim();
|
|
38
45
|
if (expectedFingerprint && parsed.credential_fingerprint !== expectedFingerprint) return null;
|
|
39
46
|
if (!isValidModelList(parsed.models)) return null;
|
|
40
|
-
|
|
47
|
+
const resolvedHardTtlMs = normalizedTtl(
|
|
48
|
+
hardTtlMs,
|
|
49
|
+
normalizedTtl(maxAgeMs, MODEL_CACHE_HARD_TTL_MS),
|
|
50
|
+
);
|
|
51
|
+
const resolvedSoftTtlMs = Math.min(
|
|
52
|
+
normalizedTtl(softTtlMs, MODEL_CACHE_SOFT_TTL_MS),
|
|
53
|
+
resolvedHardTtlMs,
|
|
54
|
+
);
|
|
55
|
+
const ageMs = Math.max(0, now() - savedAt);
|
|
56
|
+
if (ageMs > resolvedHardTtlMs) return null;
|
|
57
|
+
return {
|
|
58
|
+
models: parsed.models,
|
|
59
|
+
savedAtMs: savedAt,
|
|
60
|
+
ageMs,
|
|
61
|
+
state: ageMs > resolvedSoftTtlMs ? "stale" : "fresh",
|
|
62
|
+
};
|
|
41
63
|
} catch {
|
|
42
64
|
return null;
|
|
43
65
|
}
|
|
44
66
|
}
|
|
45
67
|
|
|
68
|
+
export function loadModelCache(options = {}) {
|
|
69
|
+
return loadModelCacheEntry(options)?.models || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
46
72
|
export function saveModelCache(models, {
|
|
47
73
|
home = os.homedir(),
|
|
48
74
|
profile = "codex",
|
|
49
75
|
credentialFingerprint = "",
|
|
76
|
+
isCurrent = () => true,
|
|
77
|
+
now = Date.now,
|
|
50
78
|
} = {}) {
|
|
51
79
|
if (!isValidModelList(models)) return false;
|
|
80
|
+
if (typeof isCurrent === "function" && !isCurrent()) return false;
|
|
52
81
|
const filePath = modelCachePath(home, profile);
|
|
53
82
|
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
54
|
-
const payload = { saved_at: new Date().toISOString(), models };
|
|
83
|
+
const payload = { saved_at: new Date(now()).toISOString(), models };
|
|
55
84
|
const fingerprint = String(credentialFingerprint || "").trim();
|
|
56
85
|
if (fingerprint) payload.credential_fingerprint = fingerprint;
|
|
57
86
|
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
58
87
|
try {
|
|
59
88
|
fs.writeFileSync(tempPath, `${JSON.stringify(payload)}\n`, { mode: 0o600 });
|
|
89
|
+
if (typeof isCurrent === "function" && !isCurrent()) return false;
|
|
60
90
|
fs.renameSync(tempPath, filePath);
|
|
61
91
|
fs.chmodSync(filePath, 0o600);
|
|
62
92
|
return true;
|
package/src/observability.mjs
CHANGED
|
@@ -116,11 +116,23 @@ function modelRegistryStatus(modelRegistry) {
|
|
|
116
116
|
const modelData = Array.isArray(modelRegistry?.models)
|
|
117
117
|
? modelRegistry.models
|
|
118
118
|
: modelRegistry?.models?.data;
|
|
119
|
-
|
|
119
|
+
const result = {
|
|
120
120
|
source: String(modelRegistry?.source || "built-in"),
|
|
121
121
|
models: Array.isArray(modelData) ? modelData.length : 0,
|
|
122
122
|
claude_models: Array.isArray(modelRegistry?.modelDefs) ? modelRegistry.modelDefs.length : 0,
|
|
123
123
|
};
|
|
124
|
+
if (typeof modelRegistry?.cacheState === "string") {
|
|
125
|
+
const savedAtMs = Number(modelRegistry.cacheSavedAtMs);
|
|
126
|
+
result.cache_state = modelRegistry.cacheState;
|
|
127
|
+
result.cache_age_ms = Number.isFinite(savedAtMs) ? Math.max(0, Date.now() - savedAtMs) : null;
|
|
128
|
+
result.refresh_in_flight = modelRegistry.refreshInFlight === true;
|
|
129
|
+
result.generation = finiteRuntimeNumber(modelRegistry.generation);
|
|
130
|
+
result.last_error = typeof modelRegistry.lastError === "string" ? modelRegistry.lastError : null;
|
|
131
|
+
result.last_error_at_ms = Number.isFinite(modelRegistry.lastErrorAtMs)
|
|
132
|
+
? modelRegistry.lastErrorAtMs
|
|
133
|
+
: null;
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
124
136
|
}
|
|
125
137
|
|
|
126
138
|
function finiteRuntimeNumber(value, fallback = 0) {
|
package/src/pm-studio-relay.mjs
CHANGED
|
@@ -445,11 +445,14 @@ export function createPmStudioRelayHandler(options = {}) {
|
|
|
445
445
|
releaseRequest = releaseOnce(admission);
|
|
446
446
|
abort.setTimeout(requestBodyTimeoutMs, "request_body_timeout");
|
|
447
447
|
const body = await readJsonBody(req, { admission, signal: abort.signal });
|
|
448
|
-
const
|
|
449
|
-
if (
|
|
448
|
+
const routePlan = modelRouter.plan(body?.model);
|
|
449
|
+
if (routePlan.disposition === "reject") {
|
|
450
450
|
throw localError(400, "model_not_supported", "The requested Claude model is not enabled for PM Studio");
|
|
451
451
|
}
|
|
452
|
-
if (
|
|
452
|
+
if (routePlan.disposition !== "relay"
|
|
453
|
+
|| routePlan.origin !== "ccdx"
|
|
454
|
+
|| routePlan.profile !== "claude"
|
|
455
|
+
|| routePlan.protocol !== "openai-chat-completions") {
|
|
453
456
|
throw localError(
|
|
454
457
|
400,
|
|
455
458
|
"native_route_required",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
isValidModelList,
|
|
3
|
-
|
|
3
|
+
loadModelCacheEntry,
|
|
4
4
|
saveModelCache,
|
|
5
5
|
} from "./model-cache.mjs";
|
|
6
6
|
import {
|
|
@@ -22,13 +22,48 @@ function parsedCatalog(result) {
|
|
|
22
22
|
if (result?.status < 200 || result?.status >= 300) {
|
|
23
23
|
throw new Error(`Copilot models returned HTTP ${result?.status}`);
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
let models;
|
|
26
|
+
try {
|
|
27
|
+
models = JSON.parse(result.body);
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error("Copilot models response was not valid JSON");
|
|
30
|
+
}
|
|
26
31
|
if (!isValidModelList(models)) {
|
|
27
32
|
throw new Error("Copilot models response contained no valid models");
|
|
28
33
|
}
|
|
29
34
|
return models;
|
|
30
35
|
}
|
|
31
36
|
|
|
37
|
+
function createModelRegistry() {
|
|
38
|
+
return {
|
|
39
|
+
modelDefs: undefined,
|
|
40
|
+
models: undefined,
|
|
41
|
+
source: "built-in",
|
|
42
|
+
cacheState: "none",
|
|
43
|
+
cacheSavedAtMs: null,
|
|
44
|
+
lastError: null,
|
|
45
|
+
lastErrorAtMs: null,
|
|
46
|
+
refreshInFlight: false,
|
|
47
|
+
generation: 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sameModelDefs(left, right) {
|
|
52
|
+
if (left === right) return true;
|
|
53
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
54
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function safeRefreshError(error, { timedOut = false, timeoutMs } = {}) {
|
|
58
|
+
if (timedOut) return `Model refresh timed out after ${timeoutMs}ms`;
|
|
59
|
+
const raw = String(error?.message || error?.name || "Model refresh failed")
|
|
60
|
+
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
61
|
+
.replace(/\b(?:github_pat_|gh[pousr]_|copilot_)[A-Za-z0-9._-]+\b/gi, "<redacted>")
|
|
62
|
+
.replace(/\bBearer\s+\S+/gi, "Bearer <redacted>")
|
|
63
|
+
.trim();
|
|
64
|
+
return (raw || "Model refresh failed").slice(0, 240);
|
|
65
|
+
}
|
|
66
|
+
|
|
32
67
|
export function createProfileModelRuntime({
|
|
33
68
|
codexClient,
|
|
34
69
|
claudeClient = codexClient,
|
|
@@ -51,11 +86,13 @@ export function createProfileModelRuntime({
|
|
|
51
86
|
throw new Error("Isolated Claude models require an isolated Claude client");
|
|
52
87
|
}
|
|
53
88
|
|
|
54
|
-
const codexRegistry =
|
|
89
|
+
const codexRegistry = createModelRegistry();
|
|
55
90
|
const claudeRegistry = claudeMode === "inherited"
|
|
56
91
|
? codexRegistry
|
|
57
|
-
:
|
|
92
|
+
: createModelRegistry();
|
|
58
93
|
const customClaudeDefs = parseModelAliasEnv(env.CCDX_CLAUDE_MODEL_ALIASES);
|
|
94
|
+
const refreshFlights = new Map();
|
|
95
|
+
const refreshGenerations = new Map();
|
|
59
96
|
const refreshTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
60
97
|
? timeoutMs
|
|
61
98
|
: DEFAULT_TIMEOUT_MS;
|
|
@@ -85,8 +122,9 @@ export function createProfileModelRuntime({
|
|
|
85
122
|
? customClaudeDefs.map((model) => ({ ...model }))
|
|
86
123
|
: claudeDesktopModelDefsFromCopilotModels(models);
|
|
87
124
|
if (discovered.length) {
|
|
125
|
+
const changed = !sameModelDefs(registry.modelDefs, discovered);
|
|
88
126
|
registry.modelDefs = discovered;
|
|
89
|
-
notifyClaudeModels(discovered);
|
|
127
|
+
if (changed) notifyClaudeModels(discovered);
|
|
90
128
|
}
|
|
91
129
|
return registry.modelDefs;
|
|
92
130
|
}
|
|
@@ -96,11 +134,13 @@ export function createProfileModelRuntime({
|
|
|
96
134
|
? String(claudeCredentialFingerprint || "").trim()
|
|
97
135
|
: String(codexCredentialFingerprint || "").trim();
|
|
98
136
|
if (profile === "claude" && !credentialFingerprint) return false;
|
|
99
|
-
const cached =
|
|
137
|
+
const cached = loadModelCacheEntry({ home, profile, credentialFingerprint });
|
|
100
138
|
if (!cached) return false;
|
|
101
139
|
try {
|
|
102
|
-
applyCatalog({ client, models: cached, registry, source: "cache", ownsClaudeModels });
|
|
103
|
-
|
|
140
|
+
applyCatalog({ client, models: cached.models, registry, source: "cache", ownsClaudeModels });
|
|
141
|
+
registry.cacheState = cached.state;
|
|
142
|
+
registry.cacheSavedAtMs = cached.savedAtMs;
|
|
143
|
+
return { loaded: true, stale: cached.state === "stale" };
|
|
104
144
|
} catch {
|
|
105
145
|
return false;
|
|
106
146
|
}
|
|
@@ -131,13 +171,22 @@ export function createProfileModelRuntime({
|
|
|
131
171
|
}
|
|
132
172
|
}
|
|
133
173
|
|
|
134
|
-
function persist(models, profile) {
|
|
174
|
+
function persist(models, profile, registry, generation) {
|
|
135
175
|
const credentialFingerprint = profile === "claude"
|
|
136
176
|
? String(claudeCredentialFingerprint || "").trim()
|
|
137
177
|
: String(codexCredentialFingerprint || "").trim();
|
|
138
178
|
if (profile === "claude" && !credentialFingerprint) return;
|
|
139
179
|
try {
|
|
140
|
-
saveModelCache(models, {
|
|
180
|
+
const saved = saveModelCache(models, {
|
|
181
|
+
home,
|
|
182
|
+
profile,
|
|
183
|
+
credentialFingerprint,
|
|
184
|
+
isCurrent: () => refreshGenerations.get(profile) === generation,
|
|
185
|
+
});
|
|
186
|
+
if (saved) {
|
|
187
|
+
registry.cacheState = "fresh";
|
|
188
|
+
registry.cacheSavedAtMs = Date.now();
|
|
189
|
+
}
|
|
141
190
|
} catch (error) {
|
|
142
191
|
const label = profile === "codex" ? "Copilot" : "Claude Copilot";
|
|
143
192
|
emit("warn", `Could not persist the ${label} model cache (${error.message})`);
|
|
@@ -162,12 +211,13 @@ export function createProfileModelRuntime({
|
|
|
162
211
|
return profile === "claude" ? "built-in model list" : "no cached model list";
|
|
163
212
|
}
|
|
164
213
|
|
|
165
|
-
async function
|
|
214
|
+
async function performRefresh({ client, profile, registry, ownsClaudeModels, checksCodex }, generation) {
|
|
166
215
|
const controllerLabel = claudeMode === "inherited"
|
|
167
216
|
? ""
|
|
168
217
|
: `${profile === "codex" ? "Codex" : "Claude"} `;
|
|
169
218
|
try {
|
|
170
219
|
const models = await requestCatalog(client);
|
|
220
|
+
if (refreshGenerations.get(profile) !== generation) return registry.modelDefs;
|
|
171
221
|
if (checksCodex) checkCodexCatalog(models);
|
|
172
222
|
const modelDefs = applyCatalog({
|
|
173
223
|
client,
|
|
@@ -176,20 +226,44 @@ export function createProfileModelRuntime({
|
|
|
176
226
|
source: "live",
|
|
177
227
|
ownsClaudeModels,
|
|
178
228
|
});
|
|
179
|
-
persist(models, profile);
|
|
229
|
+
persist(models, profile, registry, generation);
|
|
230
|
+
registry.lastError = null;
|
|
231
|
+
registry.lastErrorAtMs = null;
|
|
180
232
|
if (ownsClaudeModels) logClaudeCatalog(modelDefs);
|
|
181
233
|
return modelDefs;
|
|
182
234
|
} catch (error) {
|
|
235
|
+
if (refreshGenerations.get(profile) !== generation) return registry.modelDefs;
|
|
183
236
|
const fallback = fallbackLabel(registry, profile);
|
|
184
237
|
const timedOut = error?.name === "AbortError" || error?.code === "ABORT_ERR";
|
|
238
|
+
const diagnostic = safeRefreshError(error, { timedOut, timeoutMs: refreshTimeoutMs });
|
|
239
|
+
registry.lastError = diagnostic;
|
|
240
|
+
registry.lastErrorAtMs = Date.now();
|
|
185
241
|
const message = timedOut
|
|
186
242
|
? `${controllerLabel}model refresh timed out after ${refreshTimeoutMs}ms; using ${fallback}`
|
|
187
|
-
: `Could not refresh ${controllerLabel.toLowerCase()}model list; using ${fallback} (${
|
|
243
|
+
: `Could not refresh ${controllerLabel.toLowerCase()}model list; using ${fallback} (${diagnostic})`;
|
|
188
244
|
emit("warn", message[0].toUpperCase() + message.slice(1));
|
|
189
245
|
return registry.modelDefs;
|
|
190
246
|
}
|
|
191
247
|
}
|
|
192
248
|
|
|
249
|
+
function refreshProfile(options) {
|
|
250
|
+
const { profile, registry } = options;
|
|
251
|
+
const existing = refreshFlights.get(profile);
|
|
252
|
+
if (existing) return existing.promise;
|
|
253
|
+
|
|
254
|
+
const generation = (refreshGenerations.get(profile) || 0) + 1;
|
|
255
|
+
refreshGenerations.set(profile, generation);
|
|
256
|
+
registry.generation = generation;
|
|
257
|
+
registry.refreshInFlight = true;
|
|
258
|
+
const flight = { generation, promise: null };
|
|
259
|
+
refreshFlights.set(profile, flight);
|
|
260
|
+
flight.promise = performRefresh(options, generation).finally(() => {
|
|
261
|
+
if (refreshFlights.get(profile) === flight) refreshFlights.delete(profile);
|
|
262
|
+
if (refreshGenerations.get(profile) === generation) registry.refreshInFlight = false;
|
|
263
|
+
});
|
|
264
|
+
return flight.promise;
|
|
265
|
+
}
|
|
266
|
+
|
|
193
267
|
function refreshCodex() {
|
|
194
268
|
return refreshProfile({
|
|
195
269
|
client: codexClient,
|
package/src/profile-routing.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
isClaudeCopilotCatalogEntry,
|
|
3
3
|
isClaudeCopilotModel,
|
|
4
4
|
} from "./models.mjs";
|
|
5
|
+
import { createRoutePlan } from "./route-plan.mjs";
|
|
5
6
|
|
|
6
7
|
function catalogData(catalog) {
|
|
7
8
|
if (Array.isArray(catalog)) return catalog;
|
|
@@ -47,14 +48,51 @@ export function createPmStudioModelRouter({ getCatalog, isClaudeEnabled } = {})
|
|
|
47
48
|
return { allowed, known };
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
function classify(modelId) {
|
|
52
|
+
const id = String(modelId || "").trim();
|
|
53
|
+
const { allowed, known } = availability();
|
|
54
|
+
if (allowed.has(id)) return "claude";
|
|
55
|
+
if (known.has(id) || id.toLowerCase().startsWith("claude-")) return "unsupported_claude";
|
|
56
|
+
return "enterprise";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function plan(modelId) {
|
|
60
|
+
const id = String(modelId || "").trim();
|
|
61
|
+
const modelType = classify(id);
|
|
62
|
+
const protocol = "openai-chat-completions";
|
|
63
|
+
if (modelType === "claude") {
|
|
64
|
+
return createRoutePlan({
|
|
65
|
+
disposition: "relay",
|
|
66
|
+
origin: "ccdx",
|
|
67
|
+
profile: "claude",
|
|
68
|
+
protocol,
|
|
69
|
+
model: id,
|
|
70
|
+
surface: "pm-studio",
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (modelType === "unsupported_claude") {
|
|
74
|
+
return createRoutePlan({
|
|
75
|
+
disposition: "reject",
|
|
76
|
+
origin: "ccdx",
|
|
77
|
+
profile: "claude",
|
|
78
|
+
protocol,
|
|
79
|
+
model: id || "unknown",
|
|
80
|
+
surface: "pm-studio",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return createRoutePlan({
|
|
84
|
+
disposition: "native",
|
|
85
|
+
origin: "pm-studio-native",
|
|
86
|
+
profile: "enterprise",
|
|
87
|
+
protocol,
|
|
88
|
+
model: id || "unknown",
|
|
89
|
+
surface: "pm-studio",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
50
93
|
return Object.freeze({
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const { allowed, known } = availability();
|
|
54
|
-
if (allowed.has(id)) return "claude";
|
|
55
|
-
if (known.has(id) || id.toLowerCase().startsWith("claude-")) return "unsupported_claude";
|
|
56
|
-
return "enterprise";
|
|
57
|
-
},
|
|
94
|
+
plan,
|
|
95
|
+
classify,
|
|
58
96
|
allowedModels() {
|
|
59
97
|
return [...availability().allowed.values()];
|
|
60
98
|
},
|
package/src/response-history.mjs
CHANGED
|
@@ -202,13 +202,17 @@ export function responseHistoryMaterializedBytes(responseId) {
|
|
|
202
202
|
return chain.reduce((bytes, entry) => bytes + entry.bytes, 0);
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
export function materializeResponseHistory(responseId, { assertActive } = {}) {
|
|
205
|
+
export function materializeResponseHistory(responseId, { assertActive, routeMetadata } = {}) {
|
|
206
206
|
assertActive?.();
|
|
207
207
|
const { chain, rootId } = responseHistoryChain(responseId);
|
|
208
208
|
const items = [];
|
|
209
209
|
for (let index = 0; index < chain.length; index += 1) {
|
|
210
210
|
if ((index & 63) === 0) assertActive?.();
|
|
211
211
|
const entry = chain[chain.length - index - 1];
|
|
212
|
+
routeMetadata?.push({
|
|
213
|
+
affinity: entry.routeAffinity || null,
|
|
214
|
+
hasOpaque: entry.hasOpaque === true,
|
|
215
|
+
});
|
|
212
216
|
items.push(...entry.inputItems, ...entry.outputItems);
|
|
213
217
|
}
|
|
214
218
|
assertActive?.();
|
|
@@ -218,11 +222,22 @@ export function materializeResponseHistory(responseId, { assertActive } = {}) {
|
|
|
218
222
|
return materialized;
|
|
219
223
|
}
|
|
220
224
|
|
|
221
|
-
export function rememberResponseHistoryNode({
|
|
225
|
+
export function rememberResponseHistoryNode({
|
|
226
|
+
id,
|
|
227
|
+
parentId,
|
|
228
|
+
inputItems,
|
|
229
|
+
outputItems,
|
|
230
|
+
hasOpaque = false,
|
|
231
|
+
routeAffinity = null,
|
|
232
|
+
takeOwnership = false,
|
|
233
|
+
}) {
|
|
222
234
|
if (!id || !Array.isArray(inputItems)) return;
|
|
223
235
|
clearResponsesToolOutputPartsCache(inputItems);
|
|
224
236
|
clearResponsesToolOutputPartsCache(outputItems);
|
|
225
|
-
const
|
|
237
|
+
const historyValue = routeAffinity
|
|
238
|
+
? [inputItems, outputItems, { routeAffinity, hasOpaque: hasOpaque === true }]
|
|
239
|
+
: [inputItems, outputItems];
|
|
240
|
+
const bytes = jsonByteLength(historyValue);
|
|
226
241
|
if (bytes > maxBytes) {
|
|
227
242
|
if (histories.has(id)) removeSubtree(id);
|
|
228
243
|
rememberEvictedId(id);
|
|
@@ -236,6 +251,8 @@ export function rememberResponseHistoryNode({ id, parentId, inputItems, outputIt
|
|
|
236
251
|
rootId,
|
|
237
252
|
inputItems: takeOwnership ? inputItems : cloneJson(inputItems),
|
|
238
253
|
outputItems: takeOwnership ? outputItems : cloneJson(outputItems),
|
|
254
|
+
hasOpaque: hasOpaque === true,
|
|
255
|
+
routeAffinity: routeAffinity ? cloneJson(routeAffinity) : null,
|
|
239
256
|
bytes,
|
|
240
257
|
};
|
|
241
258
|
const oldRootId = existing?.rootId;
|
package/src/responses-bridge.mjs
CHANGED
|
@@ -38,6 +38,23 @@ export function responsesBodyUsesCustomTools(body) {
|
|
|
38
38
|
&& body.input.some((item) => item?.type === "custom_tool_call" || item?.type === "custom_tool_call_output");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function chatCompatibilityError(kind, itemType) {
|
|
42
|
+
const type = typeof itemType === "string" && itemType ? itemType : "unknown";
|
|
43
|
+
const message = `Responses ${kind} type "${type}" cannot be represented by Chat Completions`;
|
|
44
|
+
const error = new Error(message);
|
|
45
|
+
error.statusCode = 400;
|
|
46
|
+
error.code = "ccdx_responses_chat_incompatible";
|
|
47
|
+
error.jsonBody = {
|
|
48
|
+
error: {
|
|
49
|
+
message,
|
|
50
|
+
type: "invalid_request_error",
|
|
51
|
+
code: error.code,
|
|
52
|
+
item_type: type,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return error;
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
export function responsesToChat(body) {
|
|
42
59
|
const messages = [];
|
|
43
60
|
if (body.instructions) messages.push({ role: "system", content: body.instructions });
|
|
@@ -65,19 +82,36 @@ export function responsesToChat(body) {
|
|
|
65
82
|
if (typeof body.input === "string") {
|
|
66
83
|
messages.push({ role: "user", content: body.input });
|
|
67
84
|
} else if (Array.isArray(body.input)) {
|
|
68
|
-
for (
|
|
69
|
-
|
|
85
|
+
for (let index = 0; index < body.input.length; index += 1) {
|
|
86
|
+
const item = body.input[index];
|
|
87
|
+
const isEasyMessage = item?.type === undefined
|
|
88
|
+
&& ["user", "assistant", "system", "developer"].includes(item?.role)
|
|
89
|
+
&& Object.prototype.hasOwnProperty.call(item, "content");
|
|
90
|
+
if (item?.type === "message" || isEasyMessage) {
|
|
70
91
|
messages.push({ role: item.role, content: messageContent(item.content) });
|
|
71
|
-
} else if (["input_image", "image_url", "image"].includes(item
|
|
92
|
+
} else if (["input_image", "image_url", "image"].includes(item?.type)) {
|
|
72
93
|
messages.push({ role: "user", content: messageContent([item]) });
|
|
73
|
-
} else if (item
|
|
94
|
+
} else if (item?.type === "function_call") {
|
|
95
|
+
const toolCalls = [];
|
|
96
|
+
while (index < body.input.length && body.input[index]?.type === "function_call") {
|
|
97
|
+
const call = body.input[index];
|
|
98
|
+
toolCalls.push({
|
|
99
|
+
id: call.call_id || randomUUID(),
|
|
100
|
+
type: "function",
|
|
101
|
+
function: { name: call.name, arguments: call.arguments },
|
|
102
|
+
});
|
|
103
|
+
index += 1;
|
|
104
|
+
}
|
|
105
|
+
index -= 1;
|
|
74
106
|
messages.push({
|
|
75
107
|
role: "assistant",
|
|
76
108
|
content: null,
|
|
77
|
-
tool_calls:
|
|
109
|
+
tool_calls: toolCalls,
|
|
78
110
|
});
|
|
79
|
-
} else if (item
|
|
111
|
+
} else if (item?.type === "function_call_output") {
|
|
80
112
|
messages.push({ role: "tool", tool_call_id: item.call_id, content: typeof item.output === "string" ? item.output : JSON.stringify(item.output) });
|
|
113
|
+
} else {
|
|
114
|
+
throw chatCompatibilityError("input item", item?.type);
|
|
81
115
|
}
|
|
82
116
|
}
|
|
83
117
|
}
|
|
@@ -90,9 +124,10 @@ export function responsesToChat(body) {
|
|
|
90
124
|
if (maxTok !== undefined) chatReq.max_completion_tokens = maxTok;
|
|
91
125
|
|
|
92
126
|
if (body.tools?.length) {
|
|
127
|
+
const unsupportedTool = body.tools.find((tool) => tool?.type !== "function");
|
|
128
|
+
if (unsupportedTool) throw chatCompatibilityError("tool", unsupportedTool?.type);
|
|
93
129
|
chatReq.tools = body.tools
|
|
94
130
|
.map((tool) => {
|
|
95
|
-
if (tool?.type !== "function") return null;
|
|
96
131
|
if (tool.function?.name) return cloneJson(tool);
|
|
97
132
|
const fn = {
|
|
98
133
|
name: tool.name,
|
|
@@ -112,6 +147,8 @@ export function responsesToChat(body) {
|
|
|
112
147
|
chatReq.tool_choice = body.tool_choice;
|
|
113
148
|
} else if (body.tool_choice?.type === "function" && body.tool_choice.name) {
|
|
114
149
|
chatReq.tool_choice = { type: "function", function: { name: body.tool_choice.name } };
|
|
150
|
+
} else {
|
|
151
|
+
throw chatCompatibilityError("tool choice", body.tool_choice?.type);
|
|
115
152
|
}
|
|
116
153
|
}
|
|
117
154
|
if (body.parallel_tool_calls !== undefined) chatReq.parallel_tool_calls = body.parallel_tool_calls;
|
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
1
|
import { httpError } from "./http-transport.mjs";
|
|
3
2
|
|
|
4
3
|
const COMPACTION_TRIGGER_TYPE = "compaction_trigger";
|
|
5
|
-
const RETAINED_MESSAGE_ROLES = new Set(["user", "assistant", "developer", "system"]);
|
|
6
|
-
const RETAINED_MESSAGE_TOKEN_BUDGET = 64_000;
|
|
7
|
-
const APPROX_BYTES_PER_TOKEN = 4;
|
|
8
4
|
|
|
9
5
|
function compactionError(message) {
|
|
10
6
|
const error = httpError(message, 502);
|
|
@@ -19,47 +15,6 @@ function compactionError(message) {
|
|
|
19
15
|
return error;
|
|
20
16
|
}
|
|
21
17
|
|
|
22
|
-
function normalizeMessageContent(content) {
|
|
23
|
-
if (typeof content === "string") return [{ type: "input_text", text: content }];
|
|
24
|
-
if (!Array.isArray(content)) return [];
|
|
25
|
-
return content.map((part) => {
|
|
26
|
-
if (!part || typeof part !== "object") return part;
|
|
27
|
-
return part.type === "output_text"
|
|
28
|
-
? { ...part, type: "input_text" }
|
|
29
|
-
: structuredClone(part);
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function approximateTextTokens(content) {
|
|
34
|
-
return content.reduce((total, part) => {
|
|
35
|
-
if (part?.type !== "input_text" || typeof part.text !== "string") return total;
|
|
36
|
-
return total + Math.ceil(Buffer.byteLength(part.text) / APPROX_BYTES_PER_TOKEN);
|
|
37
|
-
}, 0);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function retainedMessages(inputItems) {
|
|
41
|
-
const kept = [];
|
|
42
|
-
let usedTokens = 0;
|
|
43
|
-
for (let index = inputItems.length - 1; index >= 0; index -= 1) {
|
|
44
|
-
const item = inputItems[index];
|
|
45
|
-
if (item?.type !== "message" || !RETAINED_MESSAGE_ROLES.has(item.role)) continue;
|
|
46
|
-
|
|
47
|
-
const content = normalizeMessageContent(item.content);
|
|
48
|
-
usedTokens += Math.max(approximateTextTokens(content), 1);
|
|
49
|
-
if (usedTokens > RETAINED_MESSAGE_TOKEN_BUDGET && kept.length > 0) break;
|
|
50
|
-
|
|
51
|
-
kept.push({
|
|
52
|
-
type: "message",
|
|
53
|
-
id: `msg_${randomUUID().replaceAll("-", "")}`,
|
|
54
|
-
status: item.status ?? "completed",
|
|
55
|
-
role: item.role,
|
|
56
|
-
content,
|
|
57
|
-
...(item.phase !== undefined ? { phase: structuredClone(item.phase) } : {}),
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
return kept.reverse();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
18
|
export function prepareResponsesCompactionRequest(reqContext) {
|
|
64
19
|
const input = Array.isArray(reqContext?.body?.input) ? reqContext.body.input : [];
|
|
65
20
|
const compactionInputItems = compactionInputWithoutTrigger(input);
|
|
@@ -74,7 +29,7 @@ export function compactionInputWithoutTrigger(input) {
|
|
|
74
29
|
: [];
|
|
75
30
|
}
|
|
76
31
|
|
|
77
|
-
export function createResponsesCompactionResult(
|
|
32
|
+
export function createResponsesCompactionResult(_inputItems, generated) {
|
|
78
33
|
if (!generated || typeof generated !== "object" || !generated.id || !Array.isArray(generated.output)) {
|
|
79
34
|
throw compactionError("Copilot compaction returned an invalid response envelope");
|
|
80
35
|
}
|
|
@@ -95,10 +50,6 @@ export function createResponsesCompactionResult(inputItems, generated) {
|
|
|
95
50
|
return {
|
|
96
51
|
...generated,
|
|
97
52
|
object: "response.compaction",
|
|
98
|
-
output: [
|
|
99
|
-
...retainedMessages(Array.isArray(inputItems) ? inputItems : []),
|
|
100
|
-
...compactionItems,
|
|
101
|
-
],
|
|
102
53
|
};
|
|
103
54
|
}
|
|
104
55
|
|