codex-copilot-dx 0.6.14 → 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 +4 -1
- package/package.json +1 -1
- package/src/adapter.mjs +6 -2
- package/src/anthropic-handler.mjs +38 -2
- package/src/anthropic.mjs +55 -22
- package/src/copilot-responses-compat.mjs +73 -0
- package/src/copilot-responses-policy.mjs +78 -0
- 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-handler.mjs +42 -9
- package/src/responses-proxy.mjs +2 -1
- package/src/responses-request.mjs +96 -102
- package/src/route-plan.mjs +51 -0
- package/src/startup.mjs +8 -2
package/README.md
CHANGED
|
@@ -91,7 +91,10 @@ Once enabled, routing is fixed by API surface rather than by model name:
|
|
|
91
91
|
|
|
92
92
|
The two profiles keep separate service tokens, API bases, refresh/backoff state,
|
|
93
93
|
model endpoint metadata, and credential-bound model caches. Switching either
|
|
94
|
-
GitHub account invalidates that profile's old cache instead of borrowing it.
|
|
94
|
+
GitHub account invalidates that profile's old cache instead of borrowing it.
|
|
95
|
+
Cached catalogs are fresh for two hours; older catalogs remain immediately
|
|
96
|
+
available for up to seven days while CCDX refreshes them in the background.
|
|
97
|
+
If the isolated Claude profile later
|
|
95
98
|
becomes invalid, Claude requests fail with a reauthentication instruction while
|
|
96
99
|
Codex remains available; CCDX never silently falls back to the enterprise
|
|
97
100
|
account. Reauthorize it explicitly with:
|
package/package.json
CHANGED
package/src/adapter.mjs
CHANGED
|
@@ -41,14 +41,18 @@ export {
|
|
|
41
41
|
responseHistoryStats,
|
|
42
42
|
} from "./response-history.mjs";
|
|
43
43
|
export { forwardToChat, responsesToChat } from "./responses-bridge.mjs";
|
|
44
|
+
export {
|
|
45
|
+
openCopilotResponse,
|
|
46
|
+
} from "./copilot-responses-compat.mjs";
|
|
44
47
|
export {
|
|
45
48
|
isEncryptedContentVerificationError,
|
|
46
49
|
isImageNamespaceCollisionError,
|
|
47
|
-
|
|
50
|
+
sanitizeImageNamespaceCollisionRequest,
|
|
51
|
+
} from "./copilot-responses-policy.mjs";
|
|
52
|
+
export {
|
|
48
53
|
prepareResponsesRequest,
|
|
49
54
|
rememberResponseHistory,
|
|
50
55
|
sanitizeEncryptedReasoningRequest,
|
|
51
|
-
sanitizeImageNamespaceCollisionRequest,
|
|
52
56
|
stripInternalResponsesInputFields,
|
|
53
57
|
} from "./responses-request.mjs";
|
|
54
58
|
|
|
@@ -20,6 +20,42 @@ import { requireUpstreamEventStream, withChatStreamUsage } from "./stream-contra
|
|
|
20
20
|
import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
|
|
21
21
|
import { recordAnthropicUsage } from "./usage.mjs";
|
|
22
22
|
|
|
23
|
+
const PROMPT_TOO_LONG_MESSAGE = "prompt is too long: your prompt is too long. Please reduce the number of messages or use a model with a larger context window.";
|
|
24
|
+
|
|
25
|
+
function contextWindowErrorBody(text) {
|
|
26
|
+
let error;
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(text);
|
|
29
|
+
error = parsed && typeof parsed === "object" ? parsed.error : null;
|
|
30
|
+
} catch {
|
|
31
|
+
error = { message: text };
|
|
32
|
+
}
|
|
33
|
+
if (!error || typeof error !== "object") return null;
|
|
34
|
+
const code = error.code;
|
|
35
|
+
const message = typeof error.message === "string" ? error.message.toLowerCase() : "";
|
|
36
|
+
const matches = code === "context_length_exceeded"
|
|
37
|
+
|| code === "model_max_prompt_tokens_exceeded"
|
|
38
|
+
|| message.includes("exceeds the context window of this model")
|
|
39
|
+
|| message.includes("maximum context length is")
|
|
40
|
+
|| message.includes("request body is too large for model context window");
|
|
41
|
+
if (!matches) return null;
|
|
42
|
+
return JSON.stringify({
|
|
43
|
+
type: "error",
|
|
44
|
+
error: { type: "invalid_request_error", message: PROMPT_TOO_LONG_MESSAGE },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sendAnthropicUpstreamError(res, response, text) {
|
|
49
|
+
const rewritten = contextWindowErrorBody(text);
|
|
50
|
+
if (!rewritten) {
|
|
51
|
+
sendUpstreamError(res, response, text);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const headers = new Headers(response.headers);
|
|
55
|
+
headers.set("content-type", "application/json");
|
|
56
|
+
sendUpstreamError(res, { status: response.status, headers }, rewritten);
|
|
57
|
+
}
|
|
58
|
+
|
|
23
59
|
export function createAnthropicCountTokensHandler(options) {
|
|
24
60
|
const { acquireRequest, requestBodyTimeoutMs } = options;
|
|
25
61
|
|
|
@@ -76,7 +112,7 @@ export function createAnthropicMessagesHandler(options) {
|
|
|
76
112
|
const upstream = await chatCompletionsFn(withChatStreamUsage(chatReq), { signal: abort.signal });
|
|
77
113
|
releaseRequest();
|
|
78
114
|
if (!upstream.ok) {
|
|
79
|
-
|
|
115
|
+
sendAnthropicUpstreamError(res, upstream, await upstream.text());
|
|
80
116
|
return;
|
|
81
117
|
}
|
|
82
118
|
await requireUpstreamEventStream(upstream);
|
|
@@ -113,7 +149,7 @@ export function createAnthropicMessagesHandler(options) {
|
|
|
113
149
|
releaseRequest();
|
|
114
150
|
const data = await upstream.text();
|
|
115
151
|
if (!upstream.ok) {
|
|
116
|
-
|
|
152
|
+
sendAnthropicUpstreamError(res, upstream, data);
|
|
117
153
|
return;
|
|
118
154
|
}
|
|
119
155
|
const anthropicMessage = chatToAnthropic(JSON.parse(data), requestedModel, { forceModel: forceRequestedModel });
|
package/src/anthropic.mjs
CHANGED
|
@@ -44,26 +44,10 @@ function toolResultContent(content) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
// Convert one Anthropic message into zero or more OpenAI messages.
|
|
47
|
-
function
|
|
48
|
-
const out = [];
|
|
49
|
-
const role = msg.role; // "user" | "assistant"
|
|
50
|
-
|
|
51
|
-
if (typeof msg.content === "string") {
|
|
52
|
-
out.push({ role, content: msg.content });
|
|
53
|
-
return out;
|
|
54
|
-
}
|
|
55
|
-
if (!Array.isArray(msg.content)) return out;
|
|
56
|
-
|
|
57
|
-
// tool_result blocks must become independent role:"tool" messages.
|
|
58
|
-
const toolResults = msg.content.filter((b) => b.type === "tool_result");
|
|
59
|
-
for (const tr of toolResults) {
|
|
60
|
-
out.push({ role: "tool", tool_call_id: tr.tool_use_id, content: toolResultContent(tr.content) });
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Convert remaining text, image, and tool_use blocks.
|
|
47
|
+
function convertContentBlocks(role, blocks) {
|
|
64
48
|
const textImageParts = [];
|
|
65
49
|
const toolCalls = [];
|
|
66
|
-
for (const b of
|
|
50
|
+
for (const b of blocks) {
|
|
67
51
|
if (b.type === "text") {
|
|
68
52
|
textImageParts.push({ type: "text", text: b.text });
|
|
69
53
|
} else if (b.type === "image") {
|
|
@@ -80,17 +64,66 @@ function convertMessage(msg) {
|
|
|
80
64
|
const m = { role, content: null, tool_calls: toolCalls };
|
|
81
65
|
if (textImageParts.length === 1 && textImageParts[0].type === "text") m.content = textImageParts[0].text;
|
|
82
66
|
else if (textImageParts.length > 0) m.content = textImageParts;
|
|
83
|
-
|
|
67
|
+
return m;
|
|
84
68
|
} else if (textImageParts.length > 0) {
|
|
85
69
|
if (textImageParts.length === 1 && textImageParts[0].type === "text") {
|
|
86
|
-
|
|
70
|
+
return { role, content: textImageParts[0].text };
|
|
87
71
|
} else {
|
|
88
|
-
|
|
72
|
+
return { role, content: textImageParts };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function convertMessage(msg) {
|
|
79
|
+
const out = [];
|
|
80
|
+
const role = msg.role; // "user" | "assistant"
|
|
81
|
+
|
|
82
|
+
if (typeof msg.content === "string") {
|
|
83
|
+
out.push({ role, content: msg.content });
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
if (!Array.isArray(msg.content)) return out;
|
|
87
|
+
|
|
88
|
+
if (!msg.content.some((block) => block.type === "tool_result")) {
|
|
89
|
+
const converted = convertContentBlocks(role, msg.content);
|
|
90
|
+
if (converted) out.push(converted);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let contentBlocks = [];
|
|
95
|
+
const flushContent = () => {
|
|
96
|
+
const converted = convertContentBlocks(role, contentBlocks);
|
|
97
|
+
if (converted) out.push(converted);
|
|
98
|
+
contentBlocks = [];
|
|
99
|
+
};
|
|
100
|
+
for (const block of msg.content) {
|
|
101
|
+
if (block.type !== "tool_result") {
|
|
102
|
+
contentBlocks.push(block);
|
|
103
|
+
continue;
|
|
89
104
|
}
|
|
105
|
+
flushContent();
|
|
106
|
+
out.push({
|
|
107
|
+
role: "tool",
|
|
108
|
+
tool_call_id: block.tool_use_id,
|
|
109
|
+
content: toolResultContent(block.content),
|
|
110
|
+
});
|
|
90
111
|
}
|
|
112
|
+
flushContent();
|
|
91
113
|
return out;
|
|
92
114
|
}
|
|
93
115
|
|
|
116
|
+
function toolInputSchema(inputSchema) {
|
|
117
|
+
if (inputSchema
|
|
118
|
+
&& typeof inputSchema === "object"
|
|
119
|
+
&& !Array.isArray(inputSchema)
|
|
120
|
+
&& inputSchema.type === "object"
|
|
121
|
+
&& Object.keys(inputSchema).length === 1) {
|
|
122
|
+
return { type: "object", properties: {} };
|
|
123
|
+
}
|
|
124
|
+
return inputSchema;
|
|
125
|
+
}
|
|
126
|
+
|
|
94
127
|
function mapToolChoice(tc) {
|
|
95
128
|
if (!tc) return undefined;
|
|
96
129
|
switch (tc.type) {
|
|
@@ -119,7 +152,7 @@ export function anthropicToChat(body, options = {}) {
|
|
|
119
152
|
if (Array.isArray(body.tools) && body.tools.length) {
|
|
120
153
|
chatReq.tools = body.tools.map((t) => ({
|
|
121
154
|
type: "function",
|
|
122
|
-
function: { name: t.name, description: t.description, parameters: t.input_schema },
|
|
155
|
+
function: { name: t.name, description: t.description, parameters: toolInputSchema(t.input_schema) },
|
|
123
156
|
}));
|
|
124
157
|
}
|
|
125
158
|
const tc = mapToolChoice(body.tool_choice);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { responses as copilotResponses } from "./copilot.mjs";
|
|
2
|
+
import {
|
|
3
|
+
isEncryptedContentVerificationError,
|
|
4
|
+
isImageNamespaceCollisionError,
|
|
5
|
+
sanitizeImageNamespaceCollisionRequest,
|
|
6
|
+
} from "./copilot-responses-policy.mjs";
|
|
7
|
+
import { httpError } from "./http-transport.mjs";
|
|
8
|
+
import {
|
|
9
|
+
finalizeEncryptedHistoryRebase,
|
|
10
|
+
sanitizeEncryptedReasoningRequest,
|
|
11
|
+
} from "./responses-request.mjs";
|
|
12
|
+
import { status } from "./status.mjs";
|
|
13
|
+
|
|
14
|
+
// Ordered Copilot-only fallbacks. Each policy is single-use and must be gated
|
|
15
|
+
// by an exact upstream failure plus a request transformation that changes the
|
|
16
|
+
// payload. Generic Responses preparation does not decide whether to retry.
|
|
17
|
+
const RETRY_POLICIES = Object.freeze([
|
|
18
|
+
Object.freeze({
|
|
19
|
+
id: "image-namespace-collision",
|
|
20
|
+
matches: isImageNamespaceCollisionError,
|
|
21
|
+
apply: sanitizeImageNamespaceCollisionRequest,
|
|
22
|
+
warning: "image_gen namespace rejected by upstream; retrying without the conflicting image tool",
|
|
23
|
+
}),
|
|
24
|
+
Object.freeze({
|
|
25
|
+
id: "encrypted-replay-rejected",
|
|
26
|
+
matches: isEncryptedContentVerificationError,
|
|
27
|
+
apply: sanitizeEncryptedReasoningRequest,
|
|
28
|
+
warning: "encrypted replay content rejected by upstream; retrying without unavailable encrypted content",
|
|
29
|
+
}),
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
|
|
33
|
+
const usedPolicies = new Set();
|
|
34
|
+
let payloadPrepared = false;
|
|
35
|
+
for (let attempt = 0; attempt <= RETRY_POLICIES.length; attempt += 1) {
|
|
36
|
+
options.assertPrepareActive?.();
|
|
37
|
+
const resp = await upstream(reqContext.body, {
|
|
38
|
+
assertActive: options.assertPrepareActive,
|
|
39
|
+
signal: options.signal,
|
|
40
|
+
currentInputStart: reqContext.currentInputStart,
|
|
41
|
+
onUpstreamStart: options.onUpstreamStart,
|
|
42
|
+
payloadPrepared,
|
|
43
|
+
});
|
|
44
|
+
options.assertPrepareActive?.();
|
|
45
|
+
payloadPrepared = true;
|
|
46
|
+
if (resp.ok) {
|
|
47
|
+
reqContext = finalizeEncryptedHistoryRebase(reqContext);
|
|
48
|
+
return { resp, reqContext };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const errorText = await resp.text();
|
|
52
|
+
options.assertPrepareActive?.();
|
|
53
|
+
let retryPolicy = null;
|
|
54
|
+
let retryContext = null;
|
|
55
|
+
for (const policy of RETRY_POLICIES) {
|
|
56
|
+
if (usedPolicies.has(policy.id) || !policy.matches(resp.status, errorText)) continue;
|
|
57
|
+
const candidate = policy.apply(reqContext);
|
|
58
|
+
if (candidate) {
|
|
59
|
+
retryPolicy = policy;
|
|
60
|
+
retryContext = candidate;
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (retryPolicy && retryContext) {
|
|
65
|
+
usedPolicies.add(retryPolicy.id);
|
|
66
|
+
reqContext = retryContext;
|
|
67
|
+
console.warn(status("warn", retryPolicy.warning));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
return { resp, reqContext, errorText };
|
|
71
|
+
}
|
|
72
|
+
throw httpError("Responses compatibility retry limit exceeded", 502);
|
|
73
|
+
}
|
|
@@ -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/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;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getCachedModelEndpoints } from "./copilot.mjs";
|
|
2
|
+
import { applyCopilotResponsesRequestPolicies } from "./copilot-responses-policy.mjs";
|
|
2
3
|
import {
|
|
3
4
|
createRequestAbort,
|
|
4
5
|
logRequestFailure,
|
|
@@ -20,10 +21,13 @@ import { clearResponsesToolOutputPartsCache } from "./responses-content.mjs";
|
|
|
20
21
|
import { proxyCopilotResponses } from "./responses-proxy.mjs";
|
|
21
22
|
import { responseHistoryMaterializedBytes } from "./response-history.mjs";
|
|
22
23
|
import {
|
|
24
|
+
applyResponseHistoryRoutePlan,
|
|
23
25
|
dropMaterializedResponseHistory,
|
|
24
26
|
prepareResponsesRequest,
|
|
25
27
|
rememberResponseHistory,
|
|
28
|
+
responseHistoryPressureRootId,
|
|
26
29
|
} from "./responses-request.mjs";
|
|
30
|
+
import { createRoutePlan } from "./route-plan.mjs";
|
|
27
31
|
import { status } from "./status.mjs";
|
|
28
32
|
import { endStreamWithError } from "./stream-errors.mjs";
|
|
29
33
|
import { safeUpstreamResponseHeaders } from "./upstream-headers.mjs";
|
|
@@ -141,7 +145,7 @@ export function createResponsesHandler(options) {
|
|
|
141
145
|
};
|
|
142
146
|
const markAdaptiveHttpTimeout = (statusCode) => {
|
|
143
147
|
if (statusCode !== 408) return false;
|
|
144
|
-
return imagePressure?.markTimeout?.(prepared
|
|
148
|
+
return imagePressure?.markTimeout?.(responseHistoryPressureRootId(prepared), {
|
|
145
149
|
eligible: imagePressureResult?.pressureEligible === true,
|
|
146
150
|
}) || false;
|
|
147
151
|
};
|
|
@@ -172,7 +176,11 @@ export function createResponsesHandler(options) {
|
|
|
172
176
|
}
|
|
173
177
|
assertPrepareActive();
|
|
174
178
|
}
|
|
175
|
-
prepared = prepareResponsesRequest(parsed, {
|
|
179
|
+
prepared = prepareResponsesRequest(parsed, {
|
|
180
|
+
assertActive: assertPrepareActive,
|
|
181
|
+
copilotBoundary: false,
|
|
182
|
+
mutate: true,
|
|
183
|
+
});
|
|
176
184
|
parsed = null;
|
|
177
185
|
assertPrepareActive();
|
|
178
186
|
imagePressureResult = imagePressure?.apply?.(prepared, { assertActive: assertPrepareActive }) || null;
|
|
@@ -192,7 +200,17 @@ export function createResponsesHandler(options) {
|
|
|
192
200
|
|| isResponsesOnlyModel(upstreamModel, getCachedModelEndpointsFn)
|
|
193
201
|
|| (usesCustomTools && cachedModelSupportsResponses(upstreamModel, getCachedModelEndpointsFn));
|
|
194
202
|
if (usesCustomTools && !useNativeResponses) throw unsupportedCustomToolsError(upstreamModel);
|
|
195
|
-
|
|
203
|
+
const routePlan = createRoutePlan({
|
|
204
|
+
disposition: "relay",
|
|
205
|
+
origin: "ccdx",
|
|
206
|
+
profile: "codex",
|
|
207
|
+
protocol: useNativeResponses ? "openai-responses" : "openai-chat-completions",
|
|
208
|
+
model: upstreamModel,
|
|
209
|
+
surface: "responses",
|
|
210
|
+
});
|
|
211
|
+
prepared = applyResponseHistoryRoutePlan(prepared, routePlan);
|
|
212
|
+
applyCopilotResponsesRequestPolicies(prepared.body);
|
|
213
|
+
if (routePlan.protocol === "openai-responses") {
|
|
196
214
|
const result = await proxyCopilotResponses(prepared, req, res, responsesFn, {
|
|
197
215
|
assertPrepareActive,
|
|
198
216
|
signal: abort.signal,
|
|
@@ -201,9 +219,9 @@ export function createResponsesHandler(options) {
|
|
|
201
219
|
releaseRequest: releaseUpstreamPayload,
|
|
202
220
|
streamIdleTimeoutMs,
|
|
203
221
|
});
|
|
204
|
-
if (result?.successful) imagePressure?.markSuccess?.(prepared
|
|
222
|
+
if (result?.successful) imagePressure?.markSuccess?.(responseHistoryPressureRootId(prepared));
|
|
205
223
|
else markAdaptiveHttpTimeout(result?.upstreamStatus);
|
|
206
|
-
} else {
|
|
224
|
+
} else if (routePlan.protocol === "openai-chat-completions") {
|
|
207
225
|
let { chatReq, bodyText } = await prepareResponsesChatPayload(prepared, {
|
|
208
226
|
assertActive: assertPrepareActive,
|
|
209
227
|
payloadOptions: responsesPayloadOptions,
|
|
@@ -254,7 +272,7 @@ export function createResponsesHandler(options) {
|
|
|
254
272
|
});
|
|
255
273
|
},
|
|
256
274
|
});
|
|
257
|
-
if (successful) imagePressure?.markSuccess?.(prepared
|
|
275
|
+
if (successful) imagePressure?.markSuccess?.(responseHistoryPressureRootId(prepared));
|
|
258
276
|
} else {
|
|
259
277
|
try {
|
|
260
278
|
startUpstreamTimeout();
|
|
@@ -272,7 +290,7 @@ export function createResponsesHandler(options) {
|
|
|
272
290
|
}
|
|
273
291
|
const response = chatToResponses(JSON.parse(data), model);
|
|
274
292
|
rememberResponseHistory(prepared, response);
|
|
275
|
-
imagePressure?.markSuccess?.(prepared
|
|
293
|
+
imagePressure?.markSuccess?.(responseHistoryPressureRootId(prepared));
|
|
276
294
|
recordResponsesUsage({ surface: prepared.surface, mode: "json", model, response, event: response });
|
|
277
295
|
res.writeHead(200, safeUpstreamResponseHeaders(upstream.headers, {
|
|
278
296
|
contentType: "application/json",
|
|
@@ -284,6 +302,8 @@ export function createResponsesHandler(options) {
|
|
|
284
302
|
sendJsonError(res, responseError, 502);
|
|
285
303
|
}
|
|
286
304
|
}
|
|
305
|
+
} else {
|
|
306
|
+
throw new Error(`Unsupported Responses target protocol: ${routePlan.protocol}`);
|
|
287
307
|
}
|
|
288
308
|
} catch (error) {
|
|
289
309
|
const responseError = applyAdaptiveTimeout(error);
|
|
@@ -351,7 +371,11 @@ export function createResponsesCompactHandler(options) {
|
|
|
351
371
|
assertPrepareActive();
|
|
352
372
|
}
|
|
353
373
|
prepared = prepareResponsesCompactionRequest(
|
|
354
|
-
prepareResponsesRequest(parsed, {
|
|
374
|
+
prepareResponsesRequest(parsed, {
|
|
375
|
+
assertActive: assertPrepareActive,
|
|
376
|
+
copilotBoundary: false,
|
|
377
|
+
mutate: true,
|
|
378
|
+
}),
|
|
355
379
|
);
|
|
356
380
|
assertPrepareActive();
|
|
357
381
|
prepared.surface = "responses_compact";
|
|
@@ -360,6 +384,15 @@ export function createResponsesCompactHandler(options) {
|
|
|
360
384
|
if (upstreamModel !== requestedModel) prepared.body.model = upstreamModel;
|
|
361
385
|
const upstreamLog = upstreamModel === requestedModel ? "" : ` upstream_model=${upstreamModel}`;
|
|
362
386
|
console.log(status("info", `responses compact model=${requestedModel}${upstreamLog} stream=false`));
|
|
387
|
+
prepared = applyResponseHistoryRoutePlan(prepared, createRoutePlan({
|
|
388
|
+
disposition: "relay",
|
|
389
|
+
origin: "ccdx",
|
|
390
|
+
profile: "codex",
|
|
391
|
+
protocol: "openai-responses",
|
|
392
|
+
model: upstreamModel,
|
|
393
|
+
surface: "responses-compact",
|
|
394
|
+
}));
|
|
395
|
+
applyCopilotResponsesRequestPolicies(prepared.body);
|
|
363
396
|
const result = await proxyCopilotResponses(prepared, req, res, responsesCompactFn, {
|
|
364
397
|
assertPrepareActive,
|
|
365
398
|
signal: abort.signal,
|
|
@@ -367,7 +400,7 @@ export function createResponsesCompactHandler(options) {
|
|
|
367
400
|
onUpstreamStart: startUpstreamTimeout,
|
|
368
401
|
releaseRequest: releaseUpstreamPayload,
|
|
369
402
|
});
|
|
370
|
-
if (result?.compacted) imagePressure?.clear?.(prepared
|
|
403
|
+
if (result?.compacted) imagePressure?.clear?.(responseHistoryPressureRootId(prepared));
|
|
371
404
|
} catch (error) {
|
|
372
405
|
logRequestFailure("Responses compact", error, abort);
|
|
373
406
|
sendJsonError(res, error, 502);
|
package/src/responses-proxy.mjs
CHANGED
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
sendUpstreamError,
|
|
6
6
|
writeOrDrain,
|
|
7
7
|
} from "./http-transport.mjs";
|
|
8
|
-
import { openCopilotResponse
|
|
8
|
+
import { openCopilotResponse } from "./copilot-responses-compat.mjs";
|
|
9
|
+
import { rememberResponseHistory } from "./responses-request.mjs";
|
|
9
10
|
import {
|
|
10
11
|
compactionInputWithoutTrigger,
|
|
11
12
|
parseResponsesCompactionResult,
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { httpError } from "./http-transport.mjs";
|
|
1
|
+
import { applyCopilotResponsesRequestPolicies } from "./copilot-responses-policy.mjs";
|
|
3
2
|
import { debugLog } from "./log.mjs";
|
|
4
3
|
import {
|
|
5
4
|
materializeResponseHistory,
|
|
@@ -8,7 +7,20 @@ import {
|
|
|
8
7
|
} from "./response-history.mjs";
|
|
9
8
|
import { isResponsesToolOutputItem, readResponsesToolOutputParts } from "./responses-content.mjs";
|
|
10
9
|
import { enforceResponsesImageLimit } from "./responses-image-limit.mjs";
|
|
11
|
-
import {
|
|
10
|
+
import { routePlanAffinity, sameRoutePlanAffinity } from "./route-plan.mjs";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
isEncryptedContentVerificationError,
|
|
14
|
+
isImageNamespaceCollisionError,
|
|
15
|
+
sanitizeImageNamespaceCollisionRequest,
|
|
16
|
+
} from "./copilot-responses-policy.mjs";
|
|
17
|
+
|
|
18
|
+
// Compatibility export for consumers that imported this helper before the
|
|
19
|
+
// Copilot retry boundary moved into its provider-owned module.
|
|
20
|
+
export async function openCopilotResponse(...args) {
|
|
21
|
+
const compatibility = await import("./copilot-responses-compat.mjs");
|
|
22
|
+
return compatibility.openCopilotResponse(...args);
|
|
23
|
+
}
|
|
12
24
|
|
|
13
25
|
function cloneJson(value) {
|
|
14
26
|
return value === undefined ? undefined : structuredClone(value);
|
|
@@ -71,6 +83,8 @@ function isEncryptedContentPart(value) {
|
|
|
71
83
|
|
|
72
84
|
const OMIT_ENCRYPTED_CONTENT = Symbol("omit-encrypted-content");
|
|
73
85
|
const ENCRYPTED_HISTORY_REBASE = Symbol("encrypted-history-rebase");
|
|
86
|
+
const RELEASE_HISTORY_INPUT = Symbol("release-history-input");
|
|
87
|
+
const HISTORY_PRESSURE_ROOT = Symbol("history-pressure-root");
|
|
74
88
|
const ENCRYPTED_TOOL_OUTPUT_MARKER = "[CCDX: encrypted tool output omitted because upstream could not decrypt it.]";
|
|
75
89
|
|
|
76
90
|
function stripEncryptedReasoningValue(value, state) {
|
|
@@ -159,7 +173,21 @@ function isEncryptedReasoningInputItem(item) {
|
|
|
159
173
|
return keys.length === 1 && keys[0] === "encrypted_content";
|
|
160
174
|
}
|
|
161
175
|
|
|
162
|
-
|
|
176
|
+
function responseValueHasOpaqueState(value) {
|
|
177
|
+
if (Array.isArray(value)) return value.some(responseValueHasOpaqueState);
|
|
178
|
+
if (!value || typeof value !== "object") return false;
|
|
179
|
+
if (isEncryptedReasoningInputItem(value)
|
|
180
|
+
|| Object.prototype.hasOwnProperty.call(value, "encrypted_content")) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
if (isResponsesToolOutputItem(value) && typeof value.output === "string") {
|
|
184
|
+
const parsed = readResponsesToolOutputParts(value);
|
|
185
|
+
if (parsed?.parts.some(responseValueHasOpaqueState)) return true;
|
|
186
|
+
}
|
|
187
|
+
return Object.values(value).some(responseValueHasOpaqueState);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function sanitizeEncryptedReasoningRequest(reqContext, { historicalOnly = false } = {}) {
|
|
163
191
|
const state = { changed: false };
|
|
164
192
|
let body = cloneJson(reqContext.body);
|
|
165
193
|
let currentInputStart = reqContext.currentInputStart;
|
|
@@ -170,6 +198,10 @@ export function sanitizeEncryptedReasoningRequest(reqContext) {
|
|
|
170
198
|
for (let index = 0; index < body.input.length; index += 1) {
|
|
171
199
|
const item = body.input[index];
|
|
172
200
|
const historical = Number.isFinite(currentInputStart) && index < currentInputStart;
|
|
201
|
+
if (historicalOnly && !historical) {
|
|
202
|
+
input.push(item);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
173
205
|
if (isEncryptedReasoningInputItem(item)) {
|
|
174
206
|
state.changed = true;
|
|
175
207
|
if (historical) historicalInputChanged = true;
|
|
@@ -188,11 +220,13 @@ export function sanitizeEncryptedReasoningRequest(reqContext) {
|
|
|
188
220
|
}
|
|
189
221
|
body.input = input;
|
|
190
222
|
if (Number.isFinite(currentInputStart)) currentInputStart = retainedBeforeCurrent;
|
|
191
|
-
} else {
|
|
223
|
+
} else if (!historicalOnly) {
|
|
192
224
|
body = stripEncryptedReasoningValue(body, state);
|
|
193
225
|
}
|
|
194
226
|
if (!state.changed) return null;
|
|
195
|
-
const historyInputItems =
|
|
227
|
+
const historyInputItems = historicalOnly
|
|
228
|
+
? reqContext.historyInputItems
|
|
229
|
+
: Array.isArray(reqContext.historyInputItems)
|
|
196
230
|
? reqContext.historyInputItems.flatMap((item) => {
|
|
197
231
|
if (isEncryptedReasoningInputItem(item)) return [];
|
|
198
232
|
const stripped = stripEncryptedReasoningInputValue(item, { changed: false });
|
|
@@ -210,10 +244,13 @@ export function sanitizeEncryptedReasoningRequest(reqContext) {
|
|
|
210
244
|
currentInputStart,
|
|
211
245
|
inputItems: Array.isArray(body.input) ? body.input : reqContext.inputItems,
|
|
212
246
|
historyInputItems,
|
|
247
|
+
[RELEASE_HISTORY_INPUT]: historicalOnly
|
|
248
|
+
? reqContext[RELEASE_HISTORY_INPUT]
|
|
249
|
+
: historyInputItems,
|
|
213
250
|
};
|
|
214
251
|
}
|
|
215
252
|
|
|
216
|
-
function finalizeEncryptedHistoryRebase(reqContext) {
|
|
253
|
+
export function finalizeEncryptedHistoryRebase(reqContext) {
|
|
217
254
|
if (!reqContext[ENCRYPTED_HISTORY_REBASE]) return reqContext;
|
|
218
255
|
const finalized = {
|
|
219
256
|
...reqContext,
|
|
@@ -226,98 +263,43 @@ function finalizeEncryptedHistoryRebase(reqContext) {
|
|
|
226
263
|
return finalized;
|
|
227
264
|
}
|
|
228
265
|
|
|
229
|
-
export function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
&& lower.includes("image_gen")
|
|
247
|
-
&& lower.includes("collid");
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function isImageNamespaceTool(tool, { collisionFallback = false } = {}) {
|
|
251
|
-
if (!tool || typeof tool !== "object") return false;
|
|
252
|
-
const type = String(tool.type || "").toLowerCase();
|
|
253
|
-
const name = String(tool.name || tool.function?.name || "").toLowerCase();
|
|
254
|
-
const namespace = String(tool.namespace || "").toLowerCase();
|
|
255
|
-
if (["image_gen", "image_generation"].includes(type)) return true;
|
|
256
|
-
if (["image_gen", "image_generation"].includes(name)) return true;
|
|
257
|
-
if (namespace === "image_gen" || namespace === "image_generation") return true;
|
|
258
|
-
return collisionFallback && [type, name, namespace].some((value) => value.startsWith("image_gen"));
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
export function sanitizeImageNamespaceCollisionRequest(reqContext) {
|
|
262
|
-
if (!Array.isArray(reqContext?.body?.tools)) return null;
|
|
263
|
-
const body = cloneJson(reqContext.body);
|
|
264
|
-
const filtered = body.tools.filter((tool) => !isImageNamespaceTool(tool, { collisionFallback: true }));
|
|
265
|
-
if (filtered.length === body.tools.length) return null;
|
|
266
|
-
if (filtered.length) body.tools = filtered;
|
|
267
|
-
else delete body.tools;
|
|
268
|
-
return { ...reqContext, body };
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
export async function openCopilotResponse(reqContext, upstream = copilotResponses, options = {}) {
|
|
272
|
-
let encryptedRetried = false;
|
|
273
|
-
let imageNamespaceRetried = false;
|
|
274
|
-
let payloadPrepared = false;
|
|
275
|
-
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
276
|
-
options.assertPrepareActive?.();
|
|
277
|
-
const resp = await upstream(reqContext.body, {
|
|
278
|
-
assertActive: options.assertPrepareActive,
|
|
279
|
-
signal: options.signal,
|
|
280
|
-
currentInputStart: reqContext.currentInputStart,
|
|
281
|
-
onUpstreamStart: options.onUpstreamStart,
|
|
282
|
-
payloadPrepared,
|
|
283
|
-
});
|
|
284
|
-
options.assertPrepareActive?.();
|
|
285
|
-
payloadPrepared = true;
|
|
286
|
-
if (resp.ok) {
|
|
287
|
-
reqContext = finalizeEncryptedHistoryRebase(reqContext);
|
|
288
|
-
return { resp, reqContext };
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const errorText = await resp.text();
|
|
292
|
-
options.assertPrepareActive?.();
|
|
293
|
-
if (!imageNamespaceRetried && isImageNamespaceCollisionError(resp.status, errorText)) {
|
|
294
|
-
const retryContext = sanitizeImageNamespaceCollisionRequest(reqContext);
|
|
295
|
-
if (retryContext) {
|
|
296
|
-
imageNamespaceRetried = true;
|
|
297
|
-
reqContext = retryContext;
|
|
298
|
-
console.warn(status("warn", "image_gen namespace rejected by upstream; retrying without the conflicting image tool"));
|
|
299
|
-
continue;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
if (!encryptedRetried && isEncryptedContentVerificationError(resp.status, errorText)) {
|
|
303
|
-
const retryContext = sanitizeEncryptedReasoningRequest(reqContext);
|
|
304
|
-
if (retryContext) {
|
|
305
|
-
encryptedRetried = true;
|
|
306
|
-
reqContext = retryContext;
|
|
307
|
-
console.warn(status("warn", "encrypted replay content rejected by upstream; retrying without unavailable encrypted content"));
|
|
308
|
-
continue;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
return { resp, reqContext, errorText };
|
|
266
|
+
export function applyResponseHistoryRoutePlan(reqContext, routePlan) {
|
|
267
|
+
const next = reqContext;
|
|
268
|
+
next.routePlan = routePlan;
|
|
269
|
+
if (!next.historyParentId) return next;
|
|
270
|
+
const currentAffinity = routePlanAffinity(routePlan);
|
|
271
|
+
const historyMetadata = next.historyRouteMetadata;
|
|
272
|
+
if (!currentAffinity
|
|
273
|
+
|| !Array.isArray(historyMetadata)
|
|
274
|
+
|| historyMetadata.length === 0
|
|
275
|
+
|| historyMetadata.some((metadata) => metadata.affinity === null)) {
|
|
276
|
+
return next;
|
|
277
|
+
}
|
|
278
|
+
const hasMismatchedOpaqueState = historyMetadata.some((metadata) => (
|
|
279
|
+
metadata.hasOpaque && !sameRoutePlanAffinity(metadata.affinity, currentAffinity)
|
|
280
|
+
));
|
|
281
|
+
if (!hasMismatchedOpaqueState) {
|
|
282
|
+
return next;
|
|
312
283
|
}
|
|
313
|
-
|
|
284
|
+
|
|
285
|
+
const sanitized = sanitizeEncryptedReasoningRequest(next, { historicalOnly: true });
|
|
286
|
+
if (!sanitized) return next;
|
|
287
|
+
sanitized.routePlan = routePlan;
|
|
288
|
+
const historyRootId = next.historyRootId;
|
|
289
|
+
const rebased = finalizeEncryptedHistoryRebase(sanitized);
|
|
290
|
+
if (historyRootId) rebased[HISTORY_PRESSURE_ROOT] = historyRootId;
|
|
291
|
+
return rebased;
|
|
314
292
|
}
|
|
315
293
|
|
|
316
|
-
function
|
|
317
|
-
return
|
|
294
|
+
export function responseHistoryPressureRootId(reqContext) {
|
|
295
|
+
return reqContext?.[HISTORY_PRESSURE_ROOT] || reqContext?.historyRootId || null;
|
|
318
296
|
}
|
|
319
297
|
|
|
320
|
-
export function prepareResponsesRequest(reqBody, {
|
|
298
|
+
export function prepareResponsesRequest(reqBody, {
|
|
299
|
+
assertActive,
|
|
300
|
+
copilotBoundary = true,
|
|
301
|
+
mutate = false,
|
|
302
|
+
} = {}) {
|
|
321
303
|
assertActive?.();
|
|
322
304
|
const body = mutate ? reqBody : cloneJson(reqBody);
|
|
323
305
|
assertActive?.();
|
|
@@ -328,9 +310,13 @@ export function prepareResponsesRequest(reqBody, { assertActive, mutate = false
|
|
|
328
310
|
const previousId = body.previous_response_id;
|
|
329
311
|
let historyItems = [];
|
|
330
312
|
let historyRootId = null;
|
|
313
|
+
const historyRouteMetadata = [];
|
|
331
314
|
|
|
332
315
|
if (previousId !== undefined && previousId !== null) {
|
|
333
|
-
historyItems = materializeResponseHistory(previousId, {
|
|
316
|
+
historyItems = materializeResponseHistory(previousId, {
|
|
317
|
+
assertActive,
|
|
318
|
+
routeMetadata: historyRouteMetadata,
|
|
319
|
+
});
|
|
334
320
|
historyRootId = responseHistoryRootId(previousId);
|
|
335
321
|
body.input = [...historyItems, ...currentInputItems];
|
|
336
322
|
} else {
|
|
@@ -351,10 +337,10 @@ export function prepareResponsesRequest(reqBody, { assertActive, mutate = false
|
|
|
351
337
|
|
|
352
338
|
delete body.previous_response_id;
|
|
353
339
|
delete body.store;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
340
|
+
// Preserve the historical helper contract for embedders. Runtime handlers
|
|
341
|
+
// pass copilotBoundary:false and apply the policy only after selecting a
|
|
342
|
+
// concrete Copilot RoutePlan.
|
|
343
|
+
if (copilotBoundary) applyCopilotResponsesRequestPolicies(body);
|
|
358
344
|
stripInternalResponsesInputFields(body.input, { assertActive });
|
|
359
345
|
stripInternalResponsesInputFields(historyInputItems, { assertActive });
|
|
360
346
|
assertActive?.();
|
|
@@ -366,15 +352,21 @@ export function prepareResponsesRequest(reqBody, { assertActive, mutate = false
|
|
|
366
352
|
currentInputStart: retainedCurrentInputStart < 0 ? body.input.length : retainedCurrentInputStart,
|
|
367
353
|
historyParentId: previousId ?? null,
|
|
368
354
|
historyRootId,
|
|
355
|
+
historyRouteMetadata,
|
|
369
356
|
historyInputItems,
|
|
357
|
+
...(previousId !== undefined && previousId !== null
|
|
358
|
+
? { [RELEASE_HISTORY_INPUT]: historyInputItems }
|
|
359
|
+
: {}),
|
|
370
360
|
takeHistoryOwnership: mutate,
|
|
371
361
|
};
|
|
372
362
|
}
|
|
373
363
|
|
|
374
364
|
export function dropMaterializedResponseHistory(reqContext) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
reqContext.
|
|
365
|
+
const releaseInput = reqContext?.[RELEASE_HISTORY_INPUT];
|
|
366
|
+
if (!Array.isArray(releaseInput)) return false;
|
|
367
|
+
reqContext.body = { ...reqContext.body, input: releaseInput };
|
|
368
|
+
reqContext.inputItems = releaseInput;
|
|
369
|
+
delete reqContext[RELEASE_HISTORY_INPUT];
|
|
378
370
|
reqContext.currentInputStart = 0;
|
|
379
371
|
return true;
|
|
380
372
|
}
|
|
@@ -391,6 +383,8 @@ export function rememberResponseHistory(reqContext, responseJson) {
|
|
|
391
383
|
parentId: compacted ? null : reqContext.historyParentId,
|
|
392
384
|
inputItems: sourceInputItems,
|
|
393
385
|
outputItems: sourceOutputItems,
|
|
386
|
+
hasOpaque: responseValueHasOpaqueState([sourceInputItems, sourceOutputItems]),
|
|
387
|
+
routeAffinity: routePlanAffinity(reqContext.routePlan),
|
|
394
388
|
takeOwnership: reqContext.takeHistoryOwnership,
|
|
395
389
|
});
|
|
396
390
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const DISPOSITIONS = new Set(["native", "relay", "reject"]);
|
|
4
|
+
const MAX_AFFINITY_MODEL_BYTES = 512;
|
|
5
|
+
|
|
6
|
+
function requiredString(value, name) {
|
|
7
|
+
const normalized = String(value || "").trim();
|
|
8
|
+
if (!normalized) throw new Error(`Route plan ${name} is required`);
|
|
9
|
+
return normalized;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createRoutePlan({
|
|
13
|
+
disposition = "relay",
|
|
14
|
+
model,
|
|
15
|
+
origin,
|
|
16
|
+
profile,
|
|
17
|
+
protocol,
|
|
18
|
+
surface,
|
|
19
|
+
} = {}) {
|
|
20
|
+
if (!DISPOSITIONS.has(disposition)) {
|
|
21
|
+
throw new Error(`Unsupported route disposition: ${disposition}`);
|
|
22
|
+
}
|
|
23
|
+
return Object.freeze({
|
|
24
|
+
disposition,
|
|
25
|
+
origin: requiredString(origin, "origin"),
|
|
26
|
+
profile: requiredString(profile, "profile"),
|
|
27
|
+
protocol: requiredString(protocol, "protocol"),
|
|
28
|
+
model: requiredString(model, "model"),
|
|
29
|
+
surface: requiredString(surface, "surface"),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function routePlanAffinity(plan) {
|
|
34
|
+
if (!plan || typeof plan !== "object") return null;
|
|
35
|
+
const { origin, profile, protocol, model } = plan;
|
|
36
|
+
if (![origin, profile, protocol, model].every((value) => typeof value === "string" && value.length > 0)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const boundedModel = Buffer.byteLength(model) <= MAX_AFFINITY_MODEL_BYTES
|
|
40
|
+
? model
|
|
41
|
+
: `sha256:${createHash("sha256").update(model).digest("hex")}`;
|
|
42
|
+
return Object.freeze({ origin, profile, protocol, model: boundedModel });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function sameRoutePlanAffinity(left, right) {
|
|
46
|
+
return Boolean(left && right)
|
|
47
|
+
&& left.origin === right.origin
|
|
48
|
+
&& left.profile === right.profile
|
|
49
|
+
&& left.protocol === right.protocol
|
|
50
|
+
&& left.model === right.model;
|
|
51
|
+
}
|
package/src/startup.mjs
CHANGED
|
@@ -2,9 +2,15 @@ export async function initializeModelRegistry({
|
|
|
2
2
|
loadCached,
|
|
3
3
|
currentModelDefs,
|
|
4
4
|
refresh,
|
|
5
|
+
onBackgroundError = () => {},
|
|
5
6
|
}) {
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const cached = loadCached();
|
|
8
|
+
const loaded = cached === true || cached?.loaded === true;
|
|
9
|
+
if (loaded) {
|
|
10
|
+
const backgroundRefresh = cached?.stale === true
|
|
11
|
+
? runInBackground(refresh, onBackgroundError)
|
|
12
|
+
: null;
|
|
13
|
+
return { modelDefs: currentModelDefs(), source: "cache", backgroundRefresh };
|
|
8
14
|
}
|
|
9
15
|
|
|
10
16
|
return { modelDefs: await refresh(), source: "live", backgroundRefresh: null };
|