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 CHANGED
@@ -49,13 +49,14 @@ Routing:
49
49
  | Requested model | Upstream model |
50
50
  | --- | --- |
51
51
  | contains `opus` | `claude-opus-5` |
52
- | `gpt-5.6-sol[1m]`, plain `gpt-5.6-sol`, or another non-Opus alias | `gpt-5.6-sol` |
52
+ | `gpt-6-astra[1m]`, plain `gpt-6-astra`, or another non-Opus alias | `gpt-6-astra` |
53
53
 
54
- The relay advertises `gpt-5.6-sol[1m]` to Claude Code. The `[1m]` selector
55
- changes Claude Code's client-side context budgeting only; every Copilot request
56
- still uses `gpt-5.6-sol`, and the relay cannot enlarge GitHub Copilot's upstream
57
- capacity. An explicit CLI or API model override that bypasses the managed Claude
58
- settings is outside this guarantee.
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` model overrides are normalized to the Claude-facing
77
- `gpt-5.6-sol[1m]` identity; unrelated model choices are preserved.
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-5.6-sol[1m], claude-opus-5
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-5.6-sol
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 [`docs/troubleshooting.md`](docs/troubleshooting.md) for common debugging workflows.
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
- Developer notes live in [`docs/development.md`](docs/development.md).
229
- Architecture notes live in [`docs/architecture.md`](docs/architecture.md).
230
- Logging notes live in [`docs/logging.md`](docs/logging.md).
231
- Troubleshooting notes live in [`docs/troubleshooting.md`](docs/troubleshooting.md).
232
- User-facing guides live in [`wiki/`](wiki/).
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
@@ -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-5.6-sol
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 docs/logging.md returned a fragment of an
151
- * object instead of the matching entry. One entry is now one line.
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.map(formatLogValue).join(" ")
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
- if ((fileLevelByMethod[level] ?? consolaLevelByName.info) <= currentLogLevel) writeLogFile(level, args).catch(() => void 0);
178
- return fn(...args);
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-5.6-sol",
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 exactGpt56SolPattern = /^gpt-5\.6-sol(?:\[1m\])*$/i;
406
- const claudeContextModelId = "gpt-5.6-sol[1m]";
407
- const copilotModelId = "gpt-5.6-sol";
408
- const normalizeClaudeModelId = (model) => exactGpt56SolPattern.test(model) ? claudeContextModelId : model;
409
- const normalizeCopilotModelId = (model) => exactGpt56SolPattern.test(model) ? copilotModelId : model;
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-5.6-sol",
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: normalizeString(raw.copilotBaseUrl) ?? defaultConfig.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 gpt-5.6-sol overrides. Sets a dummy auth token only if none is present.
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
  });