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 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. If the isolated Claude profile later
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-copilot-dx",
3
- "version": "0.6.14",
3
+ "version": "0.6.15",
4
4
  "description": "Use Codex Desktop, Claude Code, and Claude App with GitHub Copilot",
5
5
  "author": "Dale Xiao",
6
6
  "type": "module",
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
- openCopilotResponse,
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
- sendUpstreamError(res, upstream, await upstream.text());
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
- sendUpstreamError(res, upstream, data);
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 convertMessage(msg) {
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 msg.content) {
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
- out.push(m);
67
+ return m;
84
68
  } else if (textImageParts.length > 0) {
85
69
  if (textImageParts.length === 1 && textImageParts[0].type === "text") {
86
- out.push({ role, content: textImageParts[0].text });
70
+ return { role, content: textImageParts[0].text };
87
71
  } else {
88
- out.push({ role, content: textImageParts });
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
+ }
@@ -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 DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
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
- export function loadModelCache({
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
- maxAgeMs = DEFAULT_MAX_AGE_MS,
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) || now() - savedAt > maxAgeMs) return null;
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
- return parsed.models;
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;
@@ -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
- return {
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) {
@@ -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 modelType = modelRouter.classify(body?.model);
449
- if (modelType === "unsupported_claude") {
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 (modelType !== "claude") {
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
- loadModelCache,
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
- const models = JSON.parse(result.body);
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 = { modelDefs: undefined, models: undefined, source: "built-in" };
89
+ const codexRegistry = createModelRegistry();
55
90
  const claudeRegistry = claudeMode === "inherited"
56
91
  ? codexRegistry
57
- : { modelDefs: undefined, models: undefined, source: "built-in" };
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 = loadModelCache({ home, profile, credentialFingerprint });
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
- return true;
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, { home, profile, credentialFingerprint });
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 refreshProfile({ client, profile, registry, ownsClaudeModels, checksCodex }) {
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} (${error.message})`;
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,
@@ -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
- 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
- },
94
+ plan,
95
+ classify,
58
96
  allowedModels() {
59
97
  return [...availability().allowed.values()];
60
98
  },
@@ -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({ id, parentId, inputItems, outputItems, takeOwnership = false }) {
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 bytes = jsonByteLength([inputItems, outputItems]);
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?.historyRootId, {
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, { assertActive: assertPrepareActive, mutate: true });
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
- if (useNativeResponses) {
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.historyRootId);
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.historyRootId);
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.historyRootId);
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, { assertActive: assertPrepareActive, mutate: true }),
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.historyRootId);
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);
@@ -5,7 +5,8 @@ import {
5
5
  sendUpstreamError,
6
6
  writeOrDrain,
7
7
  } from "./http-transport.mjs";
8
- import { openCopilotResponse, rememberResponseHistory } from "./responses-request.mjs";
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 { responses as copilotResponses } from "./copilot.mjs";
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 { status } from "./status.mjs";
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
- export function sanitizeEncryptedReasoningRequest(reqContext) {
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 = Array.isArray(reqContext.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 isEncryptedContentVerificationError(statusCode, text) {
230
- if (statusCode < 400 || !text) return false;
231
- const lower = String(text).toLowerCase();
232
- const reasoningFailure = lower.includes("encrypted content")
233
- && lower.includes("could not be verified")
234
- && (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
235
- const functionOutputFailure = lower.includes("encrypted function output content")
236
- && lower.includes("could not be decrypted or decoded");
237
- const missingEncryptedContent = statusCode < 500
238
- && /missing required parameter:\s*(['"]?)input\[\d+\](?:\.[a-z0-9_]+|\[\d+\])*\.encrypted_content\1(?=\.?(?:\s|$|["},\]]))/.test(lower);
239
- return reasoningFailure || functionOutputFailure || missingEncryptedContent;
240
- }
241
-
242
- export function isImageNamespaceCollisionError(statusCode, text) {
243
- if (statusCode < 400 || !text) return false;
244
- const lower = String(text).toLowerCase();
245
- return lower.includes("namespace")
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
- throw httpError("Responses compatibility retry limit exceeded", 502);
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 isBuiltinImageTool(tool) {
317
- return isImageNamespaceTool(tool);
294
+ export function responseHistoryPressureRootId(reqContext) {
295
+ return reqContext?.[HISTORY_PRESSURE_ROOT] || reqContext?.historyRootId || null;
318
296
  }
319
297
 
320
- export function prepareResponsesRequest(reqBody, { assertActive, mutate = false } = {}) {
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, { assertActive });
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
- if (Array.isArray(body.tools)) {
355
- body.tools = body.tools.filter((tool) => !isBuiltinImageTool(tool));
356
- if (!body.tools.length) delete body.tools;
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
- if (!reqContext?.historyParentId || !Array.isArray(reqContext.historyInputItems)) return false;
376
- reqContext.body = { ...reqContext.body, input: reqContext.historyInputItems };
377
- reqContext.inputItems = reqContext.historyInputItems;
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
- if (loadCached()) {
7
- return { modelDefs: currentModelDefs(), source: "cache", backgroundRefresh: null };
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 };