copilot-relay 0.4.8 → 0.5.0
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 +8 -8
- package/dist/main.js +351 -31
- package/dist/main.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
# copilot-relay
|
|
2
2
|
|
|
3
|
-
**Use Claude Code with the models available through your GitHub Copilot subscription.**
|
|
3
|
+
**Use Claude Code and Codex with the models available through your GitHub Copilot subscription.**
|
|
4
4
|
|
|
5
|
-
[](https://nodejs.org/)
|
|
8
|
-
[](LICENSE)
|
|
5
|
+
[](https://github.com/D0n9X1n/copilot-relay/actions/workflows/ci.yml) [](https://www.npmjs.com/package/copilot-relay) [](https://www.npmjs.com/package/copilot-relay) [](https://github.com/D0n9X1n/copilot-relay/releases/latest)
|
|
6
|
+
[](https://nodejs.org/) [](tsconfig.json) [](wiki/README.md) [](LICENSE)
|
|
9
7
|
|
|
10
8
|
## Features
|
|
11
9
|
|
|
10
|
+
[](https://gitdiagram.com/d0n9x1n/copilot-relay/video)
|
|
11
|
+
|
|
12
12
|
- **Claude Code, familiar workflow** — Messages API compatibility, streaming responses, and tool calls.
|
|
13
|
+
- **Codex CLI and the Codex app** — an OpenAI Responses passthrough for Copilot's GPT models; [set it up](wiki/EN-Codex.md) · [中文](wiki/ZH-Codex.md).
|
|
13
14
|
- **WebSearch that fits the conversation** — relay-managed search, followed by an answer that can still use your tools.
|
|
14
15
|
- **Your models, your settings** — configurable GPT/Opus routes and reasoning effort; existing selections survive upgrades.
|
|
15
16
|
- **Use the advertised capacity** — discovered context/output limits, without silently shortening your input.
|
|
@@ -74,6 +75,5 @@ scripts. It asks GitHub with the stored token, so no relay needs to run.
|
|
|
74
75
|
- Run at login: [macOS](wiki/EN-macOS-LaunchAgent.md) · [Windows](wiki/EN-Windows-Service.md) · [Linux](wiki/EN-Linux-systemd.md)
|
|
75
76
|
- [Commands](wiki/EN-Commands.md) · [Prompt caching](wiki/EN-Prompt-Caching.md) · [Troubleshoot](wiki/EN-Logging-Troubleshooting.md) · [Understand the architecture](wiki/EN-Architecture.md) · [Contribute](wiki/EN-Development.md)
|
|
76
77
|
|
|
77
|
-
Unofficial research project; not affiliated with GitHub or
|
|
78
|
-
|
|
79
|
-
see the Wiki for known limitations. [MIT licensed](LICENSE).
|
|
78
|
+
Unofficial research project; not affiliated with GitHub, Anthropic or OpenAI. Upstream services and
|
|
79
|
+
compatibility can change; model and tool support are not guaranteed (see the Wiki). [MIT licensed](LICENSE).
|
package/dist/main.js
CHANGED
|
@@ -3343,10 +3343,10 @@ const pinCopilotModelCatalog = (config) => {
|
|
|
3343
3343
|
pinnedCatalogs.set(config, config.modelCatalog);
|
|
3344
3344
|
};
|
|
3345
3345
|
const pendingCatalogs = /* @__PURE__ */ new WeakMap();
|
|
3346
|
-
const isRecord$
|
|
3346
|
+
const isRecord$6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3347
3347
|
const isPositiveInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
3348
3348
|
function parseModelTokenLimits(value) {
|
|
3349
|
-
if (!isRecord$
|
|
3349
|
+
if (!isRecord$6(value)) return;
|
|
3350
3350
|
const context = value.max_context_window_tokens;
|
|
3351
3351
|
const prompt = value.max_prompt_tokens;
|
|
3352
3352
|
const output = value.max_output_tokens;
|
|
@@ -3402,13 +3402,13 @@ async function loadCopilotModelCatalog(config) {
|
|
|
3402
3402
|
});
|
|
3403
3403
|
if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
|
|
3404
3404
|
const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
|
|
3405
|
-
if (!isRecord$
|
|
3405
|
+
if (!isRecord$6(payload) || !Array.isArray(payload.data)) throw new Error("Copilot /models returned an invalid model catalog.");
|
|
3406
3406
|
const models$1 = /* @__PURE__ */ new Map();
|
|
3407
3407
|
for (const model of payload.data) {
|
|
3408
|
-
if (!isRecord$
|
|
3409
|
-
const capabilities = isRecord$
|
|
3408
|
+
if (!isRecord$6(model) || typeof model.id !== "string" || !model.id) continue;
|
|
3409
|
+
const capabilities = isRecord$6(model.capabilities) ? model.capabilities : void 0;
|
|
3410
3410
|
const limits = parseModelTokenLimits(capabilities?.limits);
|
|
3411
|
-
const supports = isRecord$
|
|
3411
|
+
const supports = isRecord$6(capabilities?.supports) ? capabilities.supports : void 0;
|
|
3412
3412
|
const supportedEndpoints = parseStringArray(model.supported_endpoints);
|
|
3413
3413
|
const reasoningEfforts = parseReasoningEfforts(supports);
|
|
3414
3414
|
models$1.set(model.id, {
|
|
@@ -3753,7 +3753,7 @@ const claudeCodeContext = [
|
|
|
3753
3753
|
const claudeCodeContextPatterns = [/^\S+ hook additional context:/];
|
|
3754
3754
|
const statusProbePrompt = "Reply with the single word: ok";
|
|
3755
3755
|
const statusProbeMaxTokens = 16;
|
|
3756
|
-
const isRecord$
|
|
3756
|
+
const isRecord$5 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3757
3757
|
const innermostReminder = /<system-reminder>(?:(?!<system-reminder>)[\s\S])*?<\/system-reminder>/g;
|
|
3758
3758
|
const withoutReminders = (text$1) => {
|
|
3759
3759
|
let previous = text$1;
|
|
@@ -3771,14 +3771,14 @@ const contentBlocks = (content) => {
|
|
|
3771
3771
|
type: "text",
|
|
3772
3772
|
text: content
|
|
3773
3773
|
}];
|
|
3774
|
-
return Array.isArray(content) ? content.filter(isRecord$
|
|
3774
|
+
return Array.isArray(content) ? content.filter(isRecord$5) : [];
|
|
3775
3775
|
};
|
|
3776
3776
|
const originatingTurn = (messages) => {
|
|
3777
3777
|
const blocks = [];
|
|
3778
3778
|
let sawUser = false;
|
|
3779
3779
|
for (let index = messages.length - 1; index >= 0; index--) {
|
|
3780
3780
|
const message = messages[index];
|
|
3781
|
-
if (!isRecord$
|
|
3781
|
+
if (!isRecord$5(message) || message.role === "system") continue;
|
|
3782
3782
|
if (message.role !== "user") break;
|
|
3783
3783
|
sawUser = true;
|
|
3784
3784
|
blocks.unshift(...contentBlocks(message.content));
|
|
@@ -3798,11 +3798,11 @@ const blockOrigin = (block) => {
|
|
|
3798
3798
|
const isStatusProbe = (body) => {
|
|
3799
3799
|
if (Object.keys(body).sort().join(",") !== "max_tokens,messages,model" || body.max_tokens !== statusProbeMaxTokens) return false;
|
|
3800
3800
|
const messages = body.messages;
|
|
3801
|
-
return Array.isArray(messages) && messages.length === 1 && isRecord$
|
|
3801
|
+
return Array.isArray(messages) && messages.length === 1 && isRecord$5(messages[0]) && messages[0].role === "user" && messages[0].content === statusProbePrompt;
|
|
3802
3802
|
};
|
|
3803
3803
|
const systemTexts = (system) => contentBlocks(system).flatMap((block) => block.type === "text" && typeof block.text === "string" ? [block.text] : []);
|
|
3804
3804
|
const getClaudeRequestInitiator = (body) => {
|
|
3805
|
-
if (!isRecord$
|
|
3805
|
+
if (!isRecord$5(body) || !Array.isArray(body.messages) || isStatusProbe(body)) return "agent";
|
|
3806
3806
|
const payload = body;
|
|
3807
3807
|
if (readBillingLineField(payload, "cc_is_subagent") === "true" || readBillingLineField(payload, "cc_workload") === "cron") return "agent";
|
|
3808
3808
|
if (systemTexts(removeBillingLine(payload).system).some((text$1) => startsWithAny(text$1.trimStart(), ownRequestSystemPrompts))) return "agent";
|
|
@@ -4406,7 +4406,7 @@ const webSearchInputSchema = {
|
|
|
4406
4406
|
required: ["query"],
|
|
4407
4407
|
additionalProperties: false
|
|
4408
4408
|
};
|
|
4409
|
-
const isRecord$
|
|
4409
|
+
const isRecord$4 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4410
4410
|
const isAnthropicNativeWebSearchTool = (tool) => tool.name === "web_search" && typeof tool.type === "string" && anthropicWebSearchToolPattern.test(tool.type);
|
|
4411
4411
|
const isClaudeCodeWebSearchTool = (tool) => tool.name === claudeCodeWebSearchToolName;
|
|
4412
4412
|
const isClaudeWebSearchTool = (tool) => isAnthropicNativeWebSearchTool(tool) || isClaudeCodeWebSearchTool(tool);
|
|
@@ -4446,7 +4446,7 @@ const getQueryFromToolArguments = (value) => {
|
|
|
4446
4446
|
if (!trimmed) return;
|
|
4447
4447
|
try {
|
|
4448
4448
|
const parsed = JSON.parse(trimmed);
|
|
4449
|
-
if (isRecord$
|
|
4449
|
+
if (isRecord$4(parsed) && typeof parsed.query === "string" && parsed.query.trim()) return parsed.query.trim();
|
|
4450
4450
|
} catch {
|
|
4451
4451
|
return trimmed;
|
|
4452
4452
|
}
|
|
@@ -4503,7 +4503,7 @@ const getReportedQuery = (action, queries) => {
|
|
|
4503
4503
|
const getSearchQuery = (response, requestedQuery) => {
|
|
4504
4504
|
for (const item of response.output ?? []) {
|
|
4505
4505
|
if (item.type !== "web_search_call") continue;
|
|
4506
|
-
const action = isRecord$
|
|
4506
|
+
const action = isRecord$4(item.action) ? item.action : void 0;
|
|
4507
4507
|
const query = getReportedQuery(action, Array.isArray(action?.queries) ? action.queries : []);
|
|
4508
4508
|
if (query) return query;
|
|
4509
4509
|
}
|
|
@@ -4511,7 +4511,7 @@ const getSearchQuery = (response, requestedQuery) => {
|
|
|
4511
4511
|
};
|
|
4512
4512
|
const getResponseText = (response) => (response.output ?? []).flatMap((item) => {
|
|
4513
4513
|
if (item.type !== "message") return [];
|
|
4514
|
-
return (Array.isArray(item.content) ? item.content : []).flatMap((part) => isRecord$
|
|
4514
|
+
return (Array.isArray(item.content) ? item.content : []).flatMap((part) => isRecord$4(part) && part.type === "output_text" && typeof part.text === "string" ? [part.text] : []);
|
|
4515
4515
|
}).join("\n").trim();
|
|
4516
4516
|
const cleanTitle = (line, url) => {
|
|
4517
4517
|
return line.slice(0, line.indexOf(url)).replace(/^\s*(?:[-*]|\d+[.)])\s*/, "").replace(/\s*(?:[-:|])\s*$/, "").trim() || new URL(url).hostname;
|
|
@@ -4535,15 +4535,15 @@ const parseSearchResults = (text$1) => {
|
|
|
4535
4535
|
const getStructuredSearchResults = (response) => {
|
|
4536
4536
|
const sources = [];
|
|
4537
4537
|
for (const item of response.output ?? []) {
|
|
4538
|
-
if (item.type === "web_search_call" && isRecord$
|
|
4538
|
+
if (item.type === "web_search_call" && isRecord$4(item.action) && Array.isArray(item.action.sources)) sources.push(...item.action.sources);
|
|
4539
4539
|
if (item.type === "message" && Array.isArray(item.content)) {
|
|
4540
|
-
for (const part of item.content) if (isRecord$
|
|
4540
|
+
for (const part of item.content) if (isRecord$4(part) && Array.isArray(part.annotations)) sources.push(...part.annotations.filter((annotation) => isRecord$4(annotation) && annotation.type === "url_citation"));
|
|
4541
4541
|
}
|
|
4542
4542
|
}
|
|
4543
4543
|
const results = [];
|
|
4544
4544
|
const seen = /* @__PURE__ */ new Set();
|
|
4545
4545
|
for (const source of sources) {
|
|
4546
|
-
if (!isRecord$
|
|
4546
|
+
if (!isRecord$4(source) || typeof source.url !== "string") continue;
|
|
4547
4547
|
try {
|
|
4548
4548
|
const url = new URL(source.url);
|
|
4549
4549
|
if (!/^https?:$/.test(url.protocol) || url.username || url.password || seen.has(url.href)) continue;
|
|
@@ -4595,21 +4595,21 @@ const getResultSource = (structured, results) => {
|
|
|
4595
4595
|
return results.length ? "text" : "none";
|
|
4596
4596
|
};
|
|
4597
4597
|
const interpretSearchResponse = (raw, config, requestedQuery, request, requestedEffort, requestId) => {
|
|
4598
|
-
const malformed = !isRecord$
|
|
4599
|
-
const upstream = isRecord$
|
|
4598
|
+
const malformed = !isRecord$4(raw) || raw.output !== void 0 && (!Array.isArray(raw.output) || !raw.output.every(isRecord$4));
|
|
4599
|
+
const upstream = isRecord$4(raw) ? {
|
|
4600
4600
|
...raw,
|
|
4601
|
-
output: Array.isArray(raw.output) ? raw.output.filter(isRecord$
|
|
4601
|
+
output: Array.isArray(raw.output) ? raw.output.filter(isRecord$4) : []
|
|
4602
4602
|
} : {};
|
|
4603
4603
|
const status$1 = recognizedValue(upstream.status, responseStates);
|
|
4604
|
-
const reason = recognizedValue(isRecord$
|
|
4604
|
+
const reason = recognizedValue(isRecord$4(upstream.incomplete_details) ? upstream.incomplete_details.reason : void 0, incompleteReasons);
|
|
4605
4605
|
const items = upstream.output ?? [];
|
|
4606
4606
|
const calls = items.filter((item) => item.type === "web_search_call");
|
|
4607
4607
|
const callStates = calls.map((item) => recognizedValue(item.status, searchStates));
|
|
4608
|
-
const usage$1 = isRecord$
|
|
4608
|
+
const usage$1 = isRecord$4(upstream.usage) ? upstream.usage : {};
|
|
4609
4609
|
const inputTokens = reportedTokens(usage$1.input_tokens);
|
|
4610
|
-
const cachedInputTokens = reportedTokens(isRecord$
|
|
4610
|
+
const cachedInputTokens = reportedTokens(isRecord$4(usage$1.input_tokens_details) ? usage$1.input_tokens_details.cached_tokens : void 0);
|
|
4611
4611
|
const outputTokens = reportedTokens(usage$1.output_tokens);
|
|
4612
|
-
const reasoningTokens = reportedTokens(isRecord$
|
|
4612
|
+
const reasoningTokens = reportedTokens(isRecord$4(usage$1.output_tokens_details) ? usage$1.output_tokens_details.reasoning_tokens : void 0);
|
|
4613
4613
|
const upstreamResponseId = safeResponseId(upstream.id, config.copilotToken);
|
|
4614
4614
|
const safeRequestId = typeof requestId === "string" && /^[a-f0-9-]{36}$/i.test(requestId) ? requestId : "unreported";
|
|
4615
4615
|
const text$1 = getResponseText(upstream);
|
|
@@ -4697,7 +4697,7 @@ const getSearchFailureDetail = (body, token) => {
|
|
|
4697
4697
|
let detail = body.trim();
|
|
4698
4698
|
try {
|
|
4699
4699
|
const parsed = JSON.parse(detail);
|
|
4700
|
-
detail = getErrorMessageOrCode(isRecord$
|
|
4700
|
+
detail = getErrorMessageOrCode(isRecord$4(parsed) && isRecord$4(parsed.error) ? parsed.error : void 0);
|
|
4701
4701
|
} catch {
|
|
4702
4702
|
if (/^[{[<]/.test(detail)) return "";
|
|
4703
4703
|
}
|
|
@@ -5269,7 +5269,7 @@ const lookaroundPrefixes = [
|
|
|
5269
5269
|
"(?<=",
|
|
5270
5270
|
"(?<!"
|
|
5271
5271
|
];
|
|
5272
|
-
const isRecord$
|
|
5272
|
+
const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5273
5273
|
function hasUnsupportedPatternFeatures(pattern) {
|
|
5274
5274
|
let inCharacterClass = false;
|
|
5275
5275
|
for (let index = 0; index < pattern.length; index++) {
|
|
@@ -5315,12 +5315,12 @@ function mapArray(values) {
|
|
|
5315
5315
|
}
|
|
5316
5316
|
function normalizeSchemaValue(value) {
|
|
5317
5317
|
if (Array.isArray(value)) return mapArray(value);
|
|
5318
|
-
return isRecord$
|
|
5318
|
+
return isRecord$3(value) ? normalizeResponsesToolSchema(value) : value;
|
|
5319
5319
|
}
|
|
5320
5320
|
function normalizeResponsesToolSchema(schema) {
|
|
5321
5321
|
return mapRecord(schema, (key, value) => {
|
|
5322
5322
|
if (key === "pattern" && typeof value === "string" && hasUnsupportedPatternFeatures(value)) return removed;
|
|
5323
|
-
if (schemaMapKeywords.has(key) && isRecord$
|
|
5323
|
+
if (schemaMapKeywords.has(key) && isRecord$3(value)) return mapRecord(value, (_name, nested) => normalizeSchemaValue(nested));
|
|
5324
5324
|
if (nestedSchemaKeywords.has(key)) return normalizeSchemaValue(value);
|
|
5325
5325
|
return value;
|
|
5326
5326
|
});
|
|
@@ -6897,6 +6897,320 @@ claudeRoutes.post("/messages/count_tokens", async (c) => {
|
|
|
6897
6897
|
}
|
|
6898
6898
|
});
|
|
6899
6899
|
|
|
6900
|
+
//#endregion
|
|
6901
|
+
//#region src/codex/request.ts
|
|
6902
|
+
const copilotReasoningEfforts = [
|
|
6903
|
+
"none",
|
|
6904
|
+
"minimal",
|
|
6905
|
+
"low",
|
|
6906
|
+
"medium",
|
|
6907
|
+
"high",
|
|
6908
|
+
"xhigh",
|
|
6909
|
+
"max"
|
|
6910
|
+
];
|
|
6911
|
+
const effortOrder = [
|
|
6912
|
+
"none",
|
|
6913
|
+
"minimal",
|
|
6914
|
+
"low",
|
|
6915
|
+
"medium",
|
|
6916
|
+
"high",
|
|
6917
|
+
"xhigh",
|
|
6918
|
+
"max",
|
|
6919
|
+
"ultra"
|
|
6920
|
+
];
|
|
6921
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6922
|
+
const codexRequestError = (message, code, status$1 = 400) => new HTTPError(message, Response.json({ error: {
|
|
6923
|
+
message,
|
|
6924
|
+
type: "invalid_request_error",
|
|
6925
|
+
code
|
|
6926
|
+
} }, { status: status$1 }), message);
|
|
6927
|
+
const parseCodexPayload = (text$1) => {
|
|
6928
|
+
let body;
|
|
6929
|
+
try {
|
|
6930
|
+
body = JSON.parse(text$1);
|
|
6931
|
+
} catch {
|
|
6932
|
+
throw codexRequestError("The request body is not valid JSON.", "invalid_json");
|
|
6933
|
+
}
|
|
6934
|
+
if (!isRecord$2(body) || typeof body.model !== "string" || body.model.trim() === "") throw codexRequestError("The request needs a model.", "missing_model");
|
|
6935
|
+
if (typeof body.input !== "string" && !Array.isArray(body.input)) throw codexRequestError("The request needs an input string or array.", "missing_input");
|
|
6936
|
+
return body;
|
|
6937
|
+
};
|
|
6938
|
+
const catalogDescribesUpstream = (config) => config.modelCatalog?.baseUrl === config.copilotBaseUrl;
|
|
6939
|
+
/**
|
|
6940
|
+
* The Copilot model a Codex request is sent to.
|
|
6941
|
+
*
|
|
6942
|
+
* A model Copilot's catalog lists is used as named, so Codex's own model IDs reach the same
|
|
6943
|
+
* Copilot model. A name the catalog does not list, such as Codex's internal codex-auto-review,
|
|
6944
|
+
* goes to the configured gptModel, as Claude Code's aliases do. Without a catalog the name is
|
|
6945
|
+
* forwarded unchanged. Claude models are refused: Copilot serves them only on Messages and Chat
|
|
6946
|
+
* Completions.
|
|
6947
|
+
*/
|
|
6948
|
+
const resolveCodexModel = async (config, requested) => {
|
|
6949
|
+
if (isClaudeModelId(requested)) throw codexRequestError(`${requested} is not available through the Responses API: Copilot serves Claude models only on Messages and Chat Completions. Choose a GPT model.`, "relay_unsupported_model");
|
|
6950
|
+
const normalized = normalizeCopilotModelId(requested);
|
|
6951
|
+
await ensureCopilotModelCatalog(config, normalized);
|
|
6952
|
+
pinCopilotModelCatalog(config);
|
|
6953
|
+
const model = !(getCachedCopilotModel(config, normalized) !== void 0) && catalogDescribesUpstream(config) ? getModelRouting().gptModel : normalized;
|
|
6954
|
+
const capabilities = getCachedCopilotModel(config, model);
|
|
6955
|
+
const notChat = capabilities?.type !== void 0 && capabilities.type !== "chat";
|
|
6956
|
+
const noResponses = capabilities?.supportedEndpoints !== void 0 && !capabilities.supportedEndpoints.includes("/responses");
|
|
6957
|
+
if (notChat || noResponses) throw codexRequestError(`${model} does not serve the Responses API on Copilot. Choose a model that lists /responses in copilot-relay models.`, "relay_unsupported_endpoint");
|
|
6958
|
+
return model;
|
|
6959
|
+
};
|
|
6960
|
+
const effortRank = (effort) => effortOrder.indexOf(effort);
|
|
6961
|
+
/**
|
|
6962
|
+
* The reasoning effort to send for a requested one.
|
|
6963
|
+
*
|
|
6964
|
+
* A level the model advertises, or one this relay does not know, is sent as requested. Any other
|
|
6965
|
+
* level becomes the highest advertised level below it, or the lowest advertised level when none is
|
|
6966
|
+
* below it. A model that advertises no levels gets none, since Copilot rejects the field for it.
|
|
6967
|
+
*/
|
|
6968
|
+
const clampReasoningEffort = (requested, advertised) => {
|
|
6969
|
+
if (advertised?.length === 0) return;
|
|
6970
|
+
const accepted = advertised ?? copilotReasoningEfforts;
|
|
6971
|
+
if (accepted.includes(requested) || effortRank(requested) === -1) return requested;
|
|
6972
|
+
const ranked = accepted.filter((effort) => effortRank(effort) !== -1).sort((left, right) => effortRank(left) - effortRank(right));
|
|
6973
|
+
return ranked.filter((effort) => effortRank(effort) <= effortRank(requested)).at(-1) ?? ranked[0];
|
|
6974
|
+
};
|
|
6975
|
+
/**
|
|
6976
|
+
* The body sent to Copilot for a Codex request.
|
|
6977
|
+
*
|
|
6978
|
+
* Copilot answers HTTP 400 to `service_tier`, to an `image_generation` tool, and to an effort it
|
|
6979
|
+
* does not accept; those are the only changes. Every other field, including Codex's
|
|
6980
|
+
* prompt_cache_key and its encrypted reasoning, is forwarded unchanged.
|
|
6981
|
+
*/
|
|
6982
|
+
const prepareCodexPayload = (payload, model, capabilities) => {
|
|
6983
|
+
const prepared = {
|
|
6984
|
+
...payload,
|
|
6985
|
+
model
|
|
6986
|
+
};
|
|
6987
|
+
const removed$1 = [];
|
|
6988
|
+
if (Object.hasOwn(prepared, "service_tier")) {
|
|
6989
|
+
delete prepared.service_tier;
|
|
6990
|
+
removed$1.push("service_tier");
|
|
6991
|
+
}
|
|
6992
|
+
if (Array.isArray(prepared.tools)) {
|
|
6993
|
+
const tools = prepared.tools.filter((tool) => !(isRecord$2(tool) && tool.type === "image_generation"));
|
|
6994
|
+
if (tools.length !== prepared.tools.length) {
|
|
6995
|
+
prepared.tools = tools;
|
|
6996
|
+
removed$1.push("image_generation");
|
|
6997
|
+
}
|
|
6998
|
+
}
|
|
6999
|
+
const reasoning = isRecord$2(prepared.reasoning) ? prepared.reasoning : void 0;
|
|
7000
|
+
const requestedEffort = typeof reasoning?.effort === "string" ? reasoning.effort : void 0;
|
|
7001
|
+
let effectiveEffort = requestedEffort;
|
|
7002
|
+
if (reasoning !== void 0 && requestedEffort !== void 0) {
|
|
7003
|
+
effectiveEffort = clampReasoningEffort(requestedEffort, capabilities?.reasoningEfforts);
|
|
7004
|
+
if (effectiveEffort === void 0) {
|
|
7005
|
+
const withoutEffort = { ...reasoning };
|
|
7006
|
+
delete withoutEffort.effort;
|
|
7007
|
+
prepared.reasoning = withoutEffort;
|
|
7008
|
+
} else if (effectiveEffort !== requestedEffort) prepared.reasoning = {
|
|
7009
|
+
...reasoning,
|
|
7010
|
+
effort: effectiveEffort
|
|
7011
|
+
};
|
|
7012
|
+
}
|
|
7013
|
+
return {
|
|
7014
|
+
payload: prepared,
|
|
7015
|
+
removed: removed$1,
|
|
7016
|
+
requestedEffort,
|
|
7017
|
+
effectiveEffort
|
|
7018
|
+
};
|
|
7019
|
+
};
|
|
7020
|
+
/**
|
|
7021
|
+
* Who started a Codex request, for Copilot's x-initiator header.
|
|
7022
|
+
*
|
|
7023
|
+
* A request continues the agent's own work unless its last input item is a user message: after a
|
|
7024
|
+
* tool call the last item is the tool's output. Codex marks its subagents (review, compaction,
|
|
7025
|
+
* memory consolidation, spawned agents) with x-openai-subagent, and those are always the agent.
|
|
7026
|
+
*/
|
|
7027
|
+
const getCodexInitiator = (input, subagent) => {
|
|
7028
|
+
if (subagent !== void 0) return "agent";
|
|
7029
|
+
if (typeof input === "string") return "user";
|
|
7030
|
+
const last = Array.isArray(input) ? input.at(-1) : void 0;
|
|
7031
|
+
return isRecord$2(last) && last.role === "user" && (last.type === void 0 || last.type === "message") ? "user" : "agent";
|
|
7032
|
+
};
|
|
7033
|
+
const includesImage = (value) => {
|
|
7034
|
+
if (Array.isArray(value)) return value.some(includesImage);
|
|
7035
|
+
if (!isRecord$2(value)) return false;
|
|
7036
|
+
return value.type === "input_image" || Object.values(value).some(includesImage);
|
|
7037
|
+
};
|
|
7038
|
+
/**
|
|
7039
|
+
* A stream holding only `response.failed` with code `context_length_exceeded`.
|
|
7040
|
+
*
|
|
7041
|
+
* Copilot reports a prompt over the model's input limit as HTTP 400, which Codex treats as a fatal
|
|
7042
|
+
* error. Codex recognizes a context-window overflow only from this event, and answers it by
|
|
7043
|
+
* compacting the conversation.
|
|
7044
|
+
*/
|
|
7045
|
+
const contextOverflowStream = (requestId, message) => {
|
|
7046
|
+
const event = {
|
|
7047
|
+
type: "response.failed",
|
|
7048
|
+
response: {
|
|
7049
|
+
id: `resp_relay_${requestId}`,
|
|
7050
|
+
object: "response",
|
|
7051
|
+
status: "failed",
|
|
7052
|
+
error: {
|
|
7053
|
+
code: "context_length_exceeded",
|
|
7054
|
+
message
|
|
7055
|
+
}
|
|
7056
|
+
}
|
|
7057
|
+
};
|
|
7058
|
+
return `event: response.failed\ndata: ${JSON.stringify(event)}\n\n`;
|
|
7059
|
+
};
|
|
7060
|
+
|
|
7061
|
+
//#endregion
|
|
7062
|
+
//#region src/routes/responses.ts
|
|
7063
|
+
const responsesRoutes = new Hono();
|
|
7064
|
+
const forwardedHeaders = [
|
|
7065
|
+
"content-type",
|
|
7066
|
+
"retry-after",
|
|
7067
|
+
"openai-model",
|
|
7068
|
+
"x-request-id",
|
|
7069
|
+
"x-github-request-id",
|
|
7070
|
+
"x-copilot-service-request-id"
|
|
7071
|
+
];
|
|
7072
|
+
const copyForwardedHeaders = (source) => {
|
|
7073
|
+
const headers = new Headers();
|
|
7074
|
+
for (const name of forwardedHeaders) {
|
|
7075
|
+
const value = source.get(name);
|
|
7076
|
+
if (value !== null) headers.set(name, value);
|
|
7077
|
+
}
|
|
7078
|
+
return headers;
|
|
7079
|
+
};
|
|
7080
|
+
const readSubagent = (value) => {
|
|
7081
|
+
if (value === void 0) return;
|
|
7082
|
+
return /^[\w.-]{1,64}$/.test(value) ? value : "other";
|
|
7083
|
+
};
|
|
7084
|
+
/**
|
|
7085
|
+
* Passes the upstream body to the client and settles the request trace when it ends.
|
|
7086
|
+
*
|
|
7087
|
+
* The trace cancels every upstream body still open once the handler settles, so a streamed answer
|
|
7088
|
+
* must settle the handler only after its last byte, an error, or the client's cancellation.
|
|
7089
|
+
*/
|
|
7090
|
+
const settleWhenDone = (body, trace) => {
|
|
7091
|
+
const reader = body.getReader();
|
|
7092
|
+
let settled = false;
|
|
7093
|
+
const settle = (error) => {
|
|
7094
|
+
if (settled) return;
|
|
7095
|
+
settled = true;
|
|
7096
|
+
trace?.handlerSettled(error);
|
|
7097
|
+
};
|
|
7098
|
+
return new ReadableStream({
|
|
7099
|
+
pull: async (controller) => {
|
|
7100
|
+
try {
|
|
7101
|
+
const next = await reader.read();
|
|
7102
|
+
if (settled) return;
|
|
7103
|
+
if (next.done) {
|
|
7104
|
+
controller.close();
|
|
7105
|
+
settle();
|
|
7106
|
+
return;
|
|
7107
|
+
}
|
|
7108
|
+
controller.enqueue(next.value);
|
|
7109
|
+
} catch (error) {
|
|
7110
|
+
if (!settled) controller.error(error);
|
|
7111
|
+
settle(error);
|
|
7112
|
+
}
|
|
7113
|
+
},
|
|
7114
|
+
cancel: async (reason) => {
|
|
7115
|
+
settle();
|
|
7116
|
+
await reader.cancel(reason).catch(() => {});
|
|
7117
|
+
}
|
|
7118
|
+
}, { highWaterMark: 0 });
|
|
7119
|
+
};
|
|
7120
|
+
responsesRoutes.post("/responses", async (c) => {
|
|
7121
|
+
const config = c.get("config");
|
|
7122
|
+
const requestId = c.get("requestId");
|
|
7123
|
+
const trace = c.get("requestTrace");
|
|
7124
|
+
const signal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
|
|
7125
|
+
let response;
|
|
7126
|
+
let stream = false;
|
|
7127
|
+
try {
|
|
7128
|
+
const payload = parseCodexPayload(await c.req.text());
|
|
7129
|
+
stream = payload.stream === true;
|
|
7130
|
+
const model = await resolveCodexModel(config, payload.model);
|
|
7131
|
+
const prepared = prepareCodexPayload(payload, model, getCachedCopilotModel(config, model));
|
|
7132
|
+
const subagent = readSubagent(c.req.header("x-openai-subagent"));
|
|
7133
|
+
const initiator = getCodexInitiator(payload.input, subagent);
|
|
7134
|
+
log.info(sanitizeTerminalString([
|
|
7135
|
+
`request_id=${requestId}`,
|
|
7136
|
+
"Model request client=codex",
|
|
7137
|
+
`requested_model=${payload.model}`,
|
|
7138
|
+
`upstream_model=${model}`,
|
|
7139
|
+
"upstream_api=responses",
|
|
7140
|
+
`requested_think_effort=${prepared.requestedEffort ?? "unset"}`,
|
|
7141
|
+
`effective_think_effort=${prepared.effectiveEffort ?? "omitted"}`,
|
|
7142
|
+
`initiator=${initiator}`,
|
|
7143
|
+
...subagent === void 0 ? [] : [`subagent=${subagent}`],
|
|
7144
|
+
...prepared.removed.length === 0 ? [] : [`removed=${prepared.removed.join(",")}`]
|
|
7145
|
+
].join(" ")));
|
|
7146
|
+
response = await fetchCopilot(getCopilotProviderContext(config), "/responses", {
|
|
7147
|
+
method: "POST",
|
|
7148
|
+
headers: {
|
|
7149
|
+
accept: stream ? "text/event-stream" : "application/json",
|
|
7150
|
+
"content-type": "application/json"
|
|
7151
|
+
},
|
|
7152
|
+
body: JSON.stringify(prepared.payload)
|
|
7153
|
+
}, {
|
|
7154
|
+
initiator,
|
|
7155
|
+
requestId,
|
|
7156
|
+
signal,
|
|
7157
|
+
timeoutMs: config.upstreamTimeoutMs,
|
|
7158
|
+
vision: includesImage(prepared.payload.input)
|
|
7159
|
+
});
|
|
7160
|
+
if (!response.ok) {
|
|
7161
|
+
const detail = await readCopilotText(response, signal, config.upstreamTimeoutMs);
|
|
7162
|
+
const overflow = toPromptTooLongError(response.status, detail);
|
|
7163
|
+
if (overflow && stream) {
|
|
7164
|
+
c.set("requestErrorMessage", overflow.message);
|
|
7165
|
+
log.error(`request_id=${requestId} ${overflow.message}; sent to Codex as context_length_exceeded`);
|
|
7166
|
+
return new Response(contextOverflowStream(requestId, overflow.message), {
|
|
7167
|
+
status: 200,
|
|
7168
|
+
headers: {
|
|
7169
|
+
"content-type": "text/event-stream",
|
|
7170
|
+
"cache-control": "no-cache"
|
|
7171
|
+
}
|
|
7172
|
+
});
|
|
7173
|
+
}
|
|
7174
|
+
const message = `Copilot /responses returned ${response.status}`;
|
|
7175
|
+
c.set("requestErrorMessage", message);
|
|
7176
|
+
log.error(`request_id=${requestId} ${message}`);
|
|
7177
|
+
return new Response(detail, {
|
|
7178
|
+
status: response.status,
|
|
7179
|
+
headers: copyForwardedHeaders(response.headers)
|
|
7180
|
+
});
|
|
7181
|
+
}
|
|
7182
|
+
} catch (error) {
|
|
7183
|
+
if (error instanceof ProxyNotImplementedError) {
|
|
7184
|
+
c.set("requestErrorMessage", error.message);
|
|
7185
|
+
return c.json({ error: {
|
|
7186
|
+
message: error.message,
|
|
7187
|
+
type: "server_error",
|
|
7188
|
+
code: "relay_not_configured"
|
|
7189
|
+
} }, 501);
|
|
7190
|
+
}
|
|
7191
|
+
if (!(error instanceof HTTPError)) throw error;
|
|
7192
|
+
const message = sanitizeTerminalString(error.message);
|
|
7193
|
+
c.set("requestErrorMessage", message);
|
|
7194
|
+
log.error(`request_id=${requestId} ${message}`);
|
|
7195
|
+
if (error.response.status === 400 && trace?.manifest.exchanges.length === 0) trace.recordFailure("local-validation");
|
|
7196
|
+
return new Response(await error.response.arrayBuffer(), {
|
|
7197
|
+
status: error.response.status,
|
|
7198
|
+
headers: copyForwardedHeaders(error.response.headers)
|
|
7199
|
+
});
|
|
7200
|
+
}
|
|
7201
|
+
const headers = copyForwardedHeaders(response.headers);
|
|
7202
|
+
if (stream) headers.set("cache-control", "no-cache");
|
|
7203
|
+
if (!response.body) return new Response(null, {
|
|
7204
|
+
status: response.status,
|
|
7205
|
+
headers
|
|
7206
|
+
});
|
|
7207
|
+
trace?.deferHandler();
|
|
7208
|
+
return new Response(settleWhenDone(response.body, trace), {
|
|
7209
|
+
status: response.status,
|
|
7210
|
+
headers
|
|
7211
|
+
});
|
|
7212
|
+
});
|
|
7213
|
+
|
|
6900
7214
|
//#endregion
|
|
6901
7215
|
//#region src/server.ts
|
|
6902
7216
|
const loggedRequestHeaders = [
|
|
@@ -6951,6 +7265,11 @@ const presentsApiKey = (headers, apiKey) => {
|
|
|
6951
7265
|
for (const presented of [headers.get("x-api-key"), bearer]) if (typeof presented === "string" && timingSafeEqual(sha256(presented), expected)) matched = true;
|
|
6952
7266
|
return matched;
|
|
6953
7267
|
};
|
|
7268
|
+
const jsonBodyPaths = new Set([
|
|
7269
|
+
"/v1/messages",
|
|
7270
|
+
"/v1/messages/count_tokens",
|
|
7271
|
+
"/v1/responses"
|
|
7272
|
+
]);
|
|
6954
7273
|
const isOpenProbe = (method, path$1) => method === "GET" && path$1 === "/healthz" || (method === "GET" || method === "HEAD") && path$1 === "/api/hello";
|
|
6955
7274
|
const createServer = (config) => {
|
|
6956
7275
|
const app = new Hono();
|
|
@@ -7007,7 +7326,7 @@ const createServer = (config) => {
|
|
|
7007
7326
|
}
|
|
7008
7327
|
}, 401, { "www-authenticate": "Bearer" });
|
|
7009
7328
|
}
|
|
7010
|
-
if (c.req.method === "POST" && (c.req.path
|
|
7329
|
+
if (c.req.method === "POST" && jsonBodyPaths.has(c.req.path) && (incoming ? Boolean(incoming.headers["transfer-encoding"]) || Number(incoming.headers["content-length"] ?? 0) > 0 : c.req.raw.body !== null) && c.req.header("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/json") {
|
|
7011
7330
|
const message = "Content-Type must be application/json";
|
|
7012
7331
|
c.set("requestErrorMessage", message);
|
|
7013
7332
|
return c.json({ error: { message } }, 415);
|
|
@@ -7051,6 +7370,7 @@ const createServer = (config) => {
|
|
|
7051
7370
|
}));
|
|
7052
7371
|
app.on(["GET", "HEAD"], "/api/hello", (c) => c.body(null, 200));
|
|
7053
7372
|
app.route("/v1", claudeRoutes);
|
|
7373
|
+
app.route("/v1", responsesRoutes);
|
|
7054
7374
|
app.notFound(async (c) => {
|
|
7055
7375
|
const message = "Unsupported Claude API route";
|
|
7056
7376
|
c.set("requestErrorMessage", message);
|
|
@@ -9514,7 +9834,7 @@ const usage = defineCommand({
|
|
|
9514
9834
|
await runMain(defineCommand({
|
|
9515
9835
|
meta: {
|
|
9516
9836
|
name: "copilot-relay",
|
|
9517
|
-
description: "Yet, just another relay for Claude Code to use a GitHub Copilot subscription."
|
|
9837
|
+
description: "Yet, just another relay for Claude Code and Codex to use a GitHub Copilot subscription."
|
|
9518
9838
|
},
|
|
9519
9839
|
subCommands: {
|
|
9520
9840
|
auth,
|