copilot-relay 0.2.2 → 0.2.3
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 +13 -4
- package/config.default.yaml +8 -1
- package/dist/main.js +99 -9
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,7 +36,7 @@ Routing:
|
|
|
36
36
|
|
|
37
37
|
| Requested model | Upstream model |
|
|
38
38
|
| --- | --- |
|
|
39
|
-
| contains `opus` | `claude-opus-
|
|
39
|
+
| contains `opus` | `claude-opus-5` |
|
|
40
40
|
| `gpt-5.6-sol[1m]`, plain `gpt-5.6-sol`, or another non-Opus alias | `gpt-5.6-sol` |
|
|
41
41
|
|
|
42
42
|
The relay advertises `gpt-5.6-sol[1m]` to Claude Code. The `[1m]` selector
|
|
@@ -69,6 +69,7 @@ the top-level `model` field in `~/.claude/settings.json`. Exact
|
|
|
69
69
|
Config lives at `~/.copilot-relay/config.yaml` and is hot-reloaded:
|
|
70
70
|
|
|
71
71
|
```yaml
|
|
72
|
+
configVersion: 2
|
|
72
73
|
host: 127.0.0.1
|
|
73
74
|
port: 4142
|
|
74
75
|
copilotBaseUrl: https://api.githubcopilot.com
|
|
@@ -80,6 +81,10 @@ upstreamTimeoutSeconds: 180
|
|
|
80
81
|
webSearchBackend:
|
|
81
82
|
```
|
|
82
83
|
|
|
84
|
+
`configVersion` is managed by copilot-relay. It gates one-time migrations that
|
|
85
|
+
update a shipped default on installs which already persisted the previous value;
|
|
86
|
+
do not edit it.
|
|
87
|
+
|
|
83
88
|
`logLevel` controls verbosity:
|
|
84
89
|
|
|
85
90
|
| Level | Logs |
|
|
@@ -119,13 +124,17 @@ Upstream failures are logged at `error` with full request and response context i
|
|
|
119
124
|
Unsupported Claude API requests are logged at `error` with the local method/path
|
|
120
125
|
and detailed request payload.
|
|
121
126
|
|
|
122
|
-
Logs are written to `~/.copilot-relay/logs/copilot-relay
|
|
127
|
+
Logs are written to `~/.copilot-relay/logs/copilot-relay.<local-date>.log`. The
|
|
128
|
+
active file rotates at local midnight, and files older than `logRetentionDays`
|
|
129
|
+
local calendar days are deleted. Rotation is what makes retention take effect:
|
|
130
|
+
without it the single log file's mtime was refreshed by every append, so nothing
|
|
131
|
+
ever aged out.
|
|
123
132
|
|
|
124
133
|
Quick inspection:
|
|
125
134
|
|
|
126
135
|
```sh
|
|
127
|
-
tail -f ~/.copilot-relay/logs/copilot-relay.log
|
|
128
|
-
grep -n "Failed to create\\|Startup preflight failed" ~/.copilot-relay/logs/copilot-relay
|
|
136
|
+
tail -f ~/.copilot-relay/logs/copilot-relay.$(date +%F).log
|
|
137
|
+
grep -n "Failed to create\\|Startup preflight failed" ~/.copilot-relay/logs/copilot-relay.*.log
|
|
129
138
|
```
|
|
130
139
|
|
|
131
140
|
See [`docs/troubleshooting.md`](docs/troubleshooting.md) for common debugging workflows.
|
package/config.default.yaml
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
# Most values hot-reload while the service is running. host and port still
|
|
6
6
|
# require restart because the listening socket is already bound.
|
|
7
7
|
|
|
8
|
+
# Config schema version. Managed by copilot-relay; do not edit.
|
|
9
|
+
# Used to apply one-time migrations when a shipped default changes.
|
|
10
|
+
configVersion: 2
|
|
11
|
+
|
|
8
12
|
# Local host for the Claude Code-compatible HTTP server.
|
|
9
13
|
host: 127.0.0.1
|
|
10
14
|
|
|
@@ -29,6 +33,9 @@ claudeSetup: true
|
|
|
29
33
|
logLevel: info
|
|
30
34
|
|
|
31
35
|
# Number of days to keep files in ~/.copilot-relay/logs.
|
|
36
|
+
# Counted in local calendar days: 3 keeps today plus the previous two days.
|
|
37
|
+
# The active log file is copilot-relay.<local-date>.log and rotates at local
|
|
38
|
+
# midnight, which is what makes this setting take effect at all.
|
|
32
39
|
logRetentionDays: 3
|
|
33
40
|
|
|
34
41
|
# Default upstream thinking/reasoning effort sent to Copilot.
|
|
@@ -48,4 +55,4 @@ webSearchBackend:
|
|
|
48
55
|
gptModel: gpt-5.6-sol
|
|
49
56
|
|
|
50
57
|
# Upstream Copilot model used for requests containing "opus".
|
|
51
|
-
opusModel: claude-opus-
|
|
58
|
+
opusModel: claude-opus-5
|
package/dist/main.js
CHANGED
|
@@ -57,8 +57,26 @@ const configPath = path.join(appDir, "config.yaml");
|
|
|
57
57
|
const copilotTokenPath = path.join(appDir, "copilot_token.json");
|
|
58
58
|
const githubTokenPath = path.join(appDir, "github_token");
|
|
59
59
|
const logsDir = path.join(appDir, "logs");
|
|
60
|
-
const
|
|
60
|
+
const logFileBaseName = "copilot-relay";
|
|
61
|
+
const legacyLogPath = path.join(logsDir, `${logFileBaseName}.log`);
|
|
61
62
|
const pidPath = path.join(appDir, "copilot-relay.pid");
|
|
63
|
+
/**
|
|
64
|
+
* Local-date stamp used in log file names.
|
|
65
|
+
*
|
|
66
|
+
* Local rather than UTC on purpose: `logRetentionDays` is a human-facing "how
|
|
67
|
+
* many days do I keep" setting, and a UTC stamp would roll the active file over
|
|
68
|
+
* in the middle of the local afternoon for anyone west of Greenwich.
|
|
69
|
+
*/
|
|
70
|
+
const formatLogDate = (date) => [
|
|
71
|
+
date.getFullYear(),
|
|
72
|
+
String(date.getMonth() + 1).padStart(2, "0"),
|
|
73
|
+
String(date.getDate()).padStart(2, "0")
|
|
74
|
+
].join("-");
|
|
75
|
+
/**
|
|
76
|
+
* Path of the log file that owns `date`. Resolved per write rather than cached,
|
|
77
|
+
* so a long-running relay rotates at local midnight with no timer to drift.
|
|
78
|
+
*/
|
|
79
|
+
const getLogPath = (date = /* @__PURE__ */ new Date()) => path.join(logsDir, `${logFileBaseName}.${formatLogDate(date)}.log`);
|
|
62
80
|
const paths = {
|
|
63
81
|
appDir,
|
|
64
82
|
configPath,
|
|
@@ -70,7 +88,8 @@ const paths = {
|
|
|
70
88
|
path.join(homeDir, ".copilot-tennel.yaml")
|
|
71
89
|
],
|
|
72
90
|
legacyCopilotTokenPaths,
|
|
73
|
-
|
|
91
|
+
legacyLogPath,
|
|
92
|
+
logFileBaseName,
|
|
74
93
|
logsDir,
|
|
75
94
|
pidPath
|
|
76
95
|
};
|
|
@@ -103,8 +122,10 @@ async function ensureFile(filePath, options = {}) {
|
|
|
103
122
|
//#endregion
|
|
104
123
|
//#region src/lib/log.ts
|
|
105
124
|
const logCleanupCheckIntervalMs = 3600 * 1e3;
|
|
125
|
+
const millisecondsPerDay = 1440 * 60 * 1e3;
|
|
106
126
|
let logRetentionDays = 3;
|
|
107
127
|
let nextLogCleanupCheckAt = 0;
|
|
128
|
+
const datedLogFilePattern = /* @__PURE__ */ new RegExp(`^${paths.logFileBaseName}\\.(\\d{4})-(\\d{2})-(\\d{2})\\.log$`);
|
|
108
129
|
const consolaLevelByName = {
|
|
109
130
|
error: 0,
|
|
110
131
|
info: 3,
|
|
@@ -120,7 +141,22 @@ const setLogLevel = (level) => {
|
|
|
120
141
|
currentLogLevel = consolaLevelByName[level];
|
|
121
142
|
consola.level = consolaLevelByName[level];
|
|
122
143
|
};
|
|
123
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Render one logged value as a single physical line.
|
|
146
|
+
*
|
|
147
|
+
* `breakLength: Infinity` matters as much as the depth cap. The previous
|
|
148
|
+
* `inspect(value, { depth: null })` pretty-printed each payload across
|
|
149
|
+
* thousands of physical lines, which was both the dominant source of log volume
|
|
150
|
+
* and the reason the `grep` recipes in docs/logging.md returned a fragment of an
|
|
151
|
+
* object instead of the matching entry. One entry is now one line.
|
|
152
|
+
*/
|
|
153
|
+
const formatLogValue = (value) => typeof value === "string" ? value : inspect(value, {
|
|
154
|
+
breakLength: Infinity,
|
|
155
|
+
compact: true,
|
|
156
|
+
depth: 6,
|
|
157
|
+
maxArrayLength: 100,
|
|
158
|
+
maxStringLength: 4e3
|
|
159
|
+
});
|
|
124
160
|
const writeLogFile = async (level, values) => {
|
|
125
161
|
await fs.mkdir(paths.logsDir, { recursive: true });
|
|
126
162
|
await cleanupLogsIfDue();
|
|
@@ -129,7 +165,7 @@ const writeLogFile = async (level, values) => {
|
|
|
129
165
|
level,
|
|
130
166
|
values.map(formatLogValue).join(" ")
|
|
131
167
|
].join(" ");
|
|
132
|
-
await fs.appendFile(
|
|
168
|
+
await fs.appendFile(getLogPath(), `${line}\n`);
|
|
133
169
|
};
|
|
134
170
|
const cleanupLogsIfDue = async () => {
|
|
135
171
|
const now = Date.now();
|
|
@@ -144,14 +180,31 @@ const wrapFileLog = (level, fn) => ((...args) => {
|
|
|
144
180
|
consola.error = wrapFileLog("error", consola.error.bind(consola));
|
|
145
181
|
consola.info = wrapFileLog("info", consola.info.bind(consola));
|
|
146
182
|
consola.debug = wrapFileLog("debug", consola.debug.bind(consola));
|
|
183
|
+
/**
|
|
184
|
+
* Calendar day a log file belongs to, or undefined when its name carries no
|
|
185
|
+
* date stamp.
|
|
186
|
+
*
|
|
187
|
+
* The filename stamp is preferred over mtime because mtime is rewritten by
|
|
188
|
+
* backups, `cp`, and editors touching the file, any of which would silently
|
|
189
|
+
* extend or shorten the retention window.
|
|
190
|
+
*/
|
|
191
|
+
const parseLogFileDate = (fileName) => {
|
|
192
|
+
const match = datedLogFilePattern.exec(fileName);
|
|
193
|
+
if (!match) return;
|
|
194
|
+
const [, year, month, day] = match;
|
|
195
|
+
const date = new Date(Number(year), Number(month) - 1, Number(day));
|
|
196
|
+
return date.getFullYear() === Number(year) && date.getMonth() === Number(month) - 1 && date.getDate() === Number(day) ? date : void 0;
|
|
197
|
+
};
|
|
147
198
|
const cleanupLogs = async (retentionDays) => {
|
|
148
199
|
logRetentionDays = retentionDays;
|
|
149
200
|
await fs.mkdir(paths.logsDir, { recursive: true });
|
|
150
|
-
const
|
|
201
|
+
const startOfToday = /* @__PURE__ */ new Date();
|
|
202
|
+
startOfToday.setHours(0, 0, 0, 0);
|
|
203
|
+
const cutoff = startOfToday.getTime() - (retentionDays - 1) * millisecondsPerDay;
|
|
151
204
|
const entries = await fs.readdir(paths.logsDir, { withFileTypes: true });
|
|
152
205
|
await Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith(".log")).map(async (entry) => {
|
|
153
206
|
const filePath = `${paths.logsDir}/${entry.name}`;
|
|
154
|
-
if ((await fs.stat(filePath)).mtimeMs < cutoff) await fs.
|
|
207
|
+
if ((parseLogFileDate(entry.name)?.getTime() ?? (await fs.stat(filePath)).mtimeMs) < cutoff) await fs.rm(filePath, { force: true });
|
|
155
208
|
}));
|
|
156
209
|
};
|
|
157
210
|
const log = consola;
|
|
@@ -346,7 +399,7 @@ const runtimeState = {};
|
|
|
346
399
|
const defaultReasoningEffort = "max";
|
|
347
400
|
const defaultModelRouting = {
|
|
348
401
|
gptModel: "gpt-5.6-sol",
|
|
349
|
-
opusModel: "claude-opus-
|
|
402
|
+
opusModel: "claude-opus-5"
|
|
350
403
|
};
|
|
351
404
|
const isReasoningEffort = (value) => value === "none" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max";
|
|
352
405
|
const exactGpt56SolPattern = /^gpt-5\.6-sol(?:\[1m\])*$/i;
|
|
@@ -382,14 +435,31 @@ const logLevels = [
|
|
|
382
435
|
"info",
|
|
383
436
|
"debug"
|
|
384
437
|
];
|
|
438
|
+
/**
|
|
439
|
+
* Schema version of ~/.copilot-relay/config.yaml.
|
|
440
|
+
*
|
|
441
|
+
* Bumped whenever a shipped default changes in a way that must reach installs
|
|
442
|
+
* that already persisted the previous value. See supersededOpusModels.
|
|
443
|
+
*/
|
|
444
|
+
const currentConfigVersion = 2;
|
|
445
|
+
/**
|
|
446
|
+
* Opus defaults shipped by earlier releases.
|
|
447
|
+
*
|
|
448
|
+
* readAppConfig() writes the resolved config back to disk, so every install
|
|
449
|
+
* that ever ran an older build has the superseded default sitting in its
|
|
450
|
+
* config.yaml. Without this set, bumping defaultConfig.opusModel would only
|
|
451
|
+
* ever reach fresh installs.
|
|
452
|
+
*/
|
|
453
|
+
const supersededOpusModels = new Set(["claude-opus-4.8"]);
|
|
385
454
|
const defaultConfig = {
|
|
386
455
|
claudeSetup: true,
|
|
456
|
+
configVersion: currentConfigVersion,
|
|
387
457
|
copilotBaseUrl: "https://api.githubcopilot.com",
|
|
388
458
|
gptModel: "gpt-5.6-sol",
|
|
389
459
|
host: "127.0.0.1",
|
|
390
460
|
logLevel: "info",
|
|
391
461
|
logRetentionDays: 3,
|
|
392
|
-
opusModel: "claude-opus-
|
|
462
|
+
opusModel: "claude-opus-5",
|
|
393
463
|
port: 4142,
|
|
394
464
|
thinkEffort: defaultReasoningEffort,
|
|
395
465
|
upstreamTimeoutSeconds: 180,
|
|
@@ -473,6 +543,10 @@ const parseConfigYaml = (content) => {
|
|
|
473
543
|
case "claude_setup":
|
|
474
544
|
config.claudeSetup = unquoteYamlScalar(value);
|
|
475
545
|
break;
|
|
546
|
+
case "configVersion":
|
|
547
|
+
case "config_version":
|
|
548
|
+
config.configVersion = unquoteYamlScalar(value);
|
|
549
|
+
break;
|
|
476
550
|
case "copilotBaseUrl":
|
|
477
551
|
case "copilot_base_url":
|
|
478
552
|
config.copilotBaseUrl = unquoteYamlScalar(value);
|
|
@@ -527,6 +601,9 @@ const serializeConfig = (config) => [
|
|
|
527
601
|
"#",
|
|
528
602
|
"# This file is hot-reloaded while copilot-relay is running.",
|
|
529
603
|
"",
|
|
604
|
+
"# Config schema version. Managed by copilot-relay; do not edit.",
|
|
605
|
+
`configVersion: ${config.configVersion}`,
|
|
606
|
+
"",
|
|
530
607
|
"# Local host for the Claude Code-compatible HTTP server.",
|
|
531
608
|
`host: ${config.host}`,
|
|
532
609
|
"",
|
|
@@ -568,6 +645,17 @@ const serializeConfig = (config) => [
|
|
|
568
645
|
`opusModel: ${config.opusModel}`,
|
|
569
646
|
""
|
|
570
647
|
].join("\n");
|
|
648
|
+
/**
|
|
649
|
+
* Resolve opusModel, applying the one-time superseded-default migration.
|
|
650
|
+
*
|
|
651
|
+
* Gated on configVersion so it runs exactly once. Without that gate the rewrite
|
|
652
|
+
* would re-run on every start, which would make a superseded model impossible
|
|
653
|
+
* to pin deliberately - the user's edit would be reverted underneath them.
|
|
654
|
+
*/
|
|
655
|
+
const resolveOpusModel = (rawOpusModel, configVersion) => {
|
|
656
|
+
if (rawOpusModel === void 0) return defaultConfig.opusModel;
|
|
657
|
+
return configVersion < currentConfigVersion && supersededOpusModels.has(rawOpusModel) ? defaultConfig.opusModel : rawOpusModel;
|
|
658
|
+
};
|
|
571
659
|
async function readAppConfig() {
|
|
572
660
|
const raw = await readRawConfig();
|
|
573
661
|
const claudeSetup = normalizeBoolean(raw.claudeSetup);
|
|
@@ -577,14 +665,16 @@ async function readAppConfig() {
|
|
|
577
665
|
const port = normalizePort(raw.port);
|
|
578
666
|
const thinkEffort = normalizeThinkEffort(raw.thinkEffort);
|
|
579
667
|
const upstreamTimeoutSeconds = normalizeUpstreamTimeoutSeconds(raw.upstreamTimeoutSeconds);
|
|
668
|
+
const configVersion = normalizePositiveInteger(raw.configVersion) ?? 1;
|
|
580
669
|
const config = {
|
|
581
670
|
claudeSetup: claudeSetup ?? defaultConfig.claudeSetup,
|
|
671
|
+
configVersion: currentConfigVersion,
|
|
582
672
|
copilotBaseUrl: normalizeString(raw.copilotBaseUrl) ?? defaultConfig.copilotBaseUrl,
|
|
583
673
|
gptModel: normalizeString(raw.gptModel) ?? defaultConfig.gptModel,
|
|
584
674
|
host: host ?? defaultConfig.host,
|
|
585
675
|
logLevel: logLevel ?? defaultConfig.logLevel,
|
|
586
676
|
logRetentionDays: logRetentionDays$1 ?? defaultConfig.logRetentionDays,
|
|
587
|
-
opusModel: normalizeString(raw.opusModel)
|
|
677
|
+
opusModel: resolveOpusModel(normalizeString(raw.opusModel), configVersion),
|
|
588
678
|
port: port ?? defaultConfig.port,
|
|
589
679
|
thinkEffort: thinkEffort ?? defaultConfig.thinkEffort,
|
|
590
680
|
upstreamTimeoutSeconds: upstreamTimeoutSeconds ?? defaultConfig.upstreamTimeoutSeconds,
|