codex-copilot-dx 0.4.30 → 0.4.31
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 +3 -3
- package/bin/cli.mjs +24 -11
- package/package.json +1 -1
- package/src/adapter.mjs +40 -8
- package/src/anthropic.mjs +17 -4
- package/src/claude-desktop-config.mjs +28 -0
- package/src/cli-options.mjs +1 -1
- package/src/http-transport.mjs +20 -6
- package/src/model-cache.mjs +8 -3
- package/src/response-history.mjs +47 -5
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ Or set:
|
|
|
87
87
|
CCDX_CONFIGURE_CLAUDE_DESKTOP=1 npx codex-copilot-dx@latest
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
This writes a local Claude App 3P gateway profile that points to the adapter root URL, such as `http://127.0.0.1:2026`. The profile uses a generated local bearer key unless `CCDX_CLAUDE_DESKTOP_API_KEY` is set. Restart Claude App after running the command.
|
|
90
|
+
This writes a local Claude App 3P gateway profile that points to the adapter root URL, such as `http://127.0.0.1:2026`. The profile uses a generated local bearer key unless `CCDX_CLAUDE_DESKTOP_API_KEY` is set. Later starts restore that key only from the active managed profile when its gateway URL matches the adapter. Restart Claude App after running the command.
|
|
91
91
|
|
|
92
92
|
When reusing an already-running adapter, Claude App profile updates require `CCDX_CLAUDE_DESKTOP_API_KEY` or `CCDX_PROXY_API_KEY` so the profile key matches the running process. Otherwise the existing adapter is left untouched.
|
|
93
93
|
|
|
@@ -120,7 +120,7 @@ Environment variables:
|
|
|
120
120
|
| `CCDX_IMG_MAX_INPUT_PIXELS` | `40000000` | Maximum decoded pixels accepted by `sharp` for one image |
|
|
121
121
|
| `CCDX_DISABLE_IMG_OPT` | unset | Set to `1` to disable image optimization |
|
|
122
122
|
| `CCDX_CONFIGURE_CLAUDE_DESKTOP` | unset | Set to `1` to write the Claude App 3P gateway profile during startup |
|
|
123
|
-
| `CCDX_CLAUDE_DESKTOP_API_KEY` | generated for opt-in setup |
|
|
123
|
+
| `CCDX_CLAUDE_DESKTOP_API_KEY` | managed profile or generated for opt-in setup | Explicit bearer key written into the Claude App profile and recognized by the adapter for model discovery |
|
|
124
124
|
| `CCDX_CLAUDE_MODEL_ALIASES` | built-in Claude aliases | Comma-separated Desktop-to-upstream aliases, for example `claude-sonnet-4-6=claude-sonnet-4.6` |
|
|
125
125
|
| `CCDX_GITHUB_TOKEN` | unset | Explicit GitHub Copilot OAuth token to validate and import before device login |
|
|
126
126
|
| `CCDX_GITHUB_TOKEN_PATH` | unset | Explicit file containing a GitHub Copilot OAuth token to validate and import before device login |
|
|
@@ -130,7 +130,7 @@ Environment variables:
|
|
|
130
130
|
| `CCDX_TOKEN_LOCK_TIMEOUT_MS` | `600000` | Maximum time to wait for another local process to finish GitHub token login/import |
|
|
131
131
|
| `CCDX_TOKEN_LOCK_STALE_MS` | `900000` | Age after which a stale GitHub token lock file can be removed |
|
|
132
132
|
| `CCDX_EXISTING_ADAPTER_TIMEOUT_MS` | `500` | Timeout for detecting an already-running local adapter during startup |
|
|
133
|
-
| `CCDX_MODEL_REFRESH_INTERVAL_MS` | `
|
|
133
|
+
| `CCDX_MODEL_REFRESH_INTERVAL_MS` | `7200000` | Interval for refreshing Copilot model metadata; successful lists are cached locally as last-known-good data |
|
|
134
134
|
| `CCDX_RESPONSE_HISTORY_MAX_BYTES` | `67108864` | Total in-memory byte budget for locally expanded Responses history |
|
|
135
135
|
| `CCDX_RESPONSE_HISTORY_MAX_ENTRIES` | `4096` | Maximum stored incremental Responses history nodes |
|
|
136
136
|
| `CCDX_USAGE_PATH` | `~/.local/share/codex-copilot-dx/usage.jsonl` | Local JSONL token usage log |
|
package/bin/cli.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { ensureAuth, openCodex } from "../src/launcher.mjs";
|
|
4
4
|
import { ensureCodexConfig } from "../src/config.mjs";
|
|
5
5
|
import { ensureClaudeConfig } from "../src/claude-config.mjs";
|
|
6
|
-
import { applyClaudeDesktopConfig, formatClaudeDesktopApplyResult, generatedClaudeDesktopApiKey } from "../src/claude-desktop-config.mjs";
|
|
6
|
+
import { applyClaudeDesktopConfig, formatClaudeDesktopApplyResult, generatedClaudeDesktopApiKey, loadManagedClaudeDesktopApiKey } from "../src/claude-desktop-config.mjs";
|
|
7
7
|
import { startAdapter } from "../src/adapter.mjs";
|
|
8
8
|
import { cacheModelEndpoints, listModels, refreshVSCodeVersion } from "../src/copilot.mjs";
|
|
9
9
|
import { claudeDesktopModelDefsFromCopilotModels, claudeDesktopModelIds, gptModelIdsFromCopilotModels, parseModelAliasEnv } from "../src/models.mjs";
|
|
@@ -14,7 +14,7 @@ import { checkForUpdate, localPackageVersion } from "../src/version.mjs";
|
|
|
14
14
|
import { runDoctor } from "../src/doctor.mjs";
|
|
15
15
|
import { checkRunningAdapter } from "../src/running-adapter.mjs";
|
|
16
16
|
import { assertSafeAdapterHost, isLoopbackHost } from "../src/security.mjs";
|
|
17
|
-
import { loadModelCache, saveModelCache } from "../src/model-cache.mjs";
|
|
17
|
+
import { isValidModelList, loadModelCache, saveModelCache } from "../src/model-cache.mjs";
|
|
18
18
|
import { initializeModelRegistry, runInBackground } from "../src/startup.mjs";
|
|
19
19
|
import { cliHelp, parseCliArgs, parseRuntimeOptions } from "../src/cli-options.mjs";
|
|
20
20
|
import { closeHttpServer } from "../src/shutdown.mjs";
|
|
@@ -57,7 +57,7 @@ const MODEL_REFRESH_TIMEOUT_MS = RUNTIME.modelRefreshTimeoutMs;
|
|
|
57
57
|
const EXISTING_ADAPTER_TIMEOUT_MS = RUNTIME.existingAdapterTimeoutMs;
|
|
58
58
|
const CONFIGURE_CLAUDE_DESKTOP = CLI.configureClaudeDesktop || process.env.CCDX_CONFIGURE_CLAUDE_DESKTOP === "1";
|
|
59
59
|
const LOGGING = configureLogging();
|
|
60
|
-
const MODEL_REGISTRY = { modelDefs: undefined, source: "built-in" };
|
|
60
|
+
const MODEL_REGISTRY = { modelDefs: undefined, models: undefined, source: "built-in" };
|
|
61
61
|
let activeServer = null;
|
|
62
62
|
let modelRefreshTimer = null;
|
|
63
63
|
let shuttingDown = false;
|
|
@@ -68,13 +68,16 @@ if (LOGGING.filePath) {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function applyModelsToRegistry(models, source, { updateClaudeModels = true } = {}) {
|
|
71
|
+
if (!isValidModelList(models)) throw new Error("Copilot models response contained no valid models");
|
|
71
72
|
const modelDefs = updateClaudeModels
|
|
72
73
|
? claudeDesktopModelDefsFromCopilotModels(models)
|
|
73
74
|
: MODEL_REGISTRY.modelDefs;
|
|
74
|
-
if (updateClaudeModels && !modelDefs.length) throw new Error("Copilot models response contained no Claude models");
|
|
75
75
|
cacheModelEndpoints(models);
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
MODEL_REGISTRY.models = models;
|
|
77
|
+
if (updateClaudeModels && modelDefs.length) {
|
|
78
|
+
MODEL_REGISTRY.modelDefs = modelDefs;
|
|
79
|
+
MODEL_REGISTRY.source = source;
|
|
80
|
+
}
|
|
78
81
|
return modelDefs;
|
|
79
82
|
}
|
|
80
83
|
|
|
@@ -116,8 +119,10 @@ async function refreshClaudeDesktopModelDefs() {
|
|
|
116
119
|
}
|
|
117
120
|
if (customAliases) {
|
|
118
121
|
console.log(status("info", "Using CCDX_CLAUDE_MODEL_ALIASES for Claude models"));
|
|
119
|
-
} else {
|
|
122
|
+
} else if (modelDefs?.length) {
|
|
120
123
|
console.log(status("ok", `Refreshed Claude models from GitHub Copilot: ${modelDefs.map((model) => model.id).join(", ")}`));
|
|
124
|
+
} else {
|
|
125
|
+
console.log(status("warn", "Copilot models response contained no Claude models; using built-in Claude models"));
|
|
121
126
|
}
|
|
122
127
|
return modelDefs;
|
|
123
128
|
} catch (e) {
|
|
@@ -130,7 +135,7 @@ async function refreshClaudeDesktopModelDefs() {
|
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
function currentClaudeDesktopApiKey() {
|
|
133
|
-
return process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || "";
|
|
138
|
+
return String(process.env.CCDX_CLAUDE_DESKTOP_API_KEY || process.env.CCDX_PROXY_API_KEY || "").trim();
|
|
134
139
|
}
|
|
135
140
|
|
|
136
141
|
async function reuseRunningAdapterIfAvailable() {
|
|
@@ -222,9 +227,17 @@ try {
|
|
|
222
227
|
console.log(status("warn", `ADAPTER_HOST=${ADAPTER_HOST} exposes the adapter beyond loopback because CCDX_ALLOW_LAN=1 is set. Use only on trusted networks.`));
|
|
223
228
|
}
|
|
224
229
|
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
:
|
|
230
|
+
const configuredClaudeDesktopApiKey = currentClaudeDesktopApiKey();
|
|
231
|
+
const restoredClaudeDesktopApiKey = configuredClaudeDesktopApiKey ? "" : loadManagedClaudeDesktopApiKey({
|
|
232
|
+
port: ADAPTER_PORT,
|
|
233
|
+
host: ADAPTER_HOST,
|
|
234
|
+
});
|
|
235
|
+
if (restoredClaudeDesktopApiKey) {
|
|
236
|
+
console.log(status("ok", "Restored Claude App gateway key from the managed profile"));
|
|
237
|
+
}
|
|
238
|
+
const claudeDesktopApiKey = configuredClaudeDesktopApiKey
|
|
239
|
+
|| restoredClaudeDesktopApiKey
|
|
240
|
+
|| (CONFIGURE_CLAUDE_DESKTOP ? generatedClaudeDesktopApiKey() : "");
|
|
228
241
|
|
|
229
242
|
// Start the in-process adapter.
|
|
230
243
|
activeServer = await startAdapter(ADAPTER_PORT, ADAPTER_HOST, {
|
package/package.json
CHANGED
package/src/adapter.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { chatCompletions, listModels, responses as copilotResponses, responsesCo
|
|
|
4
4
|
import { webStreamLines } from "./stream.mjs";
|
|
5
5
|
import { anthropicToChat, chatToAnthropic, streamAnthropicFromLines, countTokens } from "./anthropic.mjs";
|
|
6
6
|
import { CODEX_AUTO_REVIEW_MODEL, claudeDesktopModelsResponse, resolveAnthropicModel, resolveOpenAIModel, modelIsResponsesOnly, modelSupportsChatCompletions } from "./models.mjs";
|
|
7
|
+
import { isValidModelList } from "./model-cache.mjs";
|
|
7
8
|
import { status } from "./status.mjs";
|
|
8
9
|
import { recordAnthropicUsage, recordResponsesUsage } from "./usage.mjs";
|
|
9
10
|
import { ADAPTER_HEALTH_PATH, adapterHealthPayload } from "./running-adapter.mjs";
|
|
@@ -102,6 +103,20 @@ export function shouldServeClaudeDesktopModels(req, claudeDesktopApiKey) {
|
|
|
102
103
|
return token === claudeDesktopApiKey || xApiKey === claudeDesktopApiKey;
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
function modelListCanUseLastKnownGood(statusCode) {
|
|
107
|
+
return [408, 425, 429].includes(statusCode) || statusCode >= 500;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function sendLastKnownGoodModels(res, modelRegistry) {
|
|
111
|
+
if (!isValidModelList(modelRegistry?.models) || res.destroyed || res.writableEnded) return false;
|
|
112
|
+
res.writeHead(200, {
|
|
113
|
+
"Content-Type": "application/json",
|
|
114
|
+
"X-CCDX-Model-Source": "last-known-good",
|
|
115
|
+
});
|
|
116
|
+
res.end(JSON.stringify(modelRegistry.models));
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
105
120
|
function responsesInputItems(input, { clone = true } = {}) {
|
|
106
121
|
if (input === undefined || input === null) return [];
|
|
107
122
|
if (typeof input === "string") {
|
|
@@ -784,16 +799,13 @@ export function createAdapterHandler(options = {}) {
|
|
|
784
799
|
res.end(JSON.stringify(resp));
|
|
785
800
|
} catch (e) {
|
|
786
801
|
logRequestFailure("Responses", e, abort);
|
|
787
|
-
|
|
788
|
-
if (!res.headersSent) res.writeHead(e?.statusCode || 502);
|
|
789
|
-
res.end("Bad Gateway");
|
|
790
|
-
}
|
|
802
|
+
sendJsonError(res, e, 502);
|
|
791
803
|
}
|
|
792
804
|
}
|
|
793
805
|
}
|
|
794
806
|
} catch (e) {
|
|
795
807
|
logRequestFailure("Responses", e, abort);
|
|
796
|
-
sendJsonError(res, e);
|
|
808
|
+
sendJsonError(res, e, 502);
|
|
797
809
|
} finally {
|
|
798
810
|
abort.cleanup();
|
|
799
811
|
}
|
|
@@ -823,7 +835,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
823
835
|
});
|
|
824
836
|
} catch (e) {
|
|
825
837
|
logRequestFailure("Responses compact", e, abort);
|
|
826
|
-
sendJsonError(res, e);
|
|
838
|
+
sendJsonError(res, e, 502);
|
|
827
839
|
} finally {
|
|
828
840
|
abort.cleanup();
|
|
829
841
|
}
|
|
@@ -840,9 +852,29 @@ export function createAdapterHandler(options = {}) {
|
|
|
840
852
|
const abort = createRequestAbort(req, res);
|
|
841
853
|
abort.setTimeout(upstreamTimeoutMs);
|
|
842
854
|
listModelsFn({ signal: abort.signal })
|
|
843
|
-
.then(({ status, body }) => {
|
|
855
|
+
.then(({ status, body }) => {
|
|
856
|
+
if (status >= 200 && status < 300) {
|
|
857
|
+
let models;
|
|
858
|
+
try { models = JSON.parse(body); } catch {}
|
|
859
|
+
if (isValidModelList(models)) {
|
|
860
|
+
if (options.modelRegistry) {
|
|
861
|
+
options.modelRegistry.models = models;
|
|
862
|
+
}
|
|
863
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
864
|
+
res.end(body);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (sendLastKnownGoodModels(res, options.modelRegistry)) return;
|
|
868
|
+
sendJsonError(res, httpError("Copilot models response contained no valid models", 502), 502);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (modelListCanUseLastKnownGood(status) && sendLastKnownGoodModels(res, options.modelRegistry)) return;
|
|
872
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
873
|
+
res.end(body);
|
|
874
|
+
})
|
|
844
875
|
.catch((e) => {
|
|
845
876
|
logRequestFailure("Models", e, abort);
|
|
877
|
+
if (sendLastKnownGoodModels(res, options.modelRegistry)) return;
|
|
846
878
|
if (!res.destroyed && !res.writableEnded) {
|
|
847
879
|
if (!res.headersSent) res.writeHead(e?.statusCode || 502);
|
|
848
880
|
res.end(JSON.stringify({ error: e.message }));
|
|
@@ -927,7 +959,7 @@ export function createAdapterHandler(options = {}) {
|
|
|
927
959
|
}
|
|
928
960
|
} catch (e) {
|
|
929
961
|
logRequestFailure("Messages", e, abort);
|
|
930
|
-
sendJsonError(res, e);
|
|
962
|
+
sendJsonError(res, e, 502);
|
|
931
963
|
} finally {
|
|
932
964
|
abort.cleanup();
|
|
933
965
|
}
|
package/src/anthropic.mjs
CHANGED
|
@@ -161,6 +161,7 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
|
|
|
161
161
|
let finishReason = null;
|
|
162
162
|
const toolBlocks = {}; // openaiIndex -> { anthropicIndex }
|
|
163
163
|
let sawToolUse = false;
|
|
164
|
+
const trailingTextChunks = [];
|
|
164
165
|
let outputTokens = 0;
|
|
165
166
|
|
|
166
167
|
const ensureStart = async () => {
|
|
@@ -199,10 +200,14 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
|
|
|
199
200
|
const delta = choice.delta || {};
|
|
200
201
|
await ensureStart();
|
|
201
202
|
|
|
202
|
-
if (delta.content
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
203
|
+
if (delta.content) {
|
|
204
|
+
if (sawToolUse) {
|
|
205
|
+
trailingTextChunks.push(delta.content);
|
|
206
|
+
} else {
|
|
207
|
+
await openText();
|
|
208
|
+
await emit("content_block_delta", { type: "content_block_delta", index: blockIndex,
|
|
209
|
+
delta: { type: "text_delta", text: delta.content } });
|
|
210
|
+
}
|
|
206
211
|
}
|
|
207
212
|
|
|
208
213
|
if (Array.isArray(delta.tool_calls)) {
|
|
@@ -231,6 +236,14 @@ export async function streamAnthropicFromLines(lineIterator, emit, model, option
|
|
|
231
236
|
for (const oi of Object.keys(toolBlocks)) {
|
|
232
237
|
await emit("content_block_stop", { type: "content_block_stop", index: toolBlocks[oi].anthropicIndex });
|
|
233
238
|
}
|
|
239
|
+
if (trailingTextChunks.length) {
|
|
240
|
+
await openText();
|
|
241
|
+
for (const text of trailingTextChunks) {
|
|
242
|
+
await emit("content_block_delta", { type: "content_block_delta", index: blockIndex,
|
|
243
|
+
delta: { type: "text_delta", text } });
|
|
244
|
+
}
|
|
245
|
+
await closeText();
|
|
246
|
+
}
|
|
234
247
|
|
|
235
248
|
const stop_reason = finishReason ? mapStopReason(finishReason) : (sawToolUse ? "tool_use" : "end_turn");
|
|
236
249
|
await emit("message_delta", { type: "message_delta", delta: { stop_reason, stop_sequence: null },
|
|
@@ -159,6 +159,34 @@ function readActiveProfile(paths) {
|
|
|
159
159
|
return readJsonFile(path.join(paths.configLibraryPath, `${appliedId}.json`), {});
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
export function loadManagedClaudeDesktopApiKey({
|
|
163
|
+
port = 2026,
|
|
164
|
+
host = "127.0.0.1",
|
|
165
|
+
home = os.homedir(),
|
|
166
|
+
platform = process.platform,
|
|
167
|
+
env = process.env,
|
|
168
|
+
} = {}) {
|
|
169
|
+
try {
|
|
170
|
+
const paths = claudeDesktopPaths(home, platform, env);
|
|
171
|
+
const meta = readJsonFile(paths.metaPath, {});
|
|
172
|
+
if (String(meta.appliedId || "").trim() !== DEFAULT_CLAUDE_DESKTOP_PROFILE_ID) return "";
|
|
173
|
+
|
|
174
|
+
const profilePath = path.join(paths.configLibraryPath, `${DEFAULT_CLAUDE_DESKTOP_PROFILE_ID}.json`);
|
|
175
|
+
const profile = readJsonFile(profilePath, {});
|
|
176
|
+
if (profile.inferenceProvider !== "gateway") return "";
|
|
177
|
+
if (String(profile.inferenceGatewayAuthScheme || "").trim().toLowerCase() !== "bearer") return "";
|
|
178
|
+
|
|
179
|
+
const profileBaseUrl = String(profile.inferenceGatewayBaseUrl || "").trim();
|
|
180
|
+
validateClaudeDesktopBaseUrl(profileBaseUrl);
|
|
181
|
+
if (new URL(profileBaseUrl).href !== new URL(localGatewayBaseUrl(host, port)).href) return "";
|
|
182
|
+
|
|
183
|
+
const apiKey = String(profile.inferenceGatewayApiKey || "").trim();
|
|
184
|
+
return apiKey && apiKey !== "dummy" ? apiKey : "";
|
|
185
|
+
} catch {
|
|
186
|
+
return "";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
162
190
|
export function applyClaudeDesktopConfig({
|
|
163
191
|
port = 2026,
|
|
164
192
|
host = "127.0.0.1",
|
package/src/cli-options.mjs
CHANGED
|
@@ -51,7 +51,7 @@ export function parseRuntimeOptions(env = process.env) {
|
|
|
51
51
|
adapterHost: String(env.ADAPTER_HOST || "127.0.0.1").trim() || "127.0.0.1",
|
|
52
52
|
modelRefreshTimeoutMs: integerEnv(env, "CCDX_MODEL_REFRESH_TIMEOUT_MS", 5000, { min: 1 }),
|
|
53
53
|
existingAdapterTimeoutMs: integerEnv(env, "CCDX_EXISTING_ADAPTER_TIMEOUT_MS", 500, { min: 1 }),
|
|
54
|
-
modelRefreshIntervalMs: integerEnv(env, "CCDX_MODEL_REFRESH_INTERVAL_MS",
|
|
54
|
+
modelRefreshIntervalMs: integerEnv(env, "CCDX_MODEL_REFRESH_INTERVAL_MS", 2 * 60 * 60 * 1000, { min: 0 }),
|
|
55
55
|
upstreamTimeoutMs: integerEnv(env, "CCDX_UPSTREAM_TIMEOUT_MS", 120000, { min: 1 }),
|
|
56
56
|
streamHandshakeTimeoutMs: integerEnv(env, "CCDX_STREAM_HANDSHAKE_TIMEOUT_MS", 120000, { min: 1 }),
|
|
57
57
|
streamIdleTimeoutMs: integerEnv(env, "CCDX_STREAM_IDLE_TIMEOUT_MS", 120000, { min: 1 }),
|
package/src/http-transport.mjs
CHANGED
|
@@ -101,10 +101,10 @@ async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
|
|
|
101
101
|
} else if (encoding === "br") {
|
|
102
102
|
decoded = await decompressBody(brotliDecompressAsync, decoded, maxBytes);
|
|
103
103
|
} else if (encoding === "zstd") {
|
|
104
|
-
if (!zstdDecompressAsync) throw
|
|
104
|
+
if (!zstdDecompressAsync) throw httpError("Unsupported Content-Encoding: zstd", 415);
|
|
105
105
|
decoded = await decompressBody(zstdDecompressAsync, decoded, maxBytes);
|
|
106
106
|
} else {
|
|
107
|
-
throw
|
|
107
|
+
throw httpError(`Unsupported Content-Encoding: ${encoding}`, 415);
|
|
108
108
|
}
|
|
109
109
|
if (decoded.length > maxBytes) throw payloadTooLarge("Decoded", maxBytes);
|
|
110
110
|
}
|
|
@@ -112,17 +112,31 @@ async function decodeRequestBuffer(buffer, contentEncoding, maxBytes) {
|
|
|
112
112
|
return decoded;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
function parseRequestJson(text) {
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(text);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
throw httpError(`Invalid JSON request body: ${e.message}`, 400);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
115
123
|
export async function readJsonBody(req, {
|
|
116
124
|
maxBodyBytes = MAX_BODY_BYTES,
|
|
117
125
|
maxDecodedBodyBytes = MAX_DECODED_BODY_BYTES,
|
|
118
126
|
} = {}) {
|
|
119
127
|
const encodings = contentEncodings(req.headers?.["content-encoding"]);
|
|
120
128
|
if (encodings.every((encoding) => encoding === "identity")) {
|
|
121
|
-
return
|
|
129
|
+
return parseRequestJson(await readIdentityText(req, maxBodyBytes, maxDecodedBodyBytes));
|
|
122
130
|
}
|
|
123
131
|
const buffer = await readRequestBuffer(req, maxBodyBytes);
|
|
124
|
-
|
|
125
|
-
|
|
132
|
+
let decoded;
|
|
133
|
+
try {
|
|
134
|
+
decoded = await decodeRequestBuffer(buffer, req.headers?.["content-encoding"], maxDecodedBodyBytes);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
if (e?.statusCode) throw e;
|
|
137
|
+
throw httpError(`Invalid compressed request body: ${e.message}`, 400);
|
|
138
|
+
}
|
|
139
|
+
return parseRequestJson(decoded.toString("utf8"));
|
|
126
140
|
}
|
|
127
141
|
|
|
128
142
|
export function sendJsonError(res, err, fallbackStatus = 400) {
|
|
@@ -167,7 +181,7 @@ export function isAbortLikeError(err) {
|
|
|
167
181
|
export function abortErrorStatusCode(reason) {
|
|
168
182
|
if (["upstream_timeout", "stream_handshake_timeout", "stream_idle_timeout"].includes(reason)) return 504;
|
|
169
183
|
if (reason === "client_aborted" || reason === "client_closed") return 499;
|
|
170
|
-
return
|
|
184
|
+
return 502;
|
|
171
185
|
}
|
|
172
186
|
|
|
173
187
|
export function createRequestAbort(req, res) {
|
package/src/model-cache.mjs
CHANGED
|
@@ -10,7 +10,12 @@ export function modelCachePath(home = os.homedir()) {
|
|
|
10
10
|
|
|
11
11
|
function modelData(models) {
|
|
12
12
|
const data = Array.isArray(models) ? models : models?.data;
|
|
13
|
-
|
|
13
|
+
if (!Array.isArray(data) || !data.some((model) => String(model?.id || "").trim())) return null;
|
|
14
|
+
return data;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isValidModelList(models) {
|
|
18
|
+
return modelData(models) !== null;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
export function loadModelCache({
|
|
@@ -22,7 +27,7 @@ export function loadModelCache({
|
|
|
22
27
|
const parsed = JSON.parse(fs.readFileSync(modelCachePath(home), "utf8"));
|
|
23
28
|
const savedAt = Date.parse(parsed.saved_at);
|
|
24
29
|
if (!Number.isFinite(savedAt) || now() - savedAt > maxAgeMs) return null;
|
|
25
|
-
if (!
|
|
30
|
+
if (!isValidModelList(parsed.models)) return null;
|
|
26
31
|
return parsed.models;
|
|
27
32
|
} catch {
|
|
28
33
|
return null;
|
|
@@ -30,7 +35,7 @@ export function loadModelCache({
|
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
export function saveModelCache(models, { home = os.homedir() } = {}) {
|
|
33
|
-
if (!
|
|
38
|
+
if (!isValidModelList(models)) return false;
|
|
34
39
|
const filePath = modelCachePath(home);
|
|
35
40
|
const tempPath = `${filePath}.${process.pid}.tmp`;
|
|
36
41
|
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
package/src/response-history.mjs
CHANGED
|
@@ -73,6 +73,7 @@ function jsonByteLength(value, seen = new Set()) {
|
|
|
73
73
|
|
|
74
74
|
const histories = new Map();
|
|
75
75
|
const childrenById = new Map();
|
|
76
|
+
const treeLru = new Map();
|
|
76
77
|
const evictedIds = new Set();
|
|
77
78
|
let totalBytes = 0;
|
|
78
79
|
let maxBytes = positiveInt(process.env.CCDX_RESPONSE_HISTORY_MAX_BYTES, DEFAULT_MAX_BYTES);
|
|
@@ -83,6 +84,12 @@ function rememberEvictedId(id) {
|
|
|
83
84
|
while (evictedIds.size > 256) evictedIds.delete(evictedIds.values().next().value);
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
function touchTree(rootId) {
|
|
88
|
+
if (!rootId) return;
|
|
89
|
+
treeLru.delete(rootId);
|
|
90
|
+
treeLru.set(rootId, true);
|
|
91
|
+
}
|
|
92
|
+
|
|
86
93
|
function linkChild(parentId, id) {
|
|
87
94
|
if (!parentId) return;
|
|
88
95
|
let children = childrenById.get(parentId);
|
|
@@ -104,6 +111,7 @@ function unlinkChild(parentId, id) {
|
|
|
104
111
|
function removeSubtree(rootId) {
|
|
105
112
|
const pending = [rootId];
|
|
106
113
|
const removed = new Set();
|
|
114
|
+
const affectedRoots = new Set();
|
|
107
115
|
while (pending.length) {
|
|
108
116
|
const id = pending.pop();
|
|
109
117
|
if (removed.has(id)) continue;
|
|
@@ -114,25 +122,46 @@ function removeSubtree(rootId) {
|
|
|
114
122
|
for (const id of removed) {
|
|
115
123
|
const entry = histories.get(id);
|
|
116
124
|
if (!entry) continue;
|
|
125
|
+
affectedRoots.add(entry.rootId);
|
|
117
126
|
unlinkChild(entry.parentId, id);
|
|
118
127
|
childrenById.delete(id);
|
|
119
128
|
totalBytes -= entry.bytes;
|
|
120
129
|
histories.delete(id);
|
|
121
130
|
rememberEvictedId(id);
|
|
122
131
|
}
|
|
132
|
+
for (const affectedRoot of affectedRoots) {
|
|
133
|
+
const entry = histories.get(affectedRoot);
|
|
134
|
+
if (!entry || entry.rootId !== affectedRoot) treeLru.delete(affectedRoot);
|
|
135
|
+
}
|
|
136
|
+
if (!histories.has(rootId)) treeLru.delete(rootId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function assignSubtreeRoot(id, rootId) {
|
|
140
|
+
const pending = [id];
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
while (pending.length) {
|
|
143
|
+
const currentId = pending.pop();
|
|
144
|
+
if (seen.has(currentId)) continue;
|
|
145
|
+
seen.add(currentId);
|
|
146
|
+
const entry = histories.get(currentId);
|
|
147
|
+
if (entry) entry.rootId = rootId;
|
|
148
|
+
const children = childrenById.get(currentId);
|
|
149
|
+
if (children) pending.push(...children);
|
|
150
|
+
}
|
|
123
151
|
}
|
|
124
152
|
|
|
125
153
|
function enforceLimits() {
|
|
126
154
|
while (histories.size > maxEntries || totalBytes > maxBytes) {
|
|
127
|
-
const
|
|
128
|
-
if (!
|
|
129
|
-
removeSubtree(
|
|
155
|
+
const oldestRootId = treeLru.keys().next().value;
|
|
156
|
+
if (!oldestRootId) break;
|
|
157
|
+
removeSubtree(oldestRootId);
|
|
130
158
|
}
|
|
131
159
|
}
|
|
132
160
|
|
|
133
161
|
export function clearResponseHistoryForTests() {
|
|
134
162
|
histories.clear();
|
|
135
163
|
childrenById.clear();
|
|
164
|
+
treeLru.clear();
|
|
136
165
|
evictedIds.clear();
|
|
137
166
|
totalBytes = 0;
|
|
138
167
|
maxBytes = DEFAULT_MAX_BYTES;
|
|
@@ -163,9 +192,12 @@ export function materializeResponseHistory(responseId) {
|
|
|
163
192
|
chain.push(entry);
|
|
164
193
|
currentId = entry.parentId;
|
|
165
194
|
}
|
|
195
|
+
const rootId = chain[0]?.rootId;
|
|
166
196
|
const items = [];
|
|
167
197
|
for (const entry of chain.reverse()) items.push(...entry.inputItems, ...entry.outputItems);
|
|
168
|
-
|
|
198
|
+
const materialized = cloneJson(items);
|
|
199
|
+
touchTree(rootId);
|
|
200
|
+
return materialized;
|
|
169
201
|
}
|
|
170
202
|
|
|
171
203
|
export function rememberResponseHistoryNode({ id, parentId, inputItems, outputItems, takeOwnership = false }) {
|
|
@@ -176,20 +208,30 @@ export function rememberResponseHistoryNode({ id, parentId, inputItems, outputIt
|
|
|
176
208
|
rememberEvictedId(id);
|
|
177
209
|
return;
|
|
178
210
|
}
|
|
211
|
+
const existing = histories.get(id);
|
|
212
|
+
const parentEntry = parentId ? histories.get(parentId) : null;
|
|
213
|
+
const rootId = parentEntry?.rootId || (parentId ? existing?.rootId : id) || id;
|
|
179
214
|
const entry = {
|
|
180
215
|
parentId: parentId || null,
|
|
216
|
+
rootId,
|
|
181
217
|
inputItems: takeOwnership ? inputItems : cloneJson(inputItems),
|
|
182
218
|
outputItems: takeOwnership ? outputItems : cloneJson(outputItems),
|
|
183
219
|
bytes,
|
|
184
220
|
};
|
|
185
|
-
const
|
|
221
|
+
const oldRootId = existing?.rootId;
|
|
186
222
|
if (existing) {
|
|
187
223
|
totalBytes -= existing.bytes;
|
|
188
224
|
unlinkChild(existing.parentId, id);
|
|
189
225
|
}
|
|
190
226
|
histories.set(id, entry);
|
|
191
227
|
linkChild(entry.parentId, id);
|
|
228
|
+
if (oldRootId && oldRootId !== rootId) {
|
|
229
|
+
assignSubtreeRoot(id, rootId);
|
|
230
|
+
const oldRoot = histories.get(oldRootId);
|
|
231
|
+
if (!oldRoot || oldRoot.rootId !== oldRootId) treeLru.delete(oldRootId);
|
|
232
|
+
}
|
|
192
233
|
totalBytes += entry.bytes;
|
|
193
234
|
evictedIds.delete(id);
|
|
235
|
+
touchTree(rootId);
|
|
194
236
|
enforceLimits();
|
|
195
237
|
}
|