copilot-relay 0.3.2 → 0.3.4
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 +27 -17
- package/config.default.yaml +1 -1
- package/dist/main.js +252 -19
- package/dist/main.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -49,13 +49,14 @@ Routing:
|
|
|
49
49
|
| Requested model | Upstream model |
|
|
50
50
|
| --- | --- |
|
|
51
51
|
| contains `opus` | `claude-opus-5` |
|
|
52
|
-
| `gpt-
|
|
52
|
+
| `gpt-6-astra[1m]`, plain `gpt-6-astra`, or another non-Opus alias | `gpt-6-astra` |
|
|
53
53
|
|
|
54
|
-
The
|
|
55
|
-
changes Claude Code's client-side context budgeting
|
|
56
|
-
|
|
57
|
-
capacity.
|
|
58
|
-
|
|
54
|
+
The fresh-install default is `gpt-6-astra`, exposed to Claude Code as
|
|
55
|
+
`gpt-6-astra[1m]`. The selector changes Claude Code's client-side context budgeting
|
|
56
|
+
only; Copilot receives the canonical `gpt-6-astra` ID. It does not enlarge
|
|
57
|
+
upstream capacity. Existing `config.yaml` model choices are never migrated.
|
|
58
|
+
If your account cannot use Astra, startup fails explicitly; choose a model your
|
|
59
|
+
account supports rather than relying on an automatic fallback.
|
|
59
60
|
|
|
60
61
|
Claude Code can still show its built-in Haiku and Sonnet picker entries. The
|
|
61
62
|
managed `model` field sets the startup default but does not restrict
|
|
@@ -73,8 +74,8 @@ npx copilot-relay@latest stop
|
|
|
73
74
|
With `claudeSetup: true`, `start` manages `ANTHROPIC_BASE_URL`, a dummy
|
|
74
75
|
`ANTHROPIC_AUTH_TOKEN` when auth is absent, and the configured GPT default via
|
|
75
76
|
the top-level `model` field in `~/.claude/settings.json`. Exact
|
|
76
|
-
`gpt-5.6-sol`
|
|
77
|
-
`
|
|
77
|
+
`gpt-6-astra` and `gpt-5.6-sol` overrides are normalized to Claude-facing
|
|
78
|
+
`[1m]` identities; unrelated model choices are preserved.
|
|
78
79
|
|
|
79
80
|
## Config
|
|
80
81
|
|
|
@@ -90,12 +91,16 @@ logRetentionDays: 3
|
|
|
90
91
|
thinkEffort: max
|
|
91
92
|
upstreamTimeoutSeconds: 180
|
|
92
93
|
webSearchBackend:
|
|
94
|
+
gptModel: gpt-6-astra
|
|
95
|
+
opusModel: claude-opus-5
|
|
93
96
|
```
|
|
94
97
|
|
|
95
98
|
copilot-relay writes the resolved config back to this file, so every key is
|
|
96
99
|
present after the first start. Shipped defaults therefore apply to fresh
|
|
97
100
|
installs only — once a value is in your `config.yaml` it is never rewritten by
|
|
98
|
-
an upgrade. To pick up a changed default, edit the key yourself.
|
|
101
|
+
an upgrade. To pick up a changed default, edit the key yourself. See
|
|
102
|
+
[Configuration](wiki/EN-Configuration.md) / [配置说明](wiki/ZH-Configuration.md)
|
|
103
|
+
for live model discovery, compatible thinking efforts, and copy-paste updates.
|
|
99
104
|
|
|
100
105
|
`logLevel` controls verbosity:
|
|
101
106
|
|
|
@@ -137,7 +142,7 @@ copilot-relay 0.2.5
|
|
|
137
142
|
version 0.2.5
|
|
138
143
|
listening http://127.0.0.1:4142
|
|
139
144
|
health ok (9ms)
|
|
140
|
-
models gpt-
|
|
145
|
+
models gpt-6-astra[1m], claude-opus-5
|
|
141
146
|
upstream not checked (use --deep)
|
|
142
147
|
log ~/.copilot-relay/logs/copilot-relay.2026-07-25.log
|
|
143
148
|
config ~/.copilot-relay/config.yaml
|
|
@@ -150,7 +155,7 @@ copilot-relay 0.2.5
|
|
|
150
155
|
thinkEffort max
|
|
151
156
|
upstreamTimeoutSeconds 180
|
|
152
157
|
webSearchBackend (unset — uses gptModel)
|
|
153
|
-
gptModel gpt-
|
|
158
|
+
gptModel gpt-6-astra
|
|
154
159
|
opusModel claude-opus-5
|
|
155
160
|
host, port and claudeSetup take effect on restart; the rest hot-reload.
|
|
156
161
|
```
|
|
@@ -211,7 +216,8 @@ tail -f ~/.copilot-relay/logs/copilot-relay.$(date +%F).log
|
|
|
211
216
|
grep -n "Failed to create\\|Startup preflight failed" ~/.copilot-relay/logs/copilot-relay.*.log
|
|
212
217
|
```
|
|
213
218
|
|
|
214
|
-
See [
|
|
219
|
+
See [Logs and troubleshooting](wiki/EN-Logging-Troubleshooting.md)
|
|
220
|
+
([中文](wiki/ZH-Logging-Troubleshooting.md)) for common debugging workflows.
|
|
215
221
|
|
|
216
222
|
## Running it as a service
|
|
217
223
|
|
|
@@ -225,11 +231,15 @@ which is generated from `wiki/` on every merge. Edit the folder, not the tab.
|
|
|
225
231
|
|
|
226
232
|
## Development
|
|
227
233
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
234
|
+
All documentation lives in [`wiki/`](wiki/), in English and 中文:
|
|
235
|
+
|
|
236
|
+
| Page | Covers |
|
|
237
|
+
| --- | --- |
|
|
238
|
+
| [Architecture](wiki/EN-Architecture.md) ([中文](wiki/ZH-Architecture.md)) | Modules, request and startup flow, public API, runtime files |
|
|
239
|
+
| [Internals](wiki/EN-Internals.md) ([中文](wiki/ZH-Internals.md)) | Translation, streaming, prompt caching, lifecycle, logging invariants |
|
|
240
|
+
| [Development](wiki/EN-Development.md) ([中文](wiki/ZH-Development.md)) | Setup, tests, CI matrix, workflow, releasing |
|
|
241
|
+
| [Configuration](wiki/EN-Configuration.md) ([中文](wiki/ZH-Configuration.md)) | Every config key, hot reload vs restart |
|
|
242
|
+
| [Logs and troubleshooting](wiki/EN-Logging-Troubleshooting.md) ([中文](wiki/ZH-Logging-Troubleshooting.md)) | Log format, grep recipes, failure modes |
|
|
233
243
|
|
|
234
244
|
```sh
|
|
235
245
|
npm install
|
package/config.default.yaml
CHANGED
|
@@ -52,7 +52,7 @@ webSearchBackend:
|
|
|
52
52
|
# Model routing: requests containing "opus" use opusModel; all others use gptModel.
|
|
53
53
|
#
|
|
54
54
|
# Upstream Copilot model used for non-Opus requests.
|
|
55
|
-
gptModel: gpt-
|
|
55
|
+
gptModel: gpt-6-astra
|
|
56
56
|
|
|
57
57
|
# Upstream Copilot model used for requests containing "opus".
|
|
58
58
|
opusModel: claude-opus-5
|
package/dist/main.js
CHANGED
|
@@ -119,6 +119,124 @@ async function ensureFile(filePath, options = {}) {
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/lib/redact.ts
|
|
124
|
+
/**
|
|
125
|
+
* Origins known to carry a secret somewhere after the origin.
|
|
126
|
+
*
|
|
127
|
+
* Process-lifetime and append-only. Never pruned, because config hot-reload can
|
|
128
|
+
* change copilotBaseUrl while a request against the previous one is still in
|
|
129
|
+
* flight; dropping the old policy would un-redact that request's error on its
|
|
130
|
+
* way to the log.
|
|
131
|
+
*/
|
|
132
|
+
const sensitiveOrigins = /* @__PURE__ */ new Set();
|
|
133
|
+
/** Marker written in place of everything after the origin. */
|
|
134
|
+
const redactedMarker = "[redacted]";
|
|
135
|
+
/**
|
|
136
|
+
* ANSI escape sequences: CSI (`ESC [ … final`), OSC (`ESC ] … BEL/ST`), and the
|
|
137
|
+
* two-byte Fe forms. Matched whole rather than stripping the ESC byte alone,
|
|
138
|
+
* which would leave the printable remainder (`[2K`) in the output.
|
|
139
|
+
*
|
|
140
|
+
* Written with \x escapes rather than literal bytes: an invisible ESC in the
|
|
141
|
+
* source survives no round trip through an editor, a diff view, or a paste.
|
|
142
|
+
*/
|
|
143
|
+
const ansiEscapePattern = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][\s\S]*?(?:\x07|\x1B\\)|[@-Z\\-_])/g;
|
|
144
|
+
/** C0 controls and DEL, including CR/LF: one config value is one row. */
|
|
145
|
+
const controlBytePattern = /[\x00-\x1F\x7F]/g;
|
|
146
|
+
/**
|
|
147
|
+
* Absolute http(s) URLs inside arbitrary text.
|
|
148
|
+
*
|
|
149
|
+
* Stops at whitespace, quotes, backticks and angle brackets so a URL embedded
|
|
150
|
+
* in inspect() output — `url: 'https://host/x'` — matches without swallowing
|
|
151
|
+
* the surrounding punctuation.
|
|
152
|
+
*
|
|
153
|
+
* Backslashes are consumed rather than terminating the match. WHATWG folds `\`
|
|
154
|
+
* into `/` for http(s), so `https://host\tenant\TOKEN` parses with the tail in
|
|
155
|
+
* the path, carries no userinfo, and is therefore accepted and stored verbatim
|
|
156
|
+
* by normalizeCopilotBaseUrl — and inspect() doubles each backslash on the way
|
|
157
|
+
* to both sinks. Stopping at the first one matched only the origin, which then
|
|
158
|
+
* looked origin-only and left the entire tail in the log.
|
|
159
|
+
*/
|
|
160
|
+
const absoluteUrlPattern = /https?:\/\/[^\s'"`<>]+/gi;
|
|
161
|
+
const parseHttpUrl = (value) => {
|
|
162
|
+
let parsed;
|
|
163
|
+
try {
|
|
164
|
+
parsed = new URL(value);
|
|
165
|
+
} catch {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed : void 0;
|
|
169
|
+
};
|
|
170
|
+
/** True when the URL carries nothing after the origin worth hiding. */
|
|
171
|
+
const isOriginOnly = (parsed) => (parsed.pathname === "" || parsed.pathname === "/") && !parsed.search && !parsed.hash;
|
|
172
|
+
/**
|
|
173
|
+
* Renders a URL for display, keeping the origin and hiding the rest.
|
|
174
|
+
*
|
|
175
|
+
* The origin is the operationally useful half — it answers "which gateway am I
|
|
176
|
+
* talking to" — while path, query and fragment are where a credential ends up.
|
|
177
|
+
* An explicit marker rather than silent truncation, so a reader can tell the
|
|
178
|
+
* difference between a base URL that has no path and one whose path was
|
|
179
|
+
* withheld.
|
|
180
|
+
*
|
|
181
|
+
* Never throws: it also runs on values that failed validation.
|
|
182
|
+
*/
|
|
183
|
+
const formatUrlForDisplay = (raw) => {
|
|
184
|
+
const parsed = parseHttpUrl(raw);
|
|
185
|
+
if (!parsed) return "(invalid url)";
|
|
186
|
+
return isOriginOnly(parsed) ? raw : `${parsed.origin} (path/query/fragment hidden)`;
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Strips ANSI escapes and control bytes from a string bound for the terminal.
|
|
190
|
+
*
|
|
191
|
+
* `status` prints config values directly to stdout. A config string carrying
|
|
192
|
+
* raw control bytes could clear the line and paint status rows the relay never
|
|
193
|
+
* produced. The config file is user-owned, so this is hardening rather than a
|
|
194
|
+
* vulnerability — but it costs nothing to make the output non-forgeable. #47.
|
|
195
|
+
*/
|
|
196
|
+
const sanitizeTerminalString = (text) => text.replace(ansiEscapePattern, "").replace(controlBytePattern, "");
|
|
197
|
+
/**
|
|
198
|
+
* Marks a URL's origin as secret-bearing, if it has anything after the origin.
|
|
199
|
+
*
|
|
200
|
+
* An origin-only URL registers nothing: there is no tail to hide, and marking
|
|
201
|
+
* the origin would redact every unrelated URL on that host for no benefit.
|
|
202
|
+
*
|
|
203
|
+
* Additive and idempotent. Never throws on an unparseable value.
|
|
204
|
+
*/
|
|
205
|
+
const registerSensitiveOrigin = (raw) => {
|
|
206
|
+
const parsed = parseHttpUrl(raw);
|
|
207
|
+
if (!parsed || isOriginOnly(parsed)) return;
|
|
208
|
+
sensitiveOrigins.add(parsed.origin);
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Rewrites URLs on registered origins to `origin[redacted]`.
|
|
212
|
+
*
|
|
213
|
+
* Anchored on the parsed origin rather than on the configured string, which is
|
|
214
|
+
* what makes the variants match: fetch reports back a canonicalized URL (host
|
|
215
|
+
* case-folded, default port dropped) with the fragment already stripped, and
|
|
216
|
+
* clients append their own endpoint to the base. All of those share an origin.
|
|
217
|
+
*
|
|
218
|
+
* Deliberately not a substring substitution of the secret path. Replacing that
|
|
219
|
+
* text wherever it appeared would also rewrite unrelated prose and detached
|
|
220
|
+
* paths; only an absolute URL on a registered origin is rewritten here.
|
|
221
|
+
*/
|
|
222
|
+
const scrubSensitiveUrls = (text) => {
|
|
223
|
+
if (sensitiveOrigins.size === 0) return text;
|
|
224
|
+
return text.replace(absoluteUrlPattern, (match) => {
|
|
225
|
+
const parsed = parseHttpUrl(match);
|
|
226
|
+
if (!parsed || !sensitiveOrigins.has(parsed.origin)) return match;
|
|
227
|
+
const authorityStart = match.indexOf("://") + 3;
|
|
228
|
+
let authorityEnd = match.length;
|
|
229
|
+
for (let index = authorityStart; index < match.length; index += 1) {
|
|
230
|
+
const character = match[index];
|
|
231
|
+
if (character === "/" || character === "\\" || character === "?" || character === "#") {
|
|
232
|
+
authorityEnd = index;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return authorityEnd === match.length ? match : `${parsed.origin}${redactedMarker}`;
|
|
237
|
+
});
|
|
238
|
+
};
|
|
239
|
+
|
|
122
240
|
//#endregion
|
|
123
241
|
//#region src/lib/log.ts
|
|
124
242
|
const logCleanupCheckIntervalMs = 3600 * 1e3;
|
|
@@ -147,8 +265,9 @@ const setLogLevel = (level) => {
|
|
|
147
265
|
* `breakLength: Infinity` matters as much as the depth cap. The previous
|
|
148
266
|
* `inspect(value, { depth: null })` pretty-printed each payload across
|
|
149
267
|
* thousands of physical lines, which was both the dominant source of log volume
|
|
150
|
-
* and the reason the `grep` recipes in
|
|
151
|
-
* object instead of the matching entry. One entry is
|
|
268
|
+
* and the reason the `grep` recipes in wiki/EN-Logging-Troubleshooting.md
|
|
269
|
+
* returned a fragment of an object instead of the matching entry. One entry is
|
|
270
|
+
* now one line.
|
|
152
271
|
*/
|
|
153
272
|
const formatLogValue = (value) => typeof value === "string" ? value : inspect(value, {
|
|
154
273
|
breakLength: Infinity,
|
|
@@ -157,13 +276,21 @@ const formatLogValue = (value) => typeof value === "string" ? value : inspect(va
|
|
|
157
276
|
maxArrayLength: 100,
|
|
158
277
|
maxStringLength: 4e3
|
|
159
278
|
});
|
|
279
|
+
/**
|
|
280
|
+
* Appends one already-rendered entry.
|
|
281
|
+
*
|
|
282
|
+
* Takes strings rather than raw values: rendering happens once in wrapFileLog,
|
|
283
|
+
* so the console and the file are guaranteed to carry the same redacted text.
|
|
284
|
+
* Inspecting again here would reintroduce the gap this closes - the file would
|
|
285
|
+
* be scrubbed while the console showed the original object. See #47.
|
|
286
|
+
*/
|
|
160
287
|
const writeLogFile = async (level, values) => {
|
|
161
288
|
await fs.mkdir(paths.logsDir, { recursive: true });
|
|
162
289
|
await cleanupLogsIfDue();
|
|
163
290
|
const line = [
|
|
164
291
|
(/* @__PURE__ */ new Date()).toISOString(),
|
|
165
292
|
level,
|
|
166
|
-
values.
|
|
293
|
+
values.join(" ")
|
|
167
294
|
].join(" ");
|
|
168
295
|
await fs.appendFile(getLogPath(), `${line}\n`);
|
|
169
296
|
};
|
|
@@ -173,9 +300,27 @@ const cleanupLogsIfDue = async () => {
|
|
|
173
300
|
nextLogCleanupCheckAt = now + logCleanupCheckIntervalMs;
|
|
174
301
|
await cleanupLogs(logRetentionDays);
|
|
175
302
|
};
|
|
303
|
+
/**
|
|
304
|
+
* The single boundary where every logged value becomes redacted text.
|
|
305
|
+
*
|
|
306
|
+
* Each argument is rendered exactly once and scrubbed once, and the identical
|
|
307
|
+
* array of strings goes to both the file and the console. Scrubbing only on the
|
|
308
|
+
* way to disk would leave the original object on the console - the screen a
|
|
309
|
+
* user screenshots into an issue - so the two sinks must not diverge. See #47.
|
|
310
|
+
*
|
|
311
|
+
* Rendering is skipped only when neither sink would emit, so the cost matches
|
|
312
|
+
* the old behavior at info level. The console gate is read from consola rather
|
|
313
|
+
* than assumed equal to currentLogLevel: if the two ever diverge, rendering
|
|
314
|
+
* must follow whichever sink is still emitting, or that sink gets raw values.
|
|
315
|
+
*/
|
|
176
316
|
const wrapFileLog = (level, fn) => ((...args) => {
|
|
177
|
-
|
|
178
|
-
|
|
317
|
+
const methodLevel = fileLevelByMethod[level] ?? consolaLevelByName.info;
|
|
318
|
+
const writesToFile = methodLevel <= currentLogLevel;
|
|
319
|
+
const writesToConsole = methodLevel <= consola.level;
|
|
320
|
+
if (!writesToFile && !writesToConsole) return fn(...args);
|
|
321
|
+
const rendered = args.map((value) => scrubSensitiveUrls(formatLogValue(value)));
|
|
322
|
+
if (writesToFile) writeLogFile(level, rendered).catch(() => void 0);
|
|
323
|
+
return fn(...rendered);
|
|
179
324
|
});
|
|
180
325
|
consola.error = wrapFileLog("error", consola.error.bind(consola));
|
|
181
326
|
consola.info = wrapFileLog("info", consola.info.bind(consola));
|
|
@@ -398,15 +543,19 @@ const runtimeState = {};
|
|
|
398
543
|
//#region src/lib/models.ts
|
|
399
544
|
const defaultReasoningEffort = "max";
|
|
400
545
|
const defaultModelRouting = {
|
|
401
|
-
gptModel: "gpt-
|
|
546
|
+
gptModel: "gpt-6-astra",
|
|
402
547
|
opusModel: "claude-opus-5"
|
|
403
548
|
};
|
|
404
549
|
const isReasoningEffort = (value) => value === "none" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max";
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
550
|
+
const oneMillionContextModelPattern = /^(gpt-5\.6-sol|gpt-6-astra)(?:\[1m\])*$/i;
|
|
551
|
+
const normalizeOneMillionContextModel = (model) => {
|
|
552
|
+
return oneMillionContextModelPattern.exec(model)?.[1]?.toLowerCase();
|
|
553
|
+
};
|
|
554
|
+
const normalizeClaudeModelId = (model) => {
|
|
555
|
+
const normalized = normalizeOneMillionContextModel(model);
|
|
556
|
+
return normalized ? `${normalized}[1m]` : model;
|
|
557
|
+
};
|
|
558
|
+
const normalizeCopilotModelId = (model) => normalizeOneMillionContextModel(model) ?? model;
|
|
410
559
|
const getConfiguredModelRouting = () => runtimeState.modelRouting ?? defaultModelRouting;
|
|
411
560
|
const getModelRouting = () => {
|
|
412
561
|
const routing = getConfiguredModelRouting();
|
|
@@ -446,7 +595,7 @@ const logLevels = [
|
|
|
446
595
|
const defaultConfig = {
|
|
447
596
|
claudeSetup: true,
|
|
448
597
|
copilotBaseUrl: "https://api.githubcopilot.com",
|
|
449
|
-
gptModel: "gpt-
|
|
598
|
+
gptModel: "gpt-6-astra",
|
|
450
599
|
host: "127.0.0.1",
|
|
451
600
|
logLevel: "info",
|
|
452
601
|
logRetentionDays: 3,
|
|
@@ -480,6 +629,87 @@ const normalizePositiveInteger = (value) => {
|
|
|
480
629
|
return Number.isInteger(number) && number > 0 ? number : void 0;
|
|
481
630
|
};
|
|
482
631
|
const normalizeString = (value) => typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
632
|
+
/**
|
|
633
|
+
* A conventional http(s) URL: scheme followed by a literal "//" authority.
|
|
634
|
+
*
|
|
635
|
+
* WHATWG is far more permissive than this. `https:host/path`, `https:/host/path`
|
|
636
|
+
* and `https:\\host\path` all parse, all normalize to a real origin with the
|
|
637
|
+
* tail in the path, and all work upstream - so a protocol check alone lets them
|
|
638
|
+
* through. None of them can be found again in arbitrary log text: practical URL
|
|
639
|
+
* detection anchors on a literal "://", and loosening that to chase a bare
|
|
640
|
+
* "https:" would start matching ordinary prose. Requiring the authority prefix
|
|
641
|
+
* is the one boundary where this has a definite answer. See #47.
|
|
642
|
+
*/
|
|
643
|
+
const conventionalHttpUrlPattern = /^https?:\/\//i;
|
|
644
|
+
/**
|
|
645
|
+
* Raw characters that cannot be delimited safely in rendered log text.
|
|
646
|
+
*
|
|
647
|
+
* A raw apostrophe is the clearest case. WHATWG accepts it and leaves it raw
|
|
648
|
+
* in the path, so the configured value keeps it - and inspect() renders that
|
|
649
|
+
* value inside quotes, where the apostrophe reads as the closing delimiter.
|
|
650
|
+
* Any URL scan ends there, and the rest of the path prints in full. Quotes,
|
|
651
|
+
* backticks and angle brackets are the same story; whitespace ends a URL for
|
|
652
|
+
* every reader and scanner alike; and tab and newline are worse still,
|
|
653
|
+
* because URL parsing strips them, so the stored value would not even match
|
|
654
|
+
* what is sent upstream.
|
|
655
|
+
*
|
|
656
|
+
* Rejecting the raw byte costs nothing: every one of these has a
|
|
657
|
+
* percent-encoded spelling that is unambiguous in log text and is accepted
|
|
658
|
+
* here unchanged. See #47.
|
|
659
|
+
*/
|
|
660
|
+
const unsafeRawDelimiterPattern = /['"`<>\s\u0000-\u001F\u007F]/;
|
|
661
|
+
/**
|
|
662
|
+
* Both fixed strings. The rejected value is never interpolated: these messages
|
|
663
|
+
* reach the terminal and, via the startup failure path, the log file, which is
|
|
664
|
+
* exactly the disclosure being prevented.
|
|
665
|
+
*/
|
|
666
|
+
const invalidCopilotBaseUrlMessage = "Invalid copilotBaseUrl: expected an absolute http(s) URL, e.g. https://api.githubcopilot.com";
|
|
667
|
+
const copilotBaseUrlCredentialsMessage = "Invalid copilotBaseUrl: URL credentials (user:password@host) are not supported; remove them and use a plain https URL";
|
|
668
|
+
const copilotBaseUrlDelimiterMessage = "Invalid copilotBaseUrl: quotes, backticks, angle brackets, whitespace and control characters must be percent-encoded";
|
|
669
|
+
/**
|
|
670
|
+
* Validates copilotBaseUrl as a conventional HTTP(S) URL without credentials.
|
|
671
|
+
*
|
|
672
|
+
* Four rules, all learned from #47:
|
|
673
|
+
*
|
|
674
|
+
* The value is concatenated as `${base}${path}` for every upstream call, so it
|
|
675
|
+
* must be something Undici will accept. A relative or non-HTTP value fails at
|
|
676
|
+
* request time as an unrelated-looking fetch error; failing here names the key.
|
|
677
|
+
*
|
|
678
|
+
* It must be written with an explicit `http://` or `https://` authority. See
|
|
679
|
+
* conventionalHttpUrlPattern: the shorthand forms WHATWG also accepts are
|
|
680
|
+
* unfindable in log text, so a configured secret in one of them cannot be
|
|
681
|
+
* redacted by anything downstream. Rejecting is the only safe answer, and this
|
|
682
|
+
* is the only place with enough context to give it.
|
|
683
|
+
*
|
|
684
|
+
* Raw quote-like characters, angle brackets, whitespace and control bytes are
|
|
685
|
+
* rejected for the same reason - see unsafeRawDelimiterPattern. They are what
|
|
686
|
+
* marks the end of a URL in rendered text, so a value containing one cannot be
|
|
687
|
+
* matched whole afterwards. Their percent-encoded forms are accepted.
|
|
688
|
+
*
|
|
689
|
+
* URL userinfo (`https://user:pass@host`) is rejected rather than passed
|
|
690
|
+
* through. Undici refuses it at request time anyway, so accepting it buys only
|
|
691
|
+
* a confusing failure plus a credential written to the log on every start.
|
|
692
|
+
*
|
|
693
|
+
* The accepted value is returned as the trimmed original, never URL-normalized.
|
|
694
|
+
* Round-tripping through URL would add a trailing slash, lowercase the host and
|
|
695
|
+
* re-encode the path, silently changing both the request URL and what gets
|
|
696
|
+
* written back to config.yaml.
|
|
697
|
+
*/
|
|
698
|
+
const normalizeCopilotBaseUrl = (value) => {
|
|
699
|
+
const trimmed = normalizeString(value);
|
|
700
|
+
if (trimmed === void 0) return;
|
|
701
|
+
if (!conventionalHttpUrlPattern.test(trimmed)) throw new Error(invalidCopilotBaseUrlMessage);
|
|
702
|
+
if (unsafeRawDelimiterPattern.test(trimmed)) throw new Error(copilotBaseUrlDelimiterMessage);
|
|
703
|
+
let parsed;
|
|
704
|
+
try {
|
|
705
|
+
parsed = new URL(trimmed);
|
|
706
|
+
} catch {
|
|
707
|
+
throw new Error(invalidCopilotBaseUrlMessage);
|
|
708
|
+
}
|
|
709
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(invalidCopilotBaseUrlMessage);
|
|
710
|
+
if (parsed.username || parsed.password) throw new Error(copilotBaseUrlCredentialsMessage);
|
|
711
|
+
return trimmed;
|
|
712
|
+
};
|
|
483
713
|
const normalizeThinkEffort = (value) => {
|
|
484
714
|
if (typeof value !== "string") return;
|
|
485
715
|
const normalized = value.toLowerCase() === "minimal" ? "low" : value.toLowerCase();
|
|
@@ -640,7 +870,7 @@ async function readAppConfig() {
|
|
|
640
870
|
const upstreamTimeoutSeconds = normalizeUpstreamTimeoutSeconds(raw.upstreamTimeoutSeconds);
|
|
641
871
|
const config = {
|
|
642
872
|
claudeSetup: claudeSetup ?? defaultConfig.claudeSetup,
|
|
643
|
-
copilotBaseUrl:
|
|
873
|
+
copilotBaseUrl: normalizeCopilotBaseUrl(raw.copilotBaseUrl) ?? defaultConfig.copilotBaseUrl,
|
|
644
874
|
gptModel: normalizeString(raw.gptModel) ?? defaultConfig.gptModel,
|
|
645
875
|
host: host ?? defaultConfig.host,
|
|
646
876
|
logLevel: logLevel ?? defaultConfig.logLevel,
|
|
@@ -971,7 +1201,7 @@ const hasPrimaryModelOverride = (parsed, env) => typeof parsed.model === "string
|
|
|
971
1201
|
/**
|
|
972
1202
|
* Update `~/.claude/settings.json` so its env block points Claude Code at the
|
|
973
1203
|
* running proxy. Preserves unrelated keys and model choices while normalizing
|
|
974
|
-
* managed
|
|
1204
|
+
* managed one-million-context GPT overrides. Sets a dummy auth token only if absent.
|
|
975
1205
|
*/
|
|
976
1206
|
async function applyClaudeConfig(input) {
|
|
977
1207
|
const { configPath: configPath$1, baseUrl, gptModel } = input;
|
|
@@ -1161,7 +1391,7 @@ const fetchCopilot = async (provider, path$1, init, options = {}) => {
|
|
|
1161
1391
|
|
|
1162
1392
|
//#endregion
|
|
1163
1393
|
//#region src/copilot/responses.ts
|
|
1164
|
-
const responsesOnlyModelPattern = /^(?:gpt-5\.5|gpt-5\.6)(?:-|$)/i;
|
|
1394
|
+
const responsesOnlyModelPattern = /^(?:gpt-5\.5|gpt-5\.6|gpt-6-astra)(?:-|$)/i;
|
|
1165
1395
|
function shouldUseResponsesApiForModel(model) {
|
|
1166
1396
|
return responsesOnlyModelPattern.test(model);
|
|
1167
1397
|
}
|
|
@@ -3193,6 +3423,7 @@ async function startRelay(appConfig) {
|
|
|
3193
3423
|
const claudeConfigPath$1 = claudeConfigPath;
|
|
3194
3424
|
const config = readProxyConfig(appConfig);
|
|
3195
3425
|
const applyRuntimeConfig = (nextConfig) => {
|
|
3426
|
+
registerSensitiveOrigin(nextConfig.copilotBaseUrl);
|
|
3196
3427
|
setLogLevel(nextConfig.logLevel);
|
|
3197
3428
|
cleanupLogs(nextConfig.logRetentionDays);
|
|
3198
3429
|
runtimeState.debug = nextConfig.logLevel === "debug";
|
|
@@ -3225,7 +3456,7 @@ async function startRelay(appConfig) {
|
|
|
3225
3456
|
if (authSession.githubLogin) log.info(`GitHub user: ${authSession.githubLogin}`);
|
|
3226
3457
|
else log.error("GitHub user: unavailable");
|
|
3227
3458
|
log.info(`copilot-relay listening on http://${config.host}:${config.port}`);
|
|
3228
|
-
log.info(`copilot base url: ${config.copilotBaseUrl}`);
|
|
3459
|
+
log.info(`copilot base url: ${formatUrlForDisplay(config.copilotBaseUrl)}`);
|
|
3229
3460
|
const baseUrl = `http://${config.host}:${config.port}`;
|
|
3230
3461
|
if (appConfig.claudeSetup) try {
|
|
3231
3462
|
const claudeResult = await applyClaudeConfig({
|
|
@@ -3322,6 +3553,7 @@ const localProbeTimeoutMs = 5e3;
|
|
|
3322
3553
|
const deepProbeTimeoutMs = 6e4;
|
|
3323
3554
|
const toStatusConfig = (config) => ({
|
|
3324
3555
|
...config,
|
|
3556
|
+
copilotBaseUrl: formatUrlForDisplay(config.copilotBaseUrl),
|
|
3325
3557
|
webSearchBackend: config.webSearchBackend ?? null
|
|
3326
3558
|
});
|
|
3327
3559
|
/**
|
|
@@ -3397,7 +3629,7 @@ const renderConfig = (config, running) => {
|
|
|
3397
3629
|
const lines = configRowKeys.map((key) => {
|
|
3398
3630
|
const value = config[key];
|
|
3399
3631
|
const rendered = key === "webSearchBackend" && value === null ? "(unset — uses gptModel)" : String(value);
|
|
3400
|
-
return ` ${key.padEnd(24)}${rendered}`;
|
|
3632
|
+
return ` ${key.padEnd(24)}${sanitizeTerminalString(rendered)}`;
|
|
3401
3633
|
});
|
|
3402
3634
|
if (running) lines.push(" host, port and claudeSetup take effect on restart; the rest hot-reload.");
|
|
3403
3635
|
return lines;
|
|
@@ -3527,6 +3759,7 @@ const checkDeep = async (baseUrl, model) => {
|
|
|
3527
3759
|
const collectStatus = async (options) => {
|
|
3528
3760
|
const appConfig = await readAppConfig();
|
|
3529
3761
|
setLogLevel(appConfig.logLevel);
|
|
3762
|
+
registerSensitiveOrigin(appConfig.copilotBaseUrl);
|
|
3530
3763
|
const base = {
|
|
3531
3764
|
config: toStatusConfig(appConfig),
|
|
3532
3765
|
configPath: paths.configPath,
|
|
@@ -3575,8 +3808,8 @@ const status = defineCommand({
|
|
|
3575
3808
|
},
|
|
3576
3809
|
async run({ args }) {
|
|
3577
3810
|
const result = await collectStatus({ deep: Boolean(args.deep) });
|
|
3578
|
-
if (args.json) console.log(JSON.stringify(result, null, 2));
|
|
3579
|
-
else console.log(renderStatus(result).join("\n"));
|
|
3811
|
+
if (args.json) console.log(scrubSensitiveUrls(JSON.stringify(result, null, 2)));
|
|
3812
|
+
else console.log(scrubSensitiveUrls(renderStatus(result).join("\n")));
|
|
3580
3813
|
process.exitCode = resolveExitCode(result);
|
|
3581
3814
|
}
|
|
3582
3815
|
});
|