codex-copilot-dx 0.6.14 → 0.6.18
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 +30 -16
- package/bin/cli.mjs +5 -0
- package/package.json +1 -1
- package/src/adapter.mjs +34 -9
- package/src/anthropic-handler.mjs +54 -4
- package/src/anthropic.mjs +64 -26
- package/src/atomic-file.mjs +76 -2
- package/src/auth-profile.mjs +11 -34
- package/src/auth.mjs +265 -52
- package/src/cli-auth.mjs +28 -71
- package/src/cli-options.mjs +8 -5
- package/src/copilot-client.mjs +5 -1
- package/src/copilot-responses-compat.mjs +80 -0
- package/src/copilot-responses-policy.mjs +78 -0
- package/src/copilot.mjs +2 -1
- package/src/http-transport.mjs +91 -0
- package/src/lock.mjs +179 -17
- package/src/model-cache.mjs +36 -6
- package/src/models.mjs +17 -0
- package/src/observability.mjs +13 -1
- package/src/pm-studio-asar.mjs +399 -19
- package/src/pm-studio-relay.mjs +6 -3
- package/src/pm-studio-setup.mjs +319 -14
- package/src/pm-studio-status.mjs +5 -1
- package/src/profile-model-runtime.mjs +87 -13
- package/src/profile-routing.mjs +45 -7
- package/src/response-history.mjs +142 -16
- package/src/responses-bridge.mjs +22 -4
- package/src/responses-handler.mjs +154 -39
- package/src/responses-image-pressure.mjs +30 -0
- package/src/responses-proxy.mjs +10 -2
- package/src/responses-request.mjs +109 -103
- package/src/route-plan.mjs +51 -0
- package/src/startup.mjs +8 -2
- package/src/usage.mjs +25 -7
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
function cloneJson(value) {
|
|
2
|
+
return value === undefined ? undefined : structuredClone(value);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function isEncryptedContentVerificationError(statusCode, text) {
|
|
6
|
+
if (statusCode < 400 || !text) return false;
|
|
7
|
+
const lower = String(text).toLowerCase();
|
|
8
|
+
const reasoningFailure = lower.includes("encrypted content")
|
|
9
|
+
&& lower.includes("could not be verified")
|
|
10
|
+
&& (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
|
|
11
|
+
const functionOutputFailure = lower.includes("encrypted function output content")
|
|
12
|
+
&& lower.includes("could not be decrypted or decoded");
|
|
13
|
+
const missingEncryptedContent = statusCode < 500
|
|
14
|
+
&& /missing required parameter:\s*(['"]?)input\[\d+\](?:\.[a-z0-9_]+|\[\d+\])*\.encrypted_content\1(?=\.?(?:\s|$|["},\]]))/.test(lower);
|
|
15
|
+
return reasoningFailure || functionOutputFailure || missingEncryptedContent;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isImageNamespaceCollisionError(statusCode, text) {
|
|
19
|
+
if (statusCode < 400 || !text) return false;
|
|
20
|
+
const lower = String(text).toLowerCase();
|
|
21
|
+
return lower.includes("namespace")
|
|
22
|
+
&& lower.includes("image_gen")
|
|
23
|
+
&& lower.includes("collid");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isCopilotImageNamespaceTool(tool, { collisionFallback = false } = {}) {
|
|
27
|
+
if (!tool || typeof tool !== "object") return false;
|
|
28
|
+
const type = String(tool.type || "").toLowerCase();
|
|
29
|
+
if (["image_gen", "image_generation"].includes(type)) return true;
|
|
30
|
+
if (!collisionFallback) return false;
|
|
31
|
+
const name = String(tool.name || tool.function?.name || "").toLowerCase();
|
|
32
|
+
const namespace = String(tool.namespace || "").toLowerCase();
|
|
33
|
+
if (["image_gen", "image_generation"].includes(name)) return true;
|
|
34
|
+
if (namespace === "image_gen" || namespace === "image_generation") return true;
|
|
35
|
+
return [type, name, namespace].some((value) => value.startsWith("image_gen"));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toolName(tool) {
|
|
39
|
+
return String(tool?.name || tool?.function?.name || "").trim();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Copilot currently rejects the public image-generation tool. Keep this
|
|
43
|
+
// provider-owned policy paired with tool_choice so request invariants survive
|
|
44
|
+
// the removal. Revalidate against Copilot before deleting or broadening it.
|
|
45
|
+
// Reference: https://github.com/Menci/Floway/blob/ac2bbb04352033a7d9d74574a9d465d40395ef06/packages/provider-copilot/src/interceptors/openai-responses/strip-image-generation.ts
|
|
46
|
+
export function applyCopilotResponsesRequestPolicies(body, options = {}) {
|
|
47
|
+
if (!Array.isArray(body?.tools)) return false;
|
|
48
|
+
const removedNames = new Set();
|
|
49
|
+
const filtered = body.tools.filter((tool) => {
|
|
50
|
+
const removed = isCopilotImageNamespaceTool(tool, options);
|
|
51
|
+
if (removed) {
|
|
52
|
+
const name = toolName(tool);
|
|
53
|
+
if (name) removedNames.add(name);
|
|
54
|
+
}
|
|
55
|
+
return !removed;
|
|
56
|
+
});
|
|
57
|
+
if (filtered.length === body.tools.length) return false;
|
|
58
|
+
if (filtered.length) body.tools = filtered;
|
|
59
|
+
else delete body.tools;
|
|
60
|
+
|
|
61
|
+
const choiceName = toolName(body.tool_choice);
|
|
62
|
+
const choiceTargetsRemovedTool = choiceName
|
|
63
|
+
&& removedNames.has(choiceName)
|
|
64
|
+
&& !filtered.some((tool) => toolName(tool) === choiceName);
|
|
65
|
+
if (isCopilotImageNamespaceTool(body.tool_choice, options)
|
|
66
|
+
|| choiceTargetsRemovedTool
|
|
67
|
+
|| (body.tool_choice === "required" && !body.tools)) {
|
|
68
|
+
delete body.tool_choice;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function sanitizeImageNamespaceCollisionRequest(reqContext) {
|
|
74
|
+
if (!Array.isArray(reqContext?.body?.tools)) return null;
|
|
75
|
+
const body = cloneJson(reqContext.body);
|
|
76
|
+
if (!applyCopilotResponsesRequestPolicies(body, { collisionFallback: true })) return null;
|
|
77
|
+
return { ...reqContext, body };
|
|
78
|
+
}
|
package/src/copilot.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { status } from "./status.mjs";
|
|
3
3
|
import { debugLog } from "./log.mjs";
|
|
4
4
|
import { createCopilotClientRuntime } from "./copilot-client.mjs";
|
|
5
|
+
import { discardBoundedResponseBody } from "./http-transport.mjs";
|
|
5
6
|
|
|
6
7
|
export {
|
|
7
8
|
optimizeImageDataUrl,
|
|
@@ -156,7 +157,7 @@ export async function fetchCopilotUpstream(
|
|
|
156
157
|
const resp = await fetchImpl(url, init);
|
|
157
158
|
debugLog(`upstream ${method} ${target} status=${resp.status} attempt=${attempt + 1}/${retryCount + 1} attempt_ms=${Date.now() - attemptStart} total_ms=${Date.now() - totalStart}`);
|
|
158
159
|
if (attempt < retryCount && isRetryableUpstreamStatus(resp.status, method)) {
|
|
159
|
-
await resp
|
|
160
|
+
await discardBoundedResponseBody(resp);
|
|
160
161
|
const delay = upstreamRetryDelay(attempt, baseDelay);
|
|
161
162
|
console.warn(status("warn", `upstream ${target} returned ${resp.status}; retry ${attempt + 1}/${retryCount} in ${delay}ms`));
|
|
162
163
|
await sleep(delay, { signal });
|
package/src/http-transport.mjs
CHANGED
|
@@ -17,12 +17,103 @@ const MAX_INFLIGHT_BODY_BYTES = HTTP_RUNTIME_CONFIG.maxInflightBodyBytes;
|
|
|
17
17
|
const MAX_QUEUED_REQUESTS = HTTP_RUNTIME_CONFIG.maxQueuedRequests;
|
|
18
18
|
const REQUEST_QUEUE_TIMEOUT_MS = HTTP_RUNTIME_CONFIG.requestQueueTimeoutMs;
|
|
19
19
|
|
|
20
|
+
export const MAX_UPSTREAM_ERROR_BODY_BYTES = 1024 * 1024;
|
|
21
|
+
export const MAX_UPSTREAM_MODEL_CATALOG_BYTES = 8 * 1024 * 1024;
|
|
22
|
+
export const MAX_UPSTREAM_RETRY_DRAIN_BYTES = 64 * 1024;
|
|
23
|
+
|
|
20
24
|
export function httpError(message, statusCode) {
|
|
21
25
|
const err = new Error(message);
|
|
22
26
|
err.statusCode = statusCode;
|
|
23
27
|
return err;
|
|
24
28
|
}
|
|
25
29
|
|
|
30
|
+
function upstreamBodyTooLarge(label, maxBytes) {
|
|
31
|
+
const error = httpError(`${label} exceeds ${maxBytes} bytes`, 502);
|
|
32
|
+
error.code = "ccdx_upstream_response_too_large";
|
|
33
|
+
error.jsonBody = {
|
|
34
|
+
error: {
|
|
35
|
+
message: error.message,
|
|
36
|
+
type: "upstream_error",
|
|
37
|
+
code: error.code,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
return error;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function unreadableUpstreamBody(label) {
|
|
44
|
+
const error = httpError(`${label} is not a readable web stream`, 502);
|
|
45
|
+
error.code = "ccdx_upstream_response_unreadable";
|
|
46
|
+
error.jsonBody = {
|
|
47
|
+
error: {
|
|
48
|
+
message: error.message,
|
|
49
|
+
type: "upstream_error",
|
|
50
|
+
code: error.code,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
return error;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function cancelResponseBody(response) {
|
|
57
|
+
try { await response?.body?.cancel?.(); } catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function responseContentLength(response) {
|
|
61
|
+
const raw = response?.headers?.get?.("content-length")
|
|
62
|
+
?? response?.headers?.["content-length"]
|
|
63
|
+
?? response?.headers?.["Content-Length"];
|
|
64
|
+
const parsed = Number.parseInt(raw || "", 10);
|
|
65
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function readBoundedResponseBuffer(response, {
|
|
69
|
+
maxBytes,
|
|
70
|
+
label = "Upstream response body",
|
|
71
|
+
} = {}) {
|
|
72
|
+
const limit = Number.isFinite(maxBytes) && maxBytes >= 0 ? Math.floor(maxBytes) : 0;
|
|
73
|
+
if (responseContentLength(response) > limit) {
|
|
74
|
+
await cancelResponseBody(response);
|
|
75
|
+
throw upstreamBodyTooLarge(label, limit);
|
|
76
|
+
}
|
|
77
|
+
if (!response?.body) {
|
|
78
|
+
if ((responseContentLength(response) || 0) > 0) throw unreadableUpstreamBody(label);
|
|
79
|
+
return Buffer.alloc(0);
|
|
80
|
+
}
|
|
81
|
+
const reader = response?.body?.getReader?.();
|
|
82
|
+
if (!reader) throw unreadableUpstreamBody(label);
|
|
83
|
+
const chunks = [];
|
|
84
|
+
let total = 0;
|
|
85
|
+
try {
|
|
86
|
+
while (true) {
|
|
87
|
+
const { done, value } = await reader.read();
|
|
88
|
+
if (done) break;
|
|
89
|
+
const chunk = Buffer.from(value);
|
|
90
|
+
total += chunk.length;
|
|
91
|
+
if (total > limit) {
|
|
92
|
+
await reader.cancel().catch(() => {});
|
|
93
|
+
throw upstreamBodyTooLarge(label, limit);
|
|
94
|
+
}
|
|
95
|
+
chunks.push(chunk);
|
|
96
|
+
}
|
|
97
|
+
return Buffer.concat(chunks, total);
|
|
98
|
+
} finally {
|
|
99
|
+
reader.releaseLock();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function readBoundedResponseText(response, options) {
|
|
104
|
+
return new TextDecoder().decode(await readBoundedResponseBuffer(response, options));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function discardBoundedResponseBody(response, maxBytes = MAX_UPSTREAM_RETRY_DRAIN_BYTES) {
|
|
108
|
+
try {
|
|
109
|
+
await readBoundedResponseBuffer(response, { maxBytes, label: "Upstream retry response body" });
|
|
110
|
+
return true;
|
|
111
|
+
} catch {
|
|
112
|
+
await cancelResponseBody(response);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
26
117
|
function payloadTooLarge(kind, maxBytes) {
|
|
27
118
|
return httpError(`${kind} request body exceeds ${maxBytes} bytes`, 413);
|
|
28
119
|
}
|
package/src/lock.mjs
CHANGED
|
@@ -32,23 +32,171 @@ function processIsAlive(pid) {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function readStaleLockSnapshot(lockPath, io) {
|
|
36
|
+
const before = io.lstatSync(lockPath);
|
|
37
|
+
if (!before.isFile() || before.isSymbolicLink()) return { safe: false };
|
|
38
|
+
|
|
39
|
+
const noFollow = io.constants?.O_NOFOLLOW || 0;
|
|
40
|
+
let fd;
|
|
41
|
+
try {
|
|
42
|
+
fd = io.openSync(lockPath, io.constants.O_RDONLY | noFollow);
|
|
43
|
+
const opened = io.fstatSync(fd);
|
|
44
|
+
if (!opened.isFile()
|
|
45
|
+
|| opened.dev !== before.dev
|
|
46
|
+
|| opened.ino !== before.ino
|
|
47
|
+
|| opened.size !== before.size) {
|
|
48
|
+
return { safe: false };
|
|
49
|
+
}
|
|
50
|
+
const text = io.readFileSync(fd, "utf8");
|
|
51
|
+
const after = io.fstatSync(fd);
|
|
52
|
+
if (after.dev !== opened.dev
|
|
53
|
+
|| after.ino !== opened.ino
|
|
54
|
+
|| after.size !== opened.size
|
|
55
|
+
|| after.mtimeMs !== opened.mtimeMs
|
|
56
|
+
|| after.ctimeMs !== opened.ctimeMs) {
|
|
57
|
+
return { safe: false };
|
|
58
|
+
}
|
|
59
|
+
let record = {};
|
|
60
|
+
try {
|
|
61
|
+
record = JSON.parse(text);
|
|
62
|
+
} catch {}
|
|
63
|
+
return {
|
|
64
|
+
safe: true,
|
|
65
|
+
dev: after.dev,
|
|
66
|
+
ino: after.ino,
|
|
67
|
+
mtimeMs: after.mtimeMs,
|
|
68
|
+
size: after.size,
|
|
69
|
+
text,
|
|
70
|
+
pid: Number(record?.pid),
|
|
71
|
+
owner: typeof record?.owner === "string" ? record.owner : "",
|
|
72
|
+
};
|
|
73
|
+
} finally {
|
|
74
|
+
if (fd !== undefined) io.closeSync(fd);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
35
78
|
function sameLock(left, right) {
|
|
36
79
|
return left.dev === right.dev
|
|
37
80
|
&& left.ino === right.ino
|
|
38
81
|
&& left.mtimeMs === right.mtimeMs
|
|
39
82
|
&& left.size === right.size
|
|
40
|
-
&& left.owner === right.owner
|
|
83
|
+
&& left.owner === right.owner
|
|
84
|
+
&& left.text === right.text;
|
|
41
85
|
}
|
|
42
86
|
|
|
43
|
-
function
|
|
44
|
-
|
|
87
|
+
function lockPathExists(lockPath, io) {
|
|
88
|
+
try {
|
|
89
|
+
io.lstatSync(lockPath);
|
|
90
|
+
return true;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (error?.code === "ENOENT") return false;
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeLockRecord(lockPath, record, io) {
|
|
98
|
+
const fd = io.openSync(lockPath, "wx", 0o600);
|
|
99
|
+
try {
|
|
100
|
+
io.writeFileSync(fd, JSON.stringify(record));
|
|
101
|
+
} catch (error) {
|
|
102
|
+
io.closeSync(fd);
|
|
103
|
+
try { io.unlinkSync(lockPath); } catch {}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
return fd;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function removeOwnedLock(lockPath, owner, io) {
|
|
110
|
+
try {
|
|
111
|
+
const snapshot = readStaleLockSnapshot(lockPath, io);
|
|
112
|
+
if (snapshot.safe && snapshot.owner === owner) io.unlinkSync(lockPath);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error?.code !== "ENOENT") throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function reclaimGuardPath(lockPath) {
|
|
119
|
+
return `${lockPath}.reclaim`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function acquireReclaimGuard(lockPath, io) {
|
|
123
|
+
const guardPath = reclaimGuardPath(lockPath);
|
|
124
|
+
const owner = randomUUID();
|
|
125
|
+
let fd;
|
|
126
|
+
try {
|
|
127
|
+
fd = writeLockRecord(guardPath, {
|
|
128
|
+
pid: process.pid,
|
|
129
|
+
owner,
|
|
130
|
+
created_at: new Date().toISOString(),
|
|
131
|
+
}, io);
|
|
132
|
+
io.closeSync(fd);
|
|
133
|
+
return { guardPath, owner };
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (fd !== undefined) try { io.closeSync(fd); } catch {}
|
|
136
|
+
if (error?.code === "EEXIST") return null;
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function maybeRemoveAbandonedGuard(guardPath, staleMs, nowMs, io, processAlive) {
|
|
45
142
|
try {
|
|
46
|
-
const
|
|
47
|
-
if (nowMs -
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
143
|
+
const snapshot = readStaleLockSnapshot(guardPath, io);
|
|
144
|
+
if (!snapshot.safe || nowMs - snapshot.mtimeMs <= staleMs || processAlive(snapshot.pid)) return false;
|
|
145
|
+
const quarantinePath = `${guardPath}.stale-${randomUUID()}`;
|
|
146
|
+
io.renameSync(guardPath, quarantinePath);
|
|
147
|
+
const moved = readStaleLockSnapshot(quarantinePath, io);
|
|
148
|
+
if (!moved.safe || !sameLock(snapshot, moved)) {
|
|
149
|
+
restoreQuarantinedLock(guardPath, quarantinePath, io);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
io.unlinkSync(quarantinePath);
|
|
51
153
|
return true;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return error?.code === "ENOENT";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function restoreQuarantinedLock(lockPath, quarantinePath, io) {
|
|
160
|
+
try {
|
|
161
|
+
io.linkSync(quarantinePath, lockPath);
|
|
162
|
+
} catch {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
try {
|
|
166
|
+
io.unlinkSync(quarantinePath);
|
|
167
|
+
} catch {}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function maybeRemoveStaleLock(lockPath, staleMs, nowMs, io, processAlive) {
|
|
171
|
+
if (!Number.isFinite(staleMs) || staleMs <= 0) return false;
|
|
172
|
+
try {
|
|
173
|
+
const first = readStaleLockSnapshot(lockPath, io);
|
|
174
|
+
if (!first.safe || nowMs - first.mtimeMs <= staleMs || processAlive(first.pid)) return false;
|
|
175
|
+
const guard = acquireReclaimGuard(lockPath, io);
|
|
176
|
+
if (!guard) return false;
|
|
177
|
+
try {
|
|
178
|
+
const current = readStaleLockSnapshot(lockPath, io);
|
|
179
|
+
if (!sameLock(first, current)
|
|
180
|
+
|| nowMs - current.mtimeMs <= staleMs
|
|
181
|
+
|| processAlive(current.pid)) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
const quarantinePath = `${lockPath}.stale-${randomUUID()}`;
|
|
185
|
+
try {
|
|
186
|
+
io.renameSync(lockPath, quarantinePath);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return e?.code === "ENOENT";
|
|
189
|
+
}
|
|
190
|
+
const moved = readStaleLockSnapshot(quarantinePath, io);
|
|
191
|
+
if (!moved.safe || !sameLock(current, moved)) {
|
|
192
|
+
restoreQuarantinedLock(lockPath, quarantinePath, io);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
io.unlinkSync(quarantinePath);
|
|
196
|
+
return true;
|
|
197
|
+
} finally {
|
|
198
|
+
removeOwnedLock(guard.guardPath, guard.owner, io);
|
|
199
|
+
}
|
|
52
200
|
} catch (e) {
|
|
53
201
|
if (e?.code === "ENOENT") return true;
|
|
54
202
|
return false;
|
|
@@ -60,28 +208,42 @@ export async function withFileLock(lockPath, fn, {
|
|
|
60
208
|
staleMs = 15 * 60 * 1000,
|
|
61
209
|
pollMs = 50,
|
|
62
210
|
now = Date.now,
|
|
211
|
+
fsImpl = fs,
|
|
212
|
+
processAlive = processIsAlive,
|
|
63
213
|
} = {}) {
|
|
64
214
|
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
65
215
|
const started = now();
|
|
66
216
|
const owner = randomUUID();
|
|
67
217
|
let fd = null;
|
|
218
|
+
const guardPath = reclaimGuardPath(lockPath);
|
|
68
219
|
|
|
69
220
|
while (fd === null) {
|
|
221
|
+
const beforeAcquire = now();
|
|
222
|
+
if (lockPathExists(guardPath, fsImpl)) {
|
|
223
|
+
maybeRemoveAbandonedGuard(guardPath, staleMs, beforeAcquire, fsImpl, processAlive);
|
|
224
|
+
if (lockPathExists(guardPath, fsImpl)) {
|
|
225
|
+
if (beforeAcquire - started >= timeoutMs) throw new Error(`Timed out waiting for lock: ${lockPath}`);
|
|
226
|
+
await sleep(Math.min(pollMs, Math.max(1, timeoutMs - (beforeAcquire - started))));
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
70
230
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
231
|
+
fd = writeLockRecord(lockPath, {
|
|
232
|
+
pid: process.pid,
|
|
233
|
+
owner,
|
|
234
|
+
created_at: new Date().toISOString(),
|
|
235
|
+
}, fs);
|
|
236
|
+
if (lockPathExists(guardPath, fsImpl)) {
|
|
237
|
+
fs.closeSync(fd);
|
|
238
|
+
fd = null;
|
|
239
|
+
removeOwnedLock(lockPath, owner, fs);
|
|
240
|
+
continue;
|
|
79
241
|
}
|
|
80
242
|
break;
|
|
81
243
|
} catch (e) {
|
|
82
244
|
if (e?.code !== "EEXIST") throw e;
|
|
83
245
|
const nowMs = now();
|
|
84
|
-
if (maybeRemoveStaleLock(lockPath, staleMs, nowMs)) continue;
|
|
246
|
+
if (maybeRemoveStaleLock(lockPath, staleMs, nowMs, fsImpl, processAlive)) continue;
|
|
85
247
|
if (nowMs - started >= timeoutMs) {
|
|
86
248
|
throw new Error(`Timed out waiting for lock: ${lockPath}`);
|
|
87
249
|
}
|
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/models.mjs
CHANGED
|
@@ -117,6 +117,23 @@ export function modelIsResponsesOnly(model) {
|
|
|
117
117
|
return hasResponses && !endpoints.includes("/chat/completions");
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
export function resolveCopilotPriorityTierModel(model, serviceTier, models) {
|
|
121
|
+
const baseModel = String(model || "").trim();
|
|
122
|
+
if (baseModel !== "gpt-5.6-sol" || serviceTier !== "priority") return null;
|
|
123
|
+
const fastModelId = "gpt-5.6-sol-fast";
|
|
124
|
+
const fastModel = copilotModelData(models)
|
|
125
|
+
.find((entry) => String(entry?.id || "").trim() === fastModelId);
|
|
126
|
+
const vendor = String(fastModel?.vendor || fastModel?.owned_by || "").trim().toLowerCase();
|
|
127
|
+
const policy = String(fastModel?.policy?.state || "").trim().toLowerCase();
|
|
128
|
+
if (vendor !== "openai"
|
|
129
|
+
|| policy !== "enabled"
|
|
130
|
+
|| fastModel?.model_picker_enabled !== true
|
|
131
|
+
|| !modelIsResponsesOnly(fastModel)) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
return fastModelId;
|
|
135
|
+
}
|
|
136
|
+
|
|
120
137
|
function copilotModelData(models) {
|
|
121
138
|
const data = Array.isArray(models) ? models : models?.data;
|
|
122
139
|
return Array.isArray(data) ? data : [];
|
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) {
|