copilot-relay 0.3.4 → 0.3.6
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 +14 -2
- package/config.default.yaml +3 -0
- package/dist/main.js +307 -108
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,7 +75,17 @@ With `claudeSetup: true`, `start` manages `ANTHROPIC_BASE_URL`, a dummy
|
|
|
75
75
|
`ANTHROPIC_AUTH_TOKEN` when auth is absent, and the configured GPT default via
|
|
76
76
|
the top-level `model` field in `~/.claude/settings.json`. Exact
|
|
77
77
|
`gpt-6-astra` and `gpt-5.6-sol` overrides are normalized to Claude-facing
|
|
78
|
-
`[1m]` identities;
|
|
78
|
+
`[1m]` identities when discovery reports exactly 1M; other reported windows use
|
|
79
|
+
plain GPT IDs with Claude's numeric context setting. Unrelated model choices and
|
|
80
|
+
existing token-budget overrides are preserved.
|
|
81
|
+
|
|
82
|
+
Startup retains Copilot's actual context, prompt, and output limits and exposes
|
|
83
|
+
them on `GET /v1/models`. Managed Claude setup seeds
|
|
84
|
+
`CLAUDE_CODE_MAX_CONTEXT_TOKENS` and `CLAUDE_CODE_MAX_OUTPUT_TOKENS` when absent.
|
|
85
|
+
The default models support 1M total context, with up to 128K output tokens for
|
|
86
|
+
Astra and 64K for Opus. Input text is never shortened; smaller explicit output
|
|
87
|
+
budgets remain respected. See [full-context configuration](wiki/EN-Configuration.md)
|
|
88
|
+
for existing/manual Claude setups and the limits on input plus output.
|
|
79
89
|
|
|
80
90
|
## Config
|
|
81
91
|
|
|
@@ -116,7 +126,9 @@ Valid `thinkEffort`: `none`, `low`, `medium`, `high`, `xhigh`, `max`.
|
|
|
116
126
|
|
|
117
127
|
`upstreamTimeoutSeconds` controls the maximum time a single Claude request can
|
|
118
128
|
spend waiting on upstream Copilot calls, including chat, Responses, preflight,
|
|
119
|
-
and bridge-managed WebSearch calls. The default is `180`.
|
|
129
|
+
and bridge-managed WebSearch calls. The default is `180`. Set `0` to disable the
|
|
130
|
+
relay deadline for very long requests; client cancellation and provider/transport
|
|
131
|
+
timeouts still apply. Existing saved timeouts are never changed by an upgrade.
|
|
120
132
|
|
|
121
133
|
`webSearchBackend` controls bridge-managed Claude WebSearch. Leave it empty to
|
|
122
134
|
use `gptModel`, or set a Copilot Responses model ID such as `gpt-5.5`.
|
package/config.default.yaml
CHANGED
|
@@ -21,6 +21,7 @@ copilotBaseUrl: https://api.githubcopilot.com
|
|
|
21
21
|
|
|
22
22
|
# When true, start updates ~/.claude/settings.json with ANTHROPIC_BASE_URL
|
|
23
23
|
# and a dummy ANTHROPIC_AUTH_TOKEN so Claude Code can use this proxy.
|
|
24
|
+
# Also seeds context/output budgets from model discovery when not already set.
|
|
24
25
|
claudeSetup: true
|
|
25
26
|
|
|
26
27
|
# Log verbosity:
|
|
@@ -43,6 +44,8 @@ logRetentionDays: 3
|
|
|
43
44
|
thinkEffort: max
|
|
44
45
|
|
|
45
46
|
# Max seconds to wait for one Claude request's upstream Copilot calls.
|
|
47
|
+
# Set 0 to disable the relay deadline for very long requests. Client cancellation
|
|
48
|
+
# and upstream/provider limits still apply.
|
|
46
49
|
upstreamTimeoutSeconds: 180
|
|
47
50
|
|
|
48
51
|
# Copilot model used for bridge-managed Claude WebSearch.
|
package/dist/main.js
CHANGED
|
@@ -551,9 +551,12 @@ const oneMillionContextModelPattern = /^(gpt-5\.6-sol|gpt-6-astra)(?:\[1m\])*$/i
|
|
|
551
551
|
const normalizeOneMillionContextModel = (model) => {
|
|
552
552
|
return oneMillionContextModelPattern.exec(model)?.[1]?.toLowerCase();
|
|
553
553
|
};
|
|
554
|
-
const normalizeClaudeModelId = (model) => {
|
|
554
|
+
const normalizeClaudeModelId = (model, contextWindowTokens) => {
|
|
555
555
|
const normalized = normalizeOneMillionContextModel(model);
|
|
556
|
-
|
|
556
|
+
if (!normalized) return model;
|
|
557
|
+
const catalog = runtimeState.modelCatalog;
|
|
558
|
+
contextWindowTokens ??= catalog?.baseUrl === runtimeState.upstreamBaseUrl ? catalog?.models.get(normalized)?.limits?.max_context_window_tokens : void 0;
|
|
559
|
+
return contextWindowTokens !== void 0 && contextWindowTokens !== 1e6 ? normalized : `${normalized}[1m]`;
|
|
557
560
|
};
|
|
558
561
|
const normalizeCopilotModelId = (model) => normalizeOneMillionContextModel(model) ?? model;
|
|
559
562
|
const getConfiguredModelRouting = () => runtimeState.modelRouting ?? defaultModelRouting;
|
|
@@ -717,8 +720,8 @@ const normalizeThinkEffort = (value) => {
|
|
|
717
720
|
};
|
|
718
721
|
const normalizeUpstreamTimeoutSeconds = (value) => {
|
|
719
722
|
if (value === void 0) return;
|
|
720
|
-
const timeout =
|
|
721
|
-
if (timeout
|
|
723
|
+
const timeout = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value;
|
|
724
|
+
if (typeof timeout !== "number" || !Number.isSafeInteger(timeout) || timeout < 0) throw new Error("Invalid upstreamTimeoutSeconds: expected a non-negative integer (0 disables the deadline)");
|
|
722
725
|
return timeout;
|
|
723
726
|
};
|
|
724
727
|
const readRawConfig = async () => {
|
|
@@ -844,7 +847,7 @@ const serializeConfig = (config) => [
|
|
|
844
847
|
"# Default upstream thinking/reasoning effort: none, low, medium, high, xhigh, max.",
|
|
845
848
|
`thinkEffort: ${config.thinkEffort}`,
|
|
846
849
|
"",
|
|
847
|
-
"# Max seconds
|
|
850
|
+
"# Max seconds for one request's upstream calls; 0 disables the relay deadline.",
|
|
848
851
|
`upstreamTimeoutSeconds: ${config.upstreamTimeoutSeconds}`,
|
|
849
852
|
"",
|
|
850
853
|
"# Copilot model used for bridge-managed Claude WebSearch. Empty uses gptModel.",
|
|
@@ -1190,11 +1193,11 @@ const knownModelEnvKeys = [
|
|
|
1190
1193
|
"ANTHROPIC_SMALL_FAST_MODEL",
|
|
1191
1194
|
"CLAUDE_CODE_SUBAGENT_MODEL"
|
|
1192
1195
|
];
|
|
1193
|
-
const isRecord$
|
|
1196
|
+
const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1194
1197
|
const hasMalformedModelOverride = (parsed) => {
|
|
1195
1198
|
if (parsed.model !== void 0 && typeof parsed.model !== "string") return true;
|
|
1196
1199
|
const env = parsed.env;
|
|
1197
|
-
if (!isRecord$
|
|
1200
|
+
if (!isRecord$3(env)) return false;
|
|
1198
1201
|
return knownModelEnvKeys.some((key) => env[key] !== void 0 && typeof env[key] !== "string");
|
|
1199
1202
|
};
|
|
1200
1203
|
const hasPrimaryModelOverride = (parsed, env) => typeof parsed.model === "string" || typeof env.ANTHROPIC_MODEL === "string";
|
|
@@ -1221,7 +1224,7 @@ async function applyClaudeConfig(input) {
|
|
|
1221
1224
|
};
|
|
1222
1225
|
if (raw.trim().length > 0) try {
|
|
1223
1226
|
const value = JSON.parse(raw);
|
|
1224
|
-
if (!isRecord$
|
|
1227
|
+
if (!isRecord$3(value) || value.env !== void 0 && !isRecord$3(value.env) || hasMalformedModelOverride(value)) return {
|
|
1225
1228
|
configPath: configPath$1,
|
|
1226
1229
|
changed: false,
|
|
1227
1230
|
created: false
|
|
@@ -1234,15 +1237,18 @@ async function applyClaudeConfig(input) {
|
|
|
1234
1237
|
created: false
|
|
1235
1238
|
};
|
|
1236
1239
|
}
|
|
1237
|
-
const env = isRecord$
|
|
1240
|
+
const env = isRecord$3(parsed.env) ? { ...parsed.env } : {};
|
|
1238
1241
|
const previousBaseUrl = typeof env.ANTHROPIC_BASE_URL === "string" ? env.ANTHROPIC_BASE_URL : void 0;
|
|
1239
1242
|
const hadPrimaryModelOverride = hasPrimaryModelOverride(parsed, env);
|
|
1240
|
-
const
|
|
1243
|
+
const contextWindow = input.gptLimits?.max_context_window_tokens;
|
|
1244
|
+
const claudeGptModel = normalizeClaudeModelId(gptModel, contextWindow);
|
|
1241
1245
|
const shouldSeedTopLevelModel = !hadPrimaryModelOverride;
|
|
1242
|
-
const nextModel = typeof parsed.model === "string" ? normalizeClaudeModelId(parsed.model) : void 0;
|
|
1243
|
-
for (const key of knownModelEnvKeys) if (typeof env[key] === "string") env[key] = normalizeClaudeModelId(env[key]);
|
|
1246
|
+
const nextModel = typeof parsed.model === "string" ? normalizeClaudeModelId(parsed.model, contextWindow) : void 0;
|
|
1247
|
+
for (const key of knownModelEnvKeys) if (typeof env[key] === "string") env[key] = normalizeClaudeModelId(env[key], contextWindow);
|
|
1244
1248
|
env.ANTHROPIC_BASE_URL = baseUrl;
|
|
1245
1249
|
if (typeof env.ANTHROPIC_AUTH_TOKEN !== "string" || !env.ANTHROPIC_AUTH_TOKEN) env.ANTHROPIC_AUTH_TOKEN = "dummy";
|
|
1250
|
+
if (contextWindow !== void 0 && env.CLAUDE_CODE_MAX_CONTEXT_TOKENS === void 0) env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(contextWindow);
|
|
1251
|
+
if (input.maxOutputTokens !== void 0 && env.CLAUDE_CODE_MAX_OUTPUT_TOKENS === void 0) env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(input.maxOutputTokens);
|
|
1246
1252
|
const next = {
|
|
1247
1253
|
...parsed,
|
|
1248
1254
|
...nextModel !== void 0 && { model: nextModel },
|
|
@@ -1389,6 +1395,229 @@ const fetchCopilot = async (provider, path$1, init, options = {}) => {
|
|
|
1389
1395
|
throw lastError;
|
|
1390
1396
|
};
|
|
1391
1397
|
|
|
1398
|
+
//#endregion
|
|
1399
|
+
//#region src/copilot/models.ts
|
|
1400
|
+
const pendingCatalogs = /* @__PURE__ */ new WeakMap();
|
|
1401
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1402
|
+
const isPositiveInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
1403
|
+
function parseModelTokenLimits(value) {
|
|
1404
|
+
if (!isRecord$2(value)) return void 0;
|
|
1405
|
+
const context = value.max_context_window_tokens;
|
|
1406
|
+
const prompt = value.max_prompt_tokens;
|
|
1407
|
+
const output = value.max_output_tokens;
|
|
1408
|
+
const nonStreaming = value.max_non_streaming_output_tokens;
|
|
1409
|
+
if (!isPositiveInteger(context) || !isPositiveInteger(prompt) || !isPositiveInteger(output) || prompt > context || output > context || nonStreaming !== void 0 && (!isPositiveInteger(nonStreaming) || nonStreaming > output)) return;
|
|
1410
|
+
return {
|
|
1411
|
+
max_context_window_tokens: context,
|
|
1412
|
+
max_prompt_tokens: prompt,
|
|
1413
|
+
max_output_tokens: output,
|
|
1414
|
+
...nonStreaming !== void 0 && { max_non_streaming_output_tokens: nonStreaming }
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
function getCachedCopilotModel(config, model) {
|
|
1418
|
+
return config.modelCatalog?.baseUrl === config.copilotBaseUrl ? config.modelCatalog.models.get(normalizeCopilotModelId(model)) : void 0;
|
|
1419
|
+
}
|
|
1420
|
+
async function loadCopilotModelCatalog(config) {
|
|
1421
|
+
const provider = getCopilotProviderContext(config);
|
|
1422
|
+
const pending = pendingCatalogs.get(config);
|
|
1423
|
+
if (pending?.baseUrl === provider.baseUrl) return pending.promise;
|
|
1424
|
+
const promise = (async () => {
|
|
1425
|
+
const signal = createCopilotRequestSignal(void 0, config.upstreamTimeoutMs);
|
|
1426
|
+
const response = await fetchCopilot(provider, "/models", {
|
|
1427
|
+
method: "GET",
|
|
1428
|
+
headers: { accept: "application/json" }
|
|
1429
|
+
}, {
|
|
1430
|
+
signal,
|
|
1431
|
+
timeoutMs: config.upstreamTimeoutMs
|
|
1432
|
+
});
|
|
1433
|
+
if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
|
|
1434
|
+
const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
|
|
1435
|
+
if (!isRecord$2(payload) || !Array.isArray(payload.data)) throw new Error("Copilot /models returned an invalid model catalog.");
|
|
1436
|
+
const models = /* @__PURE__ */ new Map();
|
|
1437
|
+
for (const model of payload.data) {
|
|
1438
|
+
if (!isRecord$2(model) || typeof model.id !== "string" || !model.id) continue;
|
|
1439
|
+
const capabilities = isRecord$2(model.capabilities) ? model.capabilities : void 0;
|
|
1440
|
+
const limits = parseModelTokenLimits(capabilities?.limits);
|
|
1441
|
+
models.set(model.id, {
|
|
1442
|
+
...limits && { limits },
|
|
1443
|
+
...typeof capabilities?.tokenizer === "string" && { tokenizer: capabilities.tokenizer }
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
const catalog = {
|
|
1447
|
+
baseUrl: provider.baseUrl,
|
|
1448
|
+
models
|
|
1449
|
+
};
|
|
1450
|
+
if (config.copilotBaseUrl === provider.baseUrl) {
|
|
1451
|
+
config.modelCatalog = catalog;
|
|
1452
|
+
if (runtimeState.upstreamBaseUrl === provider.baseUrl) runtimeState.modelCatalog = catalog;
|
|
1453
|
+
}
|
|
1454
|
+
return catalog;
|
|
1455
|
+
})();
|
|
1456
|
+
pendingCatalogs.set(config, {
|
|
1457
|
+
baseUrl: provider.baseUrl,
|
|
1458
|
+
promise
|
|
1459
|
+
});
|
|
1460
|
+
try {
|
|
1461
|
+
return await promise;
|
|
1462
|
+
} finally {
|
|
1463
|
+
if (pendingCatalogs.get(config)?.promise === promise) pendingCatalogs.delete(config);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
async function boundModelOutputTokens(config, model, requested) {
|
|
1467
|
+
if (!config.modelCatalog) return requested;
|
|
1468
|
+
if (config.modelCatalog.baseUrl !== config.copilotBaseUrl || !config.modelCatalog.models.has(model)) {
|
|
1469
|
+
await loadCopilotModelCatalog(config);
|
|
1470
|
+
if (config.modelCatalog.baseUrl !== config.copilotBaseUrl) throw new Error("Copilot base URL changed during model discovery; retry the request.");
|
|
1471
|
+
}
|
|
1472
|
+
const limits = getCachedCopilotModel(config, model)?.limits;
|
|
1473
|
+
if (!limits || !isPositiveInteger(requested)) return requested;
|
|
1474
|
+
const bounded = Math.min(requested, limits.max_output_tokens);
|
|
1475
|
+
if (bounded !== requested) log.info(`Model output budget: model=${model} requested=${requested} effective=${bounded}`);
|
|
1476
|
+
return bounded;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
//#endregion
|
|
1480
|
+
//#region src/copilot/stream.ts
|
|
1481
|
+
const accumulateChunks = (chunks) => {
|
|
1482
|
+
const first = chunks[0];
|
|
1483
|
+
const toolCalls = [];
|
|
1484
|
+
let content = "";
|
|
1485
|
+
let reasoning = "";
|
|
1486
|
+
let finishReason = null;
|
|
1487
|
+
let usage;
|
|
1488
|
+
for (const chunk of chunks) {
|
|
1489
|
+
if (chunk.usage) usage = chunk.usage;
|
|
1490
|
+
const choice = chunk.choices[0];
|
|
1491
|
+
if (!choice) continue;
|
|
1492
|
+
if (typeof choice.delta?.content === "string") content += choice.delta.content;
|
|
1493
|
+
const chunkReasoning = choice.delta?.reasoning_text ?? choice.delta?.reasoning_content;
|
|
1494
|
+
if (typeof chunkReasoning === "string") reasoning += chunkReasoning;
|
|
1495
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
1496
|
+
for (const call of choice.delta?.tool_calls ?? []) {
|
|
1497
|
+
const existing = toolCalls[call.index];
|
|
1498
|
+
if (existing) {
|
|
1499
|
+
existing.function.arguments += call.function?.arguments ?? "";
|
|
1500
|
+
continue;
|
|
1501
|
+
}
|
|
1502
|
+
toolCalls[call.index] = {
|
|
1503
|
+
id: call.id ?? `call_${call.index}`,
|
|
1504
|
+
type: "function",
|
|
1505
|
+
function: {
|
|
1506
|
+
name: call.function?.name ?? "",
|
|
1507
|
+
arguments: call.function?.arguments ?? ""
|
|
1508
|
+
}
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
const collectedToolCalls = toolCalls.filter(Boolean);
|
|
1513
|
+
return {
|
|
1514
|
+
id: first?.id ?? "chat_stream_accumulated",
|
|
1515
|
+
object: "chat.completion",
|
|
1516
|
+
created: first?.created ?? 0,
|
|
1517
|
+
model: first?.model ?? "",
|
|
1518
|
+
choices: [{
|
|
1519
|
+
index: 0,
|
|
1520
|
+
message: {
|
|
1521
|
+
role: "assistant",
|
|
1522
|
+
content: content || null,
|
|
1523
|
+
...reasoning ? { reasoning_text: reasoning } : {},
|
|
1524
|
+
...collectedToolCalls.length > 0 ? { tool_calls: collectedToolCalls } : {}
|
|
1525
|
+
},
|
|
1526
|
+
finish_reason: finishReason ?? "stop"
|
|
1527
|
+
}],
|
|
1528
|
+
...usage ? { usage } : {}
|
|
1529
|
+
};
|
|
1530
|
+
};
|
|
1531
|
+
const incompleteStreamError = () => {
|
|
1532
|
+
const message = "Copilot stream ended without a completed response.";
|
|
1533
|
+
return new HTTPError(message, new Response(JSON.stringify({ error: {
|
|
1534
|
+
type: "api_error",
|
|
1535
|
+
message
|
|
1536
|
+
} }), {
|
|
1537
|
+
status: 502,
|
|
1538
|
+
headers: { "content-type": "application/json" }
|
|
1539
|
+
}), message);
|
|
1540
|
+
};
|
|
1541
|
+
async function collectChatCompletionStream(stream, signal, timeoutMs) {
|
|
1542
|
+
const chunks = [];
|
|
1543
|
+
try {
|
|
1544
|
+
for await (const event of stream) {
|
|
1545
|
+
if (event.data === "[DONE]") break;
|
|
1546
|
+
if (!event.data) continue;
|
|
1547
|
+
const chunk = JSON.parse(event.data);
|
|
1548
|
+
if (!chunk || !Array.isArray(chunk.choices)) throw incompleteStreamError();
|
|
1549
|
+
chunks.push(chunk);
|
|
1550
|
+
}
|
|
1551
|
+
if (!chunks.some((chunk) => chunk.choices[0]?.finish_reason)) throw incompleteStreamError();
|
|
1552
|
+
return accumulateChunks(chunks);
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
throw toCopilotAbortHTTPError(error, signal, timeoutMs) ?? error;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
//#endregion
|
|
1559
|
+
//#region src/copilot/tool-schema.ts
|
|
1560
|
+
const schemaMapKeywords = new Set([
|
|
1561
|
+
"$defs",
|
|
1562
|
+
"definitions",
|
|
1563
|
+
"dependencies",
|
|
1564
|
+
"dependentSchemas",
|
|
1565
|
+
"patternProperties",
|
|
1566
|
+
"properties"
|
|
1567
|
+
]);
|
|
1568
|
+
const nestedSchemaKeywords = new Set([
|
|
1569
|
+
"additionalItems",
|
|
1570
|
+
"additionalProperties",
|
|
1571
|
+
"allOf",
|
|
1572
|
+
"anyOf",
|
|
1573
|
+
"contains",
|
|
1574
|
+
"contentSchema",
|
|
1575
|
+
"else",
|
|
1576
|
+
"if",
|
|
1577
|
+
"items",
|
|
1578
|
+
"not",
|
|
1579
|
+
"oneOf",
|
|
1580
|
+
"prefixItems",
|
|
1581
|
+
"propertyNames",
|
|
1582
|
+
"then",
|
|
1583
|
+
"unevaluatedItems",
|
|
1584
|
+
"unevaluatedProperties"
|
|
1585
|
+
]);
|
|
1586
|
+
const lookaroundPrefixes = [
|
|
1587
|
+
"(?=",
|
|
1588
|
+
"(?!",
|
|
1589
|
+
"(?<=",
|
|
1590
|
+
"(?<!"
|
|
1591
|
+
];
|
|
1592
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1593
|
+
function hasUnsupportedPatternFeatures(pattern) {
|
|
1594
|
+
let inCharacterClass = false;
|
|
1595
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
1596
|
+
const character = pattern[index];
|
|
1597
|
+
if (character === "\\") {
|
|
1598
|
+
if ((pattern[index + 1] === "p" || pattern[index + 1] === "P") && pattern[index + 2] === "{") return true;
|
|
1599
|
+
index += 1;
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
if (character === "[") inCharacterClass = true;
|
|
1603
|
+
else if (character === "]") inCharacterClass = false;
|
|
1604
|
+
else if (!inCharacterClass && character === "(" && lookaroundPrefixes.some((prefix) => pattern.startsWith(prefix, index))) return true;
|
|
1605
|
+
}
|
|
1606
|
+
return false;
|
|
1607
|
+
}
|
|
1608
|
+
function normalizeSchemaValue(value) {
|
|
1609
|
+
if (Array.isArray(value)) return value.map(normalizeSchemaValue);
|
|
1610
|
+
return isRecord$1(value) ? normalizeResponsesToolSchema(value) : value;
|
|
1611
|
+
}
|
|
1612
|
+
function normalizeResponsesToolSchema(schema) {
|
|
1613
|
+
return Object.fromEntries(Object.entries(schema).flatMap(([key, value]) => {
|
|
1614
|
+
if (key === "pattern" && typeof value === "string" && hasUnsupportedPatternFeatures(value)) return [];
|
|
1615
|
+
if (schemaMapKeywords.has(key) && isRecord$1(value)) return [[key, Object.fromEntries(Object.entries(value).map(([name, nested]) => [name, normalizeSchemaValue(nested)]))]];
|
|
1616
|
+
if (nestedSchemaKeywords.has(key)) return [[key, normalizeSchemaValue(value)]];
|
|
1617
|
+
return [[key, value]];
|
|
1618
|
+
}));
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1392
1621
|
//#endregion
|
|
1393
1622
|
//#region src/copilot/responses.ts
|
|
1394
1623
|
const responsesOnlyModelPattern = /^(?:gpt-5\.5|gpt-5\.6|gpt-6-astra)(?:-|$)/i;
|
|
@@ -1710,7 +1939,7 @@ function translateTools(tools) {
|
|
|
1710
1939
|
type: "function",
|
|
1711
1940
|
name: tool.function.name,
|
|
1712
1941
|
description: tool.function.description,
|
|
1713
|
-
parameters: tool.function.parameters
|
|
1942
|
+
parameters: normalizeResponsesToolSchema(tool.function.parameters)
|
|
1714
1943
|
}));
|
|
1715
1944
|
}
|
|
1716
1945
|
function translateToolChoice(toolChoice) {
|
|
@@ -1833,9 +2062,15 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
1833
2062
|
const requestedThinking = options.requestedThinking ?? "none";
|
|
1834
2063
|
const signal = createCopilotRequestSignal(options.signal, options.timeoutMs);
|
|
1835
2064
|
const upstreamModelId = routeModelId(payload.model);
|
|
1836
|
-
const
|
|
2065
|
+
const maxTokens = await boundModelOutputTokens(config, upstreamModelId, payload.max_tokens);
|
|
2066
|
+
const nonStreamingLimit = getCachedCopilotModel(config, upstreamModelId)?.limits?.max_non_streaming_output_tokens;
|
|
2067
|
+
const bufferResponse = !payload.stream && typeof maxTokens === "number" && nonStreamingLimit !== void 0 && maxTokens > nonStreamingLimit;
|
|
2068
|
+
const completeResponse = (response$1) => bufferResponse && !("choices" in response$1) ? collectChatCompletionStream(response$1, signal, options.timeoutMs) : response$1;
|
|
2069
|
+
const upstreamPayload = {
|
|
1837
2070
|
...payload,
|
|
1838
|
-
model: upstreamModelId
|
|
2071
|
+
model: upstreamModelId,
|
|
2072
|
+
max_tokens: maxTokens,
|
|
2073
|
+
stream: bufferResponse ? true : payload.stream
|
|
1839
2074
|
};
|
|
1840
2075
|
const useResponsesApi = shouldUseResponsesApiForModel(upstreamPayload.model);
|
|
1841
2076
|
const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill$1(upstreamPayload);
|
|
@@ -1854,13 +2089,13 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
1854
2089
|
`effective_think_effort=${requestPayload.reasoning_effort ?? "none"}`
|
|
1855
2090
|
].join(" "));
|
|
1856
2091
|
log.debug("Full request payload", { payload: requestPayload });
|
|
1857
|
-
if (useResponsesApi) return createResponses(provider, compatiblePayload, {
|
|
2092
|
+
if (useResponsesApi) return completeResponse(await createResponses(provider, compatiblePayload, {
|
|
1858
2093
|
vision: enableVision,
|
|
1859
2094
|
initiator,
|
|
1860
2095
|
requestId: options.requestId,
|
|
1861
2096
|
signal,
|
|
1862
2097
|
timeoutMs: options.timeoutMs
|
|
1863
|
-
});
|
|
2098
|
+
}));
|
|
1864
2099
|
const response = await fetchCopilot(provider, "/chat/completions", {
|
|
1865
2100
|
method: "POST",
|
|
1866
2101
|
headers: {
|
|
@@ -1876,13 +2111,13 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
1876
2111
|
timeoutMs: options.timeoutMs
|
|
1877
2112
|
});
|
|
1878
2113
|
if (!response.ok) {
|
|
1879
|
-
if (await shouldRetryWithResponses(response, signal, options.timeoutMs)) return createResponses(provider, compatiblePayload, {
|
|
2114
|
+
if (await shouldRetryWithResponses(response, signal, options.timeoutMs)) return completeResponse(await createResponses(provider, compatiblePayload, {
|
|
1880
2115
|
vision: enableVision,
|
|
1881
2116
|
initiator,
|
|
1882
2117
|
requestId: options.requestId,
|
|
1883
2118
|
signal,
|
|
1884
2119
|
timeoutMs: options.timeoutMs
|
|
1885
|
-
});
|
|
2120
|
+
}));
|
|
1886
2121
|
throw new HTTPError("Failed to create chat completions", response, await logUpstreamError("Failed to create chat completions", response, {
|
|
1887
2122
|
model: payload.model,
|
|
1888
2123
|
request: requestPayload,
|
|
@@ -1890,7 +2125,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
|
|
|
1890
2125
|
requestId: options.requestId
|
|
1891
2126
|
}, signal, options.timeoutMs));
|
|
1892
2127
|
}
|
|
1893
|
-
if (compatiblePayload.stream) return events(response);
|
|
2128
|
+
if (compatiblePayload.stream) return completeResponse(events(response));
|
|
1894
2129
|
return readCopilotJson(response, signal, options.timeoutMs);
|
|
1895
2130
|
};
|
|
1896
2131
|
async function createResponses(provider, payload, options) {
|
|
@@ -1956,26 +2191,17 @@ async function shouldRetryWithResponses(response, signal, timeoutMs) {
|
|
|
1956
2191
|
|
|
1957
2192
|
//#endregion
|
|
1958
2193
|
//#region src/lib/preflight.ts
|
|
1959
|
-
const fetchUpstreamModelIds = async (config) => {
|
|
1960
|
-
const provider = getCopilotProviderContext(config);
|
|
1961
|
-
const signal = createCopilotRequestSignal(void 0, config.upstreamTimeoutMs);
|
|
1962
|
-
const response = await fetchCopilot(provider, "/models", {
|
|
1963
|
-
method: "GET",
|
|
1964
|
-
headers: { accept: "application/json" }
|
|
1965
|
-
}, {
|
|
1966
|
-
signal,
|
|
1967
|
-
timeoutMs: config.upstreamTimeoutMs
|
|
1968
|
-
});
|
|
1969
|
-
if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
|
|
1970
|
-
const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
|
|
1971
|
-
return new Set((payload.data ?? []).map((model) => model.id).filter((id) => typeof id === "string" && id.length > 0));
|
|
1972
|
-
};
|
|
1973
2194
|
const ensureRequiredModels = async (config) => {
|
|
1974
|
-
const
|
|
2195
|
+
const catalog = await loadCopilotModelCatalog(config);
|
|
1975
2196
|
const requiredModels = getUpstreamModelIds();
|
|
1976
|
-
const missingModels = requiredModels.filter((model) => !
|
|
2197
|
+
const missingModels = requiredModels.filter((model) => !catalog.models.has(model));
|
|
1977
2198
|
if (missingModels.length > 0) throw new Error(`Required Copilot model(s) unavailable upstream: ${missingModels.join(", ")}`);
|
|
1978
2199
|
log.info(`Upstream models available: ${requiredModels.join(", ")}`);
|
|
2200
|
+
for (const model of requiredModels) {
|
|
2201
|
+
const limits = catalog.models.get(model)?.limits;
|
|
2202
|
+
if (limits) log.info(`Model token limits: model=${model} context=${limits.max_context_window_tokens} input=${limits.max_prompt_tokens} output=${limits.max_output_tokens} non_streaming_output=${limits.max_non_streaming_output_tokens ?? limits.max_output_tokens}`);
|
|
2203
|
+
else log.error(`Model token limits unavailable: model=${model}; preserving client budgets without assuming a capacity.`);
|
|
2204
|
+
}
|
|
1979
2205
|
};
|
|
1980
2206
|
const createProbePayload = (model) => ({
|
|
1981
2207
|
model,
|
|
@@ -2729,56 +2955,6 @@ const chunkSettlesDecision = (chunk) => {
|
|
|
2729
2955
|
return choice.delta?.tool_calls?.some((call) => call.function?.name) === true || !!choice.finish_reason;
|
|
2730
2956
|
};
|
|
2731
2957
|
const chunkHasWebSearchCall = (chunk, toolNameMapper, isWebSearchToolName) => chunk.choices[0]?.delta?.tool_calls?.some((call) => isWebSearchName(call.function?.name, toolNameMapper, isWebSearchToolName)) ?? false;
|
|
2732
|
-
const accumulateChunks = (chunks) => {
|
|
2733
|
-
const first = chunks[0];
|
|
2734
|
-
const toolCalls = [];
|
|
2735
|
-
let content = "";
|
|
2736
|
-
let reasoning = "";
|
|
2737
|
-
let finishReason = null;
|
|
2738
|
-
let usage;
|
|
2739
|
-
for (const chunk of chunks) {
|
|
2740
|
-
if (chunk.usage) usage = chunk.usage;
|
|
2741
|
-
const choice = chunk.choices[0];
|
|
2742
|
-
if (!choice) continue;
|
|
2743
|
-
if (typeof choice.delta?.content === "string") content += choice.delta.content;
|
|
2744
|
-
const chunkReasoning = choice.delta?.reasoning_text ?? choice.delta?.reasoning_content;
|
|
2745
|
-
if (typeof chunkReasoning === "string") reasoning += chunkReasoning;
|
|
2746
|
-
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
2747
|
-
for (const call of choice.delta?.tool_calls ?? []) {
|
|
2748
|
-
const existing = toolCalls[call.index];
|
|
2749
|
-
if (existing) {
|
|
2750
|
-
existing.function.arguments += call.function?.arguments ?? "";
|
|
2751
|
-
continue;
|
|
2752
|
-
}
|
|
2753
|
-
toolCalls[call.index] = {
|
|
2754
|
-
id: call.id ?? `call_${call.index}`,
|
|
2755
|
-
type: "function",
|
|
2756
|
-
function: {
|
|
2757
|
-
name: call.function?.name ?? "",
|
|
2758
|
-
arguments: call.function?.arguments ?? ""
|
|
2759
|
-
}
|
|
2760
|
-
};
|
|
2761
|
-
}
|
|
2762
|
-
}
|
|
2763
|
-
const collectedToolCalls = toolCalls.filter(Boolean);
|
|
2764
|
-
return {
|
|
2765
|
-
id: first?.id ?? "chat_stream_accumulated",
|
|
2766
|
-
object: "chat.completion",
|
|
2767
|
-
created: first?.created ?? 0,
|
|
2768
|
-
model: first?.model ?? "",
|
|
2769
|
-
choices: [{
|
|
2770
|
-
index: 0,
|
|
2771
|
-
message: {
|
|
2772
|
-
role: "assistant",
|
|
2773
|
-
content: content || null,
|
|
2774
|
-
...reasoning ? { reasoning_text: reasoning } : {},
|
|
2775
|
-
...collectedToolCalls.length > 0 ? { tool_calls: collectedToolCalls } : {}
|
|
2776
|
-
},
|
|
2777
|
-
finish_reason: finishReason ?? "stop"
|
|
2778
|
-
}],
|
|
2779
|
-
...usage ? { usage } : {}
|
|
2780
|
-
};
|
|
2781
|
-
};
|
|
2782
2958
|
const resolveWebSearchStreamDecision = async (stream, toolNameMapper, isWebSearchToolName, onChunk) => {
|
|
2783
2959
|
const buffered = [];
|
|
2784
2960
|
const iterator = stream[Symbol.asyncIterator]();
|
|
@@ -2860,6 +3036,7 @@ const encodingMap = {
|
|
|
2860
3036
|
p50k_edit: () => import("gpt-tokenizer/encoding/p50k_edit"),
|
|
2861
3037
|
r50k_base: () => import("gpt-tokenizer/encoding/r50k_base")
|
|
2862
3038
|
};
|
|
3039
|
+
const isSupportedTokenizer = (encoding) => Object.hasOwn(encodingMap, encoding);
|
|
2863
3040
|
const encodingCache = /* @__PURE__ */ new Map();
|
|
2864
3041
|
const calculateToolCallsTokens = (toolCalls, encoder, constants) => {
|
|
2865
3042
|
let tokens = 0;
|
|
@@ -2900,13 +3077,12 @@ const getEncodeChatFunction = async (encoding) => {
|
|
|
2900
3077
|
const cached = encodingCache.get(encoding);
|
|
2901
3078
|
if (cached) return cached;
|
|
2902
3079
|
}
|
|
2903
|
-
|
|
2904
|
-
if (!(supported in encodingMap)) {
|
|
3080
|
+
if (!isSupportedTokenizer(encoding)) {
|
|
2905
3081
|
const fallback = await encodingMap.o200k_base();
|
|
2906
3082
|
encodingCache.set(encoding, fallback);
|
|
2907
3083
|
return fallback;
|
|
2908
3084
|
}
|
|
2909
|
-
const mod = await encodingMap[
|
|
3085
|
+
const mod = await encodingMap[encoding]();
|
|
2910
3086
|
encodingCache.set(encoding, mod);
|
|
2911
3087
|
return mod;
|
|
2912
3088
|
};
|
|
@@ -3009,8 +3185,8 @@ const getTokenCount = async (payload, model) => {
|
|
|
3009
3185
|
//#endregion
|
|
3010
3186
|
//#region src/routes/claude.ts
|
|
3011
3187
|
const claudeRoutes = new Hono();
|
|
3012
|
-
const createTokenCountModel = (modelId) => ({
|
|
3013
|
-
capabilities: { tokenizer
|
|
3188
|
+
const createTokenCountModel = (modelId, tokenizer = "o200k_base") => ({
|
|
3189
|
+
capabilities: { tokenizer },
|
|
3014
3190
|
id: modelId
|
|
3015
3191
|
});
|
|
3016
3192
|
const isNonStreamingResponse = (response) => typeof response === "object" && response !== null && Object.hasOwn(response, "choices");
|
|
@@ -3246,12 +3422,20 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
|
|
|
3246
3422
|
};
|
|
3247
3423
|
claudeRoutes.get("/models", (c) => c.json({
|
|
3248
3424
|
object: "list",
|
|
3249
|
-
data: getExposedModelIds().map((id) =>
|
|
3250
|
-
id
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3425
|
+
data: getExposedModelIds().map((id) => {
|
|
3426
|
+
const limits = getCachedCopilotModel(c.get("config"), id)?.limits;
|
|
3427
|
+
return {
|
|
3428
|
+
id,
|
|
3429
|
+
object: "model",
|
|
3430
|
+
created: 0,
|
|
3431
|
+
owned_by: "github-copilot",
|
|
3432
|
+
...limits && {
|
|
3433
|
+
context_window: limits.max_context_window_tokens,
|
|
3434
|
+
max_input_tokens: limits.max_prompt_tokens,
|
|
3435
|
+
max_tokens: limits.max_output_tokens
|
|
3436
|
+
}
|
|
3437
|
+
};
|
|
3438
|
+
})
|
|
3255
3439
|
}));
|
|
3256
3440
|
claudeRoutes.post("/messages", async (c) => {
|
|
3257
3441
|
const config = c.get("config");
|
|
@@ -3301,15 +3485,17 @@ claudeRoutes.post("/messages/count_tokens", async (c) => {
|
|
|
3301
3485
|
const claudePayload = await c.req.json();
|
|
3302
3486
|
const openAIPayload = translateToOpenAI(claudePayload);
|
|
3303
3487
|
const exposedModels = getExposedModelIds();
|
|
3304
|
-
const
|
|
3488
|
+
const upstreamModel = getCachedCopilotModel(c.get("config"), openAIPayload.model);
|
|
3489
|
+
const hasDiscoveredTokenizer = upstreamModel?.tokenizer !== void 0 && isSupportedTokenizer(upstreamModel.tokenizer);
|
|
3490
|
+
const selectedModel = createTokenCountModel(openAIPayload.model === exposedModels[1] ? exposedModels[1] : exposedModels[0], hasDiscoveredTokenizer ? upstreamModel?.tokenizer : void 0);
|
|
3305
3491
|
const tokenCount = await getTokenCount(openAIPayload, selectedModel);
|
|
3306
3492
|
const effectiveModelId = selectedModel.id;
|
|
3307
3493
|
if (claudePayload.tools && claudePayload.tools.length > 0) {
|
|
3308
3494
|
let mcpToolExist = false;
|
|
3309
3495
|
if (claudeBeta?.startsWith("claude-code")) mcpToolExist = claudePayload.tools.some((tool) => tool.name.startsWith("mcp__"));
|
|
3310
|
-
if (!mcpToolExist && effectiveModelId.startsWith("claude")) tokenCount.input += 346;
|
|
3496
|
+
if (!hasDiscoveredTokenizer && !mcpToolExist && effectiveModelId.startsWith("claude")) tokenCount.input += 346;
|
|
3311
3497
|
}
|
|
3312
|
-
const multiplier = effectiveModelId.startsWith("claude") ? 1.15 : 1;
|
|
3498
|
+
const multiplier = !hasDiscoveredTokenizer && effectiveModelId.startsWith("claude") ? 1.15 : 1;
|
|
3313
3499
|
const finalTokenCount = Math.round((tokenCount.input + tokenCount.output) * multiplier);
|
|
3314
3500
|
return c.json({ input_tokens: Math.max(1, finalTokenCount) });
|
|
3315
3501
|
} catch (error) {
|
|
@@ -3429,6 +3615,7 @@ async function startRelay(appConfig) {
|
|
|
3429
3615
|
runtimeState.debug = nextConfig.logLevel === "debug";
|
|
3430
3616
|
runtimeState.thinkEffort = nextConfig.thinkEffort;
|
|
3431
3617
|
config.copilotBaseUrl = nextConfig.copilotBaseUrl;
|
|
3618
|
+
runtimeState.upstreamBaseUrl = nextConfig.copilotBaseUrl;
|
|
3432
3619
|
config.host = nextConfig.host;
|
|
3433
3620
|
config.port = nextConfig.port;
|
|
3434
3621
|
config.upstreamTimeoutMs = nextConfig.upstreamTimeoutSeconds * 1e3;
|
|
@@ -3442,7 +3629,6 @@ async function startRelay(appConfig) {
|
|
|
3442
3629
|
log.info(`Log level: ${appConfig.logLevel}`);
|
|
3443
3630
|
log.info(`Think effort: ${appConfig.thinkEffort}`);
|
|
3444
3631
|
log.info(`Upstream timeout: ${appConfig.upstreamTimeoutSeconds}s`);
|
|
3445
|
-
log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
|
|
3446
3632
|
const authSession = await setupProxyAuth(config);
|
|
3447
3633
|
try {
|
|
3448
3634
|
await validateUpstream(config, appConfig.thinkEffort);
|
|
@@ -3450,6 +3636,7 @@ async function startRelay(appConfig) {
|
|
|
3450
3636
|
log.error("Startup preflight failed:", error);
|
|
3451
3637
|
process.exit(1);
|
|
3452
3638
|
}
|
|
3639
|
+
log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
|
|
3453
3640
|
const server = await startServer(config);
|
|
3454
3641
|
await writeRelayPidFile(config);
|
|
3455
3642
|
log.info(`copilot-relay version: ${appVersion}`);
|
|
@@ -3459,10 +3646,14 @@ async function startRelay(appConfig) {
|
|
|
3459
3646
|
log.info(`copilot base url: ${formatUrlForDisplay(config.copilotBaseUrl)}`);
|
|
3460
3647
|
const baseUrl = `http://${config.host}:${config.port}`;
|
|
3461
3648
|
if (appConfig.claudeSetup) try {
|
|
3649
|
+
const gptLimits = getCachedCopilotModel(config, appConfig.gptModel)?.limits;
|
|
3650
|
+
const opusLimits = getCachedCopilotModel(config, appConfig.opusModel)?.limits;
|
|
3462
3651
|
const claudeResult = await applyClaudeConfig({
|
|
3463
3652
|
baseUrl,
|
|
3464
3653
|
configPath: claudeConfigPath$1,
|
|
3465
|
-
gptModel: appConfig.gptModel
|
|
3654
|
+
gptModel: appConfig.gptModel,
|
|
3655
|
+
gptLimits,
|
|
3656
|
+
maxOutputTokens: gptLimits && opusLimits ? Math.max(gptLimits.max_output_tokens, opusLimits.max_output_tokens) : void 0
|
|
3466
3657
|
});
|
|
3467
3658
|
if (claudeResult.changed) {
|
|
3468
3659
|
log.info(`claude settings ${claudeResult.created ? "created" : "updated"}: ${claudeResult.configPath}`);
|
|
@@ -3650,7 +3841,7 @@ const renderStatus = (status$1) => {
|
|
|
3650
3841
|
const health = status$1.health;
|
|
3651
3842
|
lines.push(health?.ok ? ` health ok (${health.ms}ms)` : ` health FAILED${health?.detail ? ` — ${health.detail}` : ""}`);
|
|
3652
3843
|
lines.push(status$1.models?.length ? ` models ${status$1.models.join(", ")}` : " models unavailable");
|
|
3653
|
-
if (status$1.deep) lines.push(status$1.deep.ok ? ` upstream ok (${status$1.deep.ms}ms) — end-to-end Copilot round trip` : ` upstream FAILED${status$1.deep.detail ? ` — ${status$1.deep.detail}` : ""}`);
|
|
3844
|
+
if (status$1.deep) lines.push(status$1.deep.ok ? ` upstream ok (${status$1.deep.ms}ms) — ${status$1.deep.detail ?? "end-to-end Copilot round trip"}` : ` upstream FAILED${status$1.deep.detail ? ` — ${status$1.deep.detail}` : ""}`);
|
|
3654
3845
|
else lines.push(" upstream not checked (use --deep)");
|
|
3655
3846
|
lines.push(` log ${status$1.logPath}`, ` config ${status$1.configPath}`, ...renderConfig(status$1.config, true));
|
|
3656
3847
|
if (status$1.health?.ok && status$1.deep && !status$1.deep.ok) lines.push("", " The relay is listening but cannot reach Copilot.", " Try: copilot-relay auth");
|
|
@@ -3740,11 +3931,19 @@ const checkDeep = async (baseUrl, model) => {
|
|
|
3740
3931
|
ok: false
|
|
3741
3932
|
};
|
|
3742
3933
|
}
|
|
3743
|
-
const
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3934
|
+
const body = result.body;
|
|
3935
|
+
if (Array.isArray(body?.content)) {
|
|
3936
|
+
if (body.content.length > 0) return {
|
|
3937
|
+
ms: result.ms,
|
|
3938
|
+
ok: true
|
|
3939
|
+
};
|
|
3940
|
+
if (body.type === "message" && body.role === "assistant" && body.stop_reason === "max_tokens" && typeof body.usage?.output_tokens === "number" && Number.isFinite(body.usage.output_tokens) && body.usage.output_tokens > 0) return {
|
|
3941
|
+
detail: "output budget exhausted before visible text",
|
|
3942
|
+
ms: result.ms,
|
|
3943
|
+
ok: true
|
|
3944
|
+
};
|
|
3945
|
+
}
|
|
3946
|
+
return {
|
|
3748
3947
|
detail: "empty response",
|
|
3749
3948
|
ms: result.ms,
|
|
3750
3949
|
ok: false
|