@dondetir/ccmux 0.1.3 → 0.1.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
@@ -70,6 +70,8 @@ Free tier gets `claude-haiku-4.5` only; paid Copilot unlocks sonnet, GPT, and Ge
70
70
  | `ccmux login ollama` | Save an Ollama Cloud API key (from ollama.com/settings/keys) |
71
71
  | `ccmux logout` | Remove saved tokens (GitHub + Ollama) + VS Code Copilot tokens + stop the proxy. Use `eval "$(ccmux logout)"` to also clear tokens exported in the current shell |
72
72
  | `ccmux serve` | Run the proxy in the foreground on `:4141` |
73
+ | `ccmux claude --dangerously-skip-permissions` | Launch Claude Code with `--dangerously-skip-permissions` (aliases: `--dangerously`, `--yolo`). Skips per-action permission prompts — sandbox/VM only |
74
+ | `ccmux claude --debug` | Dump every request + upstream response (auth redacted) to `~/.config/ccmux/debug.log` (also `CCMUX_DEBUG=1`) |
73
75
 
74
76
  Logs stream to `~/.config/ccmux/proxy.log`. If models vanish, the catalog self-heals on the next request.
75
77
 
@@ -86,6 +88,36 @@ Env vars (or `~/.config/ccmux/env`):
86
88
  | `USE_NATIVE` | `1` | `1` = native pass-through; `0` = OpenAI translation path |
87
89
  | `CLAUDE_CONTEXT_WINDOW` | `200000` | Context window Claude Code assumes (token counts scaled to match) |
88
90
 
91
+ ## 🐛 Troubleshooting
92
+
93
+ ### GPT-5 / reasoning models show "invalid parameter" and no response
94
+
95
+ GPT-5 reasoning models (gpt-5.*, served via the Responses API) reject sampling
96
+ parameters — `temperature` and `top_p` — returning an "invalid/unsupported
97
+ parameter" error, so Claude Code renders nothing. ccmux now drops those for
98
+ reasoning models automatically. If a model still errors, find the exact rejected
99
+ field with debug logging:
100
+
101
+ ```bash
102
+ ccmux claude --debug # or CCMUX_DEBUG=1 ccmux serve
103
+ # then reproduce; inspect ~/.config/ccmux/debug.log
104
+ ```
105
+
106
+ Every inbound Anthropic request, the translated upstream request (Authorization
107
+ redacted), and the full upstream response (or raw SSE frames for streams) are
108
+ appended to `~/.config/ccmux/debug.log` (0600, loopback proxy only). This is the
109
+ authoritative way to see what Copilot rejected.
110
+
111
+ ### Run without permission prompts
112
+
113
+ ```bash
114
+ ccmux claude --dangerously-skip-permissions # alias: --yolo
115
+ ```
116
+
117
+ This passes `--dangerously-skip-permissions` through to Claude Code, skipping its
118
+ per-action prompts. Only do this inside a sandbox or VM — it lets the agent read,
119
+ write, and execute without asking.
120
+
89
121
  ## 🔧 Development
90
122
 
91
123
  ```bash
package/bin/ccmux.mjs CHANGED
@@ -85,6 +85,7 @@ const USAGE = `${hero()}${paint(C.bold, "Usage:")} ccmux <command> [args]
85
85
  ${paint(C.cyan, "Commands:")}
86
86
  claude [args...] Start the proxy (if needed) + launch Claude Code through it
87
87
  serve Run the proxy in the foreground on http://127.0.0.1:${PORT}
88
+ stop Stop a running background proxy
88
89
  login copilot GitHub device-flow login (saves token, auto-restarts proxy)
89
90
  login ollama Paste (or set OLLAMA_API_KEY) an Ollama Cloud key, validate, auto-restart proxy
90
91
  logout Remove saved tokens (GitHub + Ollama) + VS Code Copilot
@@ -92,6 +93,16 @@ ${paint(C.cyan, "Commands:")}
92
93
  clear exported tokens in the current shell
93
94
  help Show this help
94
95
 
96
+ ${paint(C.cyan, "Flags (any position):")}
97
+ --dangerously-skip-permissions
98
+ Launch Claude Code with --dangerously-skip-permissions
99
+ (aliases: --dangerously, --yolo). Bypasses Claude Code's
100
+ per-action permission prompts — only use in a sandbox/VM.
101
+ --debug Dump every request + upstream response (auth redacted) to
102
+ ~/.config/ccmux/debug.log. Use to diagnose "invalid
103
+ parameter" / empty-response errors (e.g. GPT-5 reasoning
104
+ models). Also set via CCMUX_DEBUG=1.
105
+
95
106
  ${paint(C.mag, "Environment")} ${paint(C.dim, "(or ~/.config/ccmux/env):")}
96
107
  NATIVE_MODEL Override the model served to Claude Code (default claude-haiku-4.5)
97
108
  PORT Proxy port, localhost only (default ${PORT})
@@ -351,7 +362,29 @@ function logout() {
351
362
  warn("ccmux: token(s) are still exported in this shell. Run `eval \"$(ccmux logout)\"` to clear them, then `ccmux login copilot`/`login ollama` to re-auth.");
352
363
  }
353
364
 
354
- function runClaude(args) {
365
+ // Pre-scan ccmux-level flags so they work in any position (e.g.
366
+ // `ccmux --dangerously-skip-permissions` or `ccmux claude --debug`). Recognized
367
+ // flags are stripped so they don't reach Claude Code twice; the permission flag
368
+ // is re-injected explicitly to guarantee it is passed through.
369
+ const DANGEROUSLY = new Set([
370
+ "--dangerously-skip-permissions",
371
+ "--dangerously-skip-permission",
372
+ "--dangerously",
373
+ "--yolo",
374
+ ]);
375
+ function extractCcmuxFlags(argv) {
376
+ let dangerously = false;
377
+ let debug = false;
378
+ const rest = [];
379
+ for (const a of argv) {
380
+ if (a === "--debug") { debug = true; continue; }
381
+ if (DANGEROUSLY.has(a)) { dangerously = true; continue; }
382
+ rest.push(a);
383
+ }
384
+ return { dangerously, debug, rest };
385
+ }
386
+
387
+ function runClaude(args, dangerously = false) {
355
388
  const env = {
356
389
  ...process.env,
357
390
  ANTHROPIC_BASE_URL: BASE,
@@ -360,8 +393,9 @@ function runClaude(args) {
360
393
  // aliased as claude-* so they survive Claude Code's discovery filter).
361
394
  CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: process.env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY ?? "1",
362
395
  };
396
+ const claudeArgs = dangerously ? ["--dangerously-skip-permissions", ...args] : args;
363
397
  registerSession();
364
- const child = spawn("claude", args, { stdio: "inherit", env });
398
+ const child = spawn("claude", claudeArgs, { stdio: "inherit", env });
365
399
  child.once("close", (code) => {
366
400
  deregisterSession();
367
401
  process.exit(code ?? 0);
@@ -369,8 +403,10 @@ function runClaude(args) {
369
403
  }
370
404
 
371
405
  async function main() {
372
- const cmd = process.argv[2];
373
- const rest = process.argv.slice(3);
406
+ const { dangerously, debug, rest: cleanArgs } = extractCcmuxFlags(process.argv.slice(2));
407
+ if (debug) process.env.CCMUX_DEBUG = "1"; // startProxy/serve inherit process.env
408
+ const cmd = cleanArgs[0];
409
+ const rest = cleanArgs.slice(1);
374
410
 
375
411
  // Print and exit without starting a proxy (unknown cmds previously fell
376
412
  // through to the proxy-up block and booted a stray proxy).
@@ -382,7 +418,11 @@ async function main() {
382
418
  console.log(`ccmux v${VERSION}`);
383
419
  process.exit(0);
384
420
  }
385
- const KNOWN = new Set(["claude", undefined, "serve", "login", "logout"]);
421
+ if (cmd === "stop") {
422
+ console.error(stopProxy() ? `ccmux: stopped proxy on ${BASE}` : "ccmux: no proxy running");
423
+ process.exit(0);
424
+ }
425
+ const KNOWN = new Set(["claude", undefined, "serve", "stop", "login", "logout"]);
386
426
  if (!KNOWN.has(cmd)) {
387
427
  console.error(`ccmux: unknown command "${cmd ?? ""}"\n\n${USAGE}`);
388
428
  process.exit(1);
@@ -450,7 +490,7 @@ async function main() {
450
490
  }
451
491
 
452
492
  if (cmd === "claude" || cmd === undefined) {
453
- runClaude(rest);
493
+ runClaude(rest, dangerously);
454
494
  } else {
455
495
  console.error(`usage: ccmux [claude ...] | login [copilot|ollama] | logout | serve`);
456
496
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dondetir/ccmux",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Run Claude Code on GitHub Copilot's backend, a local Anthropic-to-Copilot proxy",
5
5
  "private": false,
6
6
  "publishConfig": {
package/src/debug.ts ADDED
@@ -0,0 +1,108 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { CONFIG_DIR } from "./env.js";
4
+
5
+ // CCMUX_DEBUG=1 (or `ccmux --debug`) dumps every inbound Anthropic request, the
6
+ // translated upstream request (auth redacted), and the upstream response
7
+ // (status + body, or raw SSE frames for streams) to ~/.config/ccmux/debug.log.
8
+ // The file is 0600 and the proxy is loopback-only, so prompts/code stay local.
9
+ // This is the authoritative way to confirm upstream rejections (e.g. the
10
+ // "invalid parameter" GPT-5 reasoning models return for temperature/top_p).
11
+ export const DEBUG = /^(1|true|yes)$/i.test(process.env.CCMUX_DEBUG ?? "");
12
+
13
+ const LOG = path.join(CONFIG_DIR, "debug.log");
14
+
15
+ let ensured = false;
16
+ function ensureLog(): void {
17
+ if (ensured) return;
18
+ ensured = true;
19
+ try {
20
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
21
+ try { fs.chmodSync(CONFIG_DIR, 0o700); } catch { /* non-posix */ }
22
+ } catch { /* best effort */ }
23
+ }
24
+
25
+ function stamp(): string {
26
+ return new Date().toISOString();
27
+ }
28
+
29
+ // Redact bearer tokens so credentials never land in the debug log.
30
+ function redactAuth(h: any): any {
31
+ if (!h || typeof h !== "object") return h;
32
+ const out: any = Array.isArray(h) ? [...h] : { ...h };
33
+ for (const k of Object.keys(out)) {
34
+ if (/^authorization$/i.test(k))
35
+ out[k] = String(out[k]).replace(/(Bearer\s+)[^\s]+/i, "$1<redacted>");
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function write(label: string, text: string): void {
41
+ if (!DEBUG) return;
42
+ ensureLog();
43
+ try {
44
+ fs.appendFileSync(LOG, `[${stamp()}] ${label}\n${text}\n\n`, { mode: 0o600 });
45
+ try { fs.chmodSync(LOG, 0o600); } catch { /* non-posix */ }
46
+ } catch {
47
+ // Debug must never break a request.
48
+ }
49
+ }
50
+
51
+ export function debugObj(label: string, obj: unknown): void {
52
+ if (!DEBUG) return;
53
+ const text = typeof obj === "string" ? obj : JSON.stringify(obj, null, 2);
54
+ write(label, text);
55
+ }
56
+
57
+ export function debugInbound(path: string, body: unknown): void {
58
+ debugObj(`>>> INBOUND ${path}`, body);
59
+ }
60
+
61
+ export function debugUpstreamRequest(
62
+ method: string,
63
+ url: string,
64
+ headers: unknown,
65
+ body: unknown,
66
+ ): void {
67
+ if (!DEBUG) return;
68
+ write(
69
+ `>>> UPSTREAM ${method} ${url}`,
70
+ JSON.stringify({ headers: redactAuth(headers), body }, null, 2),
71
+ );
72
+ }
73
+
74
+ export function debugUpstreamResponse(status: number, bodyText: string): void {
75
+ debugObj(`<<< UPSTREAM status ${status}`, bodyText);
76
+ }
77
+
78
+ // Tee a response stream so every raw chunk is logged as it flows to the
79
+ // translator. Returns a new ReadableStream that yields the same bytes; the
80
+ // translator reads it exactly like the original upstream body.
81
+ export function debugTeeStream(
82
+ stream: ReadableStream<Uint8Array>,
83
+ label: string,
84
+ ): ReadableStream<Uint8Array> {
85
+ if (!DEBUG) return stream;
86
+ const reader = stream.getReader();
87
+ const decoder = new TextDecoder();
88
+ return new ReadableStream<Uint8Array>({
89
+ async pull(c) {
90
+ try {
91
+ const { done, value } = await reader.read();
92
+ if (done) {
93
+ c.close();
94
+ return;
95
+ }
96
+ try {
97
+ write(`${label} (stream chunk)`, decoder.decode(value, { stream: true }));
98
+ } catch { /* ignore */ }
99
+ c.enqueue(value);
100
+ } catch (e) {
101
+ c.error(e as any);
102
+ }
103
+ },
104
+ cancel(reason) {
105
+ reader.cancel(reason);
106
+ },
107
+ });
108
+ }
package/src/index.ts CHANGED
@@ -38,9 +38,19 @@ import { rewriteNativeStream } from "./native-stream.js";
38
38
  import { anthropicToResponses, translateResponsesResponse, ResponsesApiUnsupported } from "./translate-responses.js";
39
39
  import { translateResponsesStream } from "./responses-stream.js";
40
40
  import { startIdleReaper } from "./sessions.js";
41
+ import { DEBUG, debugInbound, debugUpstreamResponse, debugTeeStream } from "./debug.js";
41
42
 
42
43
  const app = new Hono();
43
44
 
45
+ // Browsers always send Origin on cross-origin requests; CLI clients don't.
46
+ // Rejecting it stops a malicious webpage from spending Copilot quota through
47
+ // the unauthenticated loopback port (no-preflight "simple request" POSTs).
48
+ app.use(async (c, next) => {
49
+ if (c.req.header("origin"))
50
+ return c.json({ type: "error", error: { type: "forbidden", message: "browser-origin requests are not allowed" } }, 403);
51
+ await next();
52
+ });
53
+
44
54
  const NATIVE_ALLOWED = new Set([
45
55
  "model", "max_tokens", "messages", "system", "tools", "tool_choice",
46
56
  "temperature", "top_p", "top_k", "stop_sequences", "stream", "thinking",
@@ -67,21 +77,41 @@ async function mirrorError(c: Context, up: Response, preRead?: string) {
67
77
  return c.json({ type: "error", error: { type, message: text } }, up.status as any);
68
78
  }
69
79
 
80
+ // Single source of truth for upstream routing. Endpoint choice is catalog-driven
81
+ // (supportedEndpoints), so new Copilot models need no code here — a /responses
82
+ // model routes to Path C, everything else to Path B. Native is the one exception:
83
+ // Copilot exposes no /v1/messages in the catalog, so Claude passthrough is a
84
+ // ccmux opt-in (USE_NATIVE), not a catalog fact. Add a future endpoint here only.
85
+ function route(model: string): "native" | "responses" | "chat" {
86
+ if (USE_NATIVE && !needsTranslation(model)) return "native";
87
+ if (needsResponsesApi(model)) return "responses";
88
+ return "chat";
89
+ }
90
+
70
91
  app.post("/v1/messages", async (c) => {
71
92
  const body = await c.req.json();
72
93
  // The /model picker may send an aliased id (claude-gpt-4.1); restore the real
73
94
  // Copilot id so path selection + scaling use it.
74
95
  body.model = unaliasModel(body.model ?? "");
96
+ debugInbound("/v1/messages", body);
75
97
  try {
76
- // Path A: native pass-through. GPT/Gemini ids always use the translation path;
77
- // Claude goes native when USE_NATIVE is set.
78
- if (USE_NATIVE && !needsTranslation(body.model ?? "")) {
98
+ const path = route(body.model ?? "");
99
+ // Path A: native pass-through to Vertex Claude (USE_NATIVE).
100
+ if (path === "native") {
79
101
  body.model = resolveModel(body.model ?? "");
80
102
  if (body.max_tokens) body.max_tokens = clampMaxTokens(body.model, body.max_tokens);
81
103
  // Copilot's native endpoint (Vertex Claude) validates strictly and 400s on
82
104
  // unknown fields. Allowlist instead of chasing each new field Claude Code adds.
83
105
  for (const k of Object.keys(body))
84
106
  if (!NATIVE_ALLOWED.has(k)) delete body[k];
107
+ // Copilot's schema also lags Claude Code's server-tool types (e.g. a new
108
+ // "advisor_20260301"); drop anything but custom tools, same as Path B.
109
+ if (Array.isArray(body.tools)) {
110
+ const allTools = body.tools;
111
+ body.tools = allTools.filter((t: any) => !t.type || t.type === "custom");
112
+ if (body.tools.length !== allTools.length)
113
+ console.warn(`dropped ${allTools.length - body.tools.length} server tool(s)`);
114
+ }
85
115
  // Rewrite thinking + output_config.effort to what the serving model supports.
86
116
  adaptBodyToModel(body);
87
117
  await ensureModelPolicy(body.model);
@@ -98,28 +128,37 @@ app.post("/v1/messages", async (c) => {
98
128
  adaptBodyToModel(body);
99
129
  up = await callCopilotNative(body);
100
130
  } else {
131
+ debugUpstreamResponse(400, txt);
101
132
  return c.json({ type: "error", error: { type: "invalid_request_error", message: txt } }, 400);
102
133
  }
103
134
  }
104
135
  if (!up.ok) {
105
136
  const txt = await up.text();
137
+ debugUpstreamResponse(up.status, txt);
106
138
  return mirrorError(c, up, txt);
107
139
  }
108
140
  const factor = contextScale(body.model);
109
141
  if (body.stream) {
110
142
  c.header("Content-Type", "text/event-stream");
111
143
  return stream(c, async (s) => {
112
- for await (const evt of rewriteNativeStream(up.body!, factor, () => {}, () => {}))
144
+ for await (const evt of rewriteNativeStream(
145
+ debugTeeStream(up.body!, "<<< native stream"),
146
+ factor,
147
+ () => {},
148
+ () => {},
149
+ ))
113
150
  await s.write(evt);
114
151
  });
115
152
  }
116
- const json: any = await up.json();
153
+ const nativeText = await up.text();
154
+ debugUpstreamResponse(up.status, nativeText);
155
+ const json: any = JSON.parse(nativeText);
117
156
  scaleUsage(json.usage, factor);
118
157
  return c.json(json);
119
158
  }
120
159
 
121
- // Path C: Responses API for models that only expose /responses (gpt-5*, mai-code-1*).
122
- if (needsResponsesApi(body.model ?? "")) {
160
+ // Path C: Responses API for /responses-capable models (gpt-5*, mai-code-1*).
161
+ if (path === "responses") {
123
162
  let responsesPayload: any;
124
163
  try {
125
164
  responsesPayload = anthropicToResponses(body);
@@ -132,17 +171,25 @@ app.post("/v1/messages", async (c) => {
132
171
  const up = await callCopilotResponses(responsesPayload);
133
172
  if (!up.ok) {
134
173
  const txt = await up.text();
174
+ debugUpstreamResponse(up.status, txt);
135
175
  return mirrorError(c, up, txt);
136
176
  }
137
177
  const factor = contextScale(responsesPayload.model);
138
178
  if (body.stream) {
139
179
  c.header("Content-Type", "text/event-stream");
140
180
  return stream(c, async (s) => {
141
- for await (const evt of translateResponsesStream(up.body!, () => {}, factor, () => {}))
181
+ for await (const evt of translateResponsesStream(
182
+ debugTeeStream(up.body!, "<<< responses stream"),
183
+ () => {},
184
+ factor,
185
+ () => {},
186
+ ))
142
187
  await s.write(evt);
143
188
  });
144
189
  }
145
- const res = translateResponsesResponse(await up.json());
190
+ const respText = await up.text();
191
+ debugUpstreamResponse(up.status, respText);
192
+ const res = translateResponsesResponse(JSON.parse(respText));
146
193
  scaleUsage(res.usage, factor);
147
194
  return c.json(res);
148
195
  }
@@ -154,6 +201,7 @@ app.post("/v1/messages", async (c) => {
154
201
  const up = await callCopilot(payload, flags, provider);
155
202
  if (!up.ok) {
156
203
  const txt = await up.text();
204
+ debugUpstreamResponse(up.status, txt);
157
205
  return mirrorError(c, up, txt);
158
206
  }
159
207
  const factor = contextScale(payload.model);
@@ -161,11 +209,18 @@ app.post("/v1/messages", async (c) => {
161
209
  if (body.stream) {
162
210
  c.header("Content-Type", "text/event-stream");
163
211
  return stream(c, async (s) => {
164
- for await (const evt of translateStream(up.body!, () => {}, factor, () => {}))
212
+ for await (const evt of translateStream(
213
+ debugTeeStream(up.body!, "<<< chat stream"),
214
+ () => {},
215
+ factor,
216
+ () => {},
217
+ ))
165
218
  await s.write(evt);
166
219
  });
167
220
  }
168
- const res = openAIToAnthropic(await up.json());
221
+ const chatText = await up.text();
222
+ debugUpstreamResponse(up.status, chatText);
223
+ const res = openAIToAnthropic(JSON.parse(chatText));
169
224
  scaleUsage(res.usage, factor);
170
225
  return c.json(res);
171
226
  } catch (e: any) {
@@ -177,6 +232,7 @@ app.post("/v1/messages", async (c) => {
177
232
  // Claude Code calls this for context management. Must never 404.
178
233
  app.post("/v1/messages/count_tokens", async (c) => {
179
234
  const body = await c.req.json();
235
+ debugInbound("/v1/messages/count_tokens", body);
180
236
  // Apply same model resolution as /v1/messages so counts match the serving model.
181
237
  body.model = resolveModel(unaliasModel(body.model ?? ""));
182
238
  const factor = contextScale(body.model);
@@ -204,10 +260,10 @@ app.get("/v1/models", async (c) => {
204
260
  // Mirror upstream errors so proxyUp()'s auth-readiness gate fails correctly.
205
261
  if (!res.ok) return mirrorError(c, res);
206
262
  data = await res.json();
207
- // Self-heal: if the startup catalog fetch failed, repopulate from this live
208
- // fetch. Gate on copilotCatalogLoaded(), not map size, because Ollama entries
209
- // can pre-fill the map and defeat a size===0 check.
210
- if (!copilotCatalogLoaded()) loadCatalog(data);
263
+ // Refresh the routing catalog on every fetch (idempotent). Copilot adds
264
+ // models mid-run; without this they'd appear in the picker but misroute
265
+ // (unaliasModel/route need a modelCatalog entry) until a proxy restart.
266
+ loadCatalog(data);
211
267
  } catch (e) {
212
268
  // If Copilot has no credentials but Ollama is configured, serve the Ollama
213
269
  // catalog alone so the proxy is usable Ollama-only. If the Copilot catalog
@@ -271,6 +327,7 @@ if (process.argv.includes("--login")) {
271
327
  console.log(
272
328
  `ccmux on http://127.0.0.1:${PORT} (${USE_NATIVE ? "native" : "translation"} path)`,
273
329
  );
330
+ if (DEBUG) console.log("ccmux: DEBUG on — dumping requests/responses to ~/.config/ccmux/debug.log");
274
331
  });
275
332
 
276
333
  // When started by `ccmux claude` (MANAGED), shut down once all sessions end.
package/src/models.ts CHANGED
@@ -179,11 +179,12 @@ export function unaliasModel(m: string): string {
179
179
  return modelCatalog.has(real) && !real.startsWith("claude-") ? real : m;
180
180
  }
181
181
 
182
- // Use /responses ONLY when it is the model's sole endpoint; dual-endpoint and
183
- // unknown models fall through to Path B safely.
182
+ // Route every /responses-capable model (the gpt-5 reasoning family) to Path C.
183
+ // Even dual-endpoint models like gpt-5.4 reject tools + reasoning_effort on
184
+ // /chat/completions ("use /v1/responses instead"), so /responses is the only
185
+ // path that works for them. Chat-only models (gpt-4o, gemini) fall through.
184
186
  export function needsResponsesApi(model: string): boolean {
185
- const eps = modelCatalog.get(model)?.supportedEndpoints ?? [];
186
- return eps.includes("/responses") && !eps.includes("/chat/completions");
187
+ return (modelCatalog.get(model)?.supportedEndpoints ?? []).includes("/responses");
187
188
  }
188
189
 
189
190
  export function clampMaxTokens(model: string, requested: number): number {
@@ -34,12 +34,14 @@ export async function* translateResponsesStream(
34
34
  let buf = "";
35
35
 
36
36
  let nextIndex = 0;
37
- const itemIdToIndex = new Map<string, number>(); // item.id -> Anthropic block index
37
+ // Key by output_index, NOT item.id: Copilot rotates the encrypted item_id on
38
+ // every event, so added/delta/done never share an id. output_index is stable.
39
+ const blockByOutputIndex = new Map<number, number>(); // output_index -> Anthropic block index
38
40
  let openBlock: number | null = null;
39
41
  let stopReason = "end_turn";
40
42
  let hadToolUse = false; // true if any function_call item was opened
41
- // Tracks which item_ids have emitted at least one delta (for .done no-delta fallback)
42
- const emittedDelta = new Set<string>();
43
+ // Tracks which output_index emitted at least one delta (for .done no-delta fallback)
44
+ const emittedDelta = new Set<number>();
43
45
  let outTokens = 0;
44
46
  let inTokens = 0;
45
47
 
@@ -89,7 +91,7 @@ export async function* translateResponsesStream(
89
91
 
90
92
  yield* closeOpen();
91
93
  const idx = nextIndex++;
92
- itemIdToIndex.set(item.id, idx);
94
+ blockByOutputIndex.set(ev.output_index, idx);
93
95
  openBlock = idx;
94
96
 
95
97
  if (item.type === "function_call") {
@@ -115,14 +117,14 @@ export async function* translateResponsesStream(
115
117
  }
116
118
 
117
119
  case "response.output_text.delta": {
118
- const idx = itemIdToIndex.get(ev.item_id);
120
+ const idx = blockByOutputIndex.get(ev.output_index);
119
121
  if (idx === undefined) break;
120
122
  if (openBlock !== idx) {
121
123
  yield* closeOpen();
122
124
  openBlock = idx;
123
125
  }
124
126
  if (ev.delta) {
125
- emittedDelta.add(ev.item_id);
127
+ emittedDelta.add(ev.output_index);
126
128
  yield sse("content_block_delta", {
127
129
  type: "content_block_delta",
128
130
  index: idx,
@@ -134,8 +136,8 @@ export async function* translateResponsesStream(
134
136
 
135
137
  case "response.output_text.done": {
136
138
  // If no deltas arrived, emit full text now so short payloads are not lost.
137
- if (!emittedDelta.has(ev.item_id) && ev.text) {
138
- const idx = itemIdToIndex.get(ev.item_id);
139
+ if (!emittedDelta.has(ev.output_index) && ev.text) {
140
+ const idx = blockByOutputIndex.get(ev.output_index);
139
141
  if (idx !== undefined) {
140
142
  yield sse("content_block_delta", {
141
143
  type: "content_block_delta",
@@ -148,14 +150,14 @@ export async function* translateResponsesStream(
148
150
  }
149
151
 
150
152
  case "response.function_call_arguments.delta": {
151
- const idx = itemIdToIndex.get(ev.item_id);
153
+ const idx = blockByOutputIndex.get(ev.output_index);
152
154
  if (idx === undefined) break;
153
155
  if (openBlock !== idx) {
154
156
  yield* closeOpen();
155
157
  openBlock = idx;
156
158
  }
157
159
  if (ev.delta) {
158
- emittedDelta.add(ev.item_id);
160
+ emittedDelta.add(ev.output_index);
159
161
  yield sse("content_block_delta", {
160
162
  type: "content_block_delta",
161
163
  index: idx,
@@ -167,8 +169,8 @@ export async function* translateResponsesStream(
167
169
 
168
170
  case "response.function_call_arguments.done": {
169
171
  // If no deltas arrived, emit full arguments now so short tool calls are not empty.
170
- if (!emittedDelta.has(ev.item_id) && ev.arguments) {
171
- const idx = itemIdToIndex.get(ev.item_id);
172
+ if (!emittedDelta.has(ev.output_index) && ev.arguments) {
173
+ const idx = blockByOutputIndex.get(ev.output_index);
172
174
  if (idx !== undefined) {
173
175
  yield sse("content_block_delta", {
174
176
  type: "content_block_delta",
@@ -181,8 +183,7 @@ export async function* translateResponsesStream(
181
183
  }
182
184
 
183
185
  case "response.output_item.done": {
184
- const item = ev.item;
185
- const idx = item ? itemIdToIndex.get(item.id) : undefined;
186
+ const idx = blockByOutputIndex.get(ev.output_index);
186
187
  if (idx !== undefined) {
187
188
  yield sse("content_block_stop", { type: "content_block_stop", index: idx });
188
189
  if (openBlock === idx) openBlock = null;
package/src/token.ts CHANGED
@@ -71,8 +71,15 @@ function persistToken(gh: string): void {
71
71
  fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
72
72
  try { fs.chmodSync(CONFIG_DIR, 0o700); } catch { /* fs ignores mode */ }
73
73
  const p = path.join(CONFIG_DIR, "env");
74
- fs.appendFileSync(p, `\nGH_TOKEN=${gh}\n`, { mode: 0o600 });
75
- // appendFileSync mode only applies on creation; chmod enforces it on existing files.
74
+ // Rewrite rather than append so re-logins replace the old token instead of
75
+ // accumulating stale plaintext tokens. Other keys (OLLAMA_API_KEY) are kept.
76
+ let kept: string[] = [];
77
+ try {
78
+ kept = fs.readFileSync(p, "utf8").split("\n").filter((l) => l.trim() && !/^GH_TOKEN=/.test(l));
79
+ } catch { /* file doesn't exist yet */ }
80
+ kept.push(`GH_TOKEN=${gh}`);
81
+ fs.writeFileSync(p, kept.join("\n") + "\n", { mode: 0o600 });
82
+ // writeFileSync mode only applies on creation; chmod enforces it on existing files.
76
83
  try { fs.chmodSync(p, 0o600); } catch { /* non-posix fs */ }
77
84
  console.log(`ccmux: saved GH_TOKEN to ${p} (one-time login)`);
78
85
  } catch {
@@ -1,4 +1,4 @@
1
- import { mapModel, clampMaxTokens, resolveEffort } from "./models.js";
1
+ import { mapModel, clampMaxTokens, resolveEffort, modelCatalog } from "./models.js";
2
2
  import type { AnthropicBody, AnthropicMessage, TranslateFlags } from "./types.js";
3
3
 
4
4
  // Collapse an Anthropic system prompt (string | text-block[]) to a plain string.
@@ -94,12 +94,24 @@ export function anthropicToOpenAI(body: AnthropicBody): { payload: any; flags: T
94
94
  flags.agent = isAgentTurn(body.messages);
95
95
 
96
96
  const model = mapModel(body.model ?? "");
97
+ // Reasoning models (gpt-5*, o-series) reject `max_tokens` and sampling params
98
+ // on /chat/completions ("Unsupported parameter: use max_completion_tokens").
99
+ // Send max_completion_tokens and drop temperature/top_p for them so a
100
+ // reasoning model that falls through to Path B still works.
101
+ const chatEntry = modelCatalog.get(model);
102
+ const isReasoning = !!(
103
+ chatEntry &&
104
+ ((chatEntry.efforts?.length ?? 0) > 0 || chatEntry.adaptiveThinking || chatEntry.maxThinking)
105
+ );
97
106
  const payload: any = {
98
107
  model,
99
108
  messages,
100
- max_tokens: clampMaxTokens(model, body.max_tokens ?? 4096),
109
+ [isReasoning ? "max_completion_tokens" : "max_tokens"]: clampMaxTokens(
110
+ model,
111
+ body.max_tokens ?? 4096,
112
+ ),
101
113
  };
102
- if (body.temperature !== undefined) payload.temperature = body.temperature;
114
+ if (body.temperature !== undefined && !isReasoning) payload.temperature = body.temperature;
103
115
  if (body.stream) {
104
116
  payload.stream = true;
105
117
  payload.stream_options = { include_usage: true }; // omitting this leaves output_tokens at 0
@@ -1,4 +1,4 @@
1
- import { mapModel, clampMaxTokens, resolveEffort } from "./models.js";
1
+ import { mapModel, clampMaxTokens, resolveEffort, modelCatalog } from "./models.js";
2
2
  import { extractSystemText } from "./translate-request.js";
3
3
  import type { AnthropicBody } from "./types.js";
4
4
 
@@ -130,8 +130,17 @@ export function anthropicToResponses(body: AnthropicBody): any {
130
130
 
131
131
  if (systemText) payload.instructions = systemText;
132
132
  if (body.stream) payload.stream = true;
133
- if (body.temperature !== undefined) payload.temperature = body.temperature;
134
- if ((body as any).top_p !== undefined) payload.top_p = (body as any).top_p;
133
+ // GPT-5 reasoning models reject temperature/top_p on the Responses API
134
+ // ("Unsupported parameter"/"invalid parameter"), which surfaces in Claude
135
+ // Code as an error with no assistant response rendered. Drop them for
136
+ // reasoning models; the Responses API doesn't honor sampling for reasoning.
137
+ const rEntry = modelCatalog.get(model);
138
+ const isReasoning = !!(
139
+ rEntry &&
140
+ ((rEntry.efforts?.length ?? 0) > 0 || rEntry.adaptiveThinking || rEntry.maxThinking)
141
+ );
142
+ if (body.temperature !== undefined && !isReasoning) payload.temperature = body.temperature;
143
+ if ((body as any).top_p !== undefined && !isReasoning) payload.top_p = (body as any).top_p;
135
144
 
136
145
  // Tool schema is flat (no function wrapper); parameters renamed from input_schema.
137
146
  const allTools = body.tools ?? [];
package/src/upstream.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import "./env.js"; // load .env BEFORE any env reads below
2
2
  import { getCopilotToken } from "./token.js";
3
+ import { debugUpstreamRequest } from "./debug.js";
3
4
  import type { TranslateFlags } from "./types.js";
4
5
 
5
6
  export const PORT = Number(process.env.PORT ?? 4141);
@@ -43,9 +44,12 @@ export async function callCopilot(
43
44
  if (provider === "ollama") {
44
45
  const body = openaiBody as any;
45
46
  const wire = { ...body, model: String(body.model ?? "").replace(/^ollama\//, "") };
46
- return fetch(`${OLLAMA_BASE}/chat/completions`, {
47
+ const url = `${OLLAMA_BASE}/chat/completions`;
48
+ const headers = { "Content-Type": "application/json", Authorization: `Bearer ${ollamaKey()}` };
49
+ debugUpstreamRequest("POST", url, headers, wire);
50
+ return fetch(url, {
47
51
  method: "POST",
48
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${ollamaKey()}` },
52
+ headers,
49
53
  body: JSON.stringify(wire),
50
54
  });
51
55
  }
@@ -53,7 +57,9 @@ export async function callCopilot(
53
57
  const headers: Record<string, string> = { ...COPILOT_HEADERS, Authorization: `Bearer ${bearer}` };
54
58
  headers["X-Initiator"] = flags.agent ? "agent" : "user";
55
59
  if (flags.vision) headers["Copilot-Vision-Request"] = "true";
56
- return fetch(`${COPILOT_BASE}/chat/completions`, {
60
+ const url = `${COPILOT_BASE}/chat/completions`;
61
+ debugUpstreamRequest("POST", url, headers, openaiBody);
62
+ return fetch(url, {
57
63
  method: "POST",
58
64
  headers,
59
65
  body: JSON.stringify(openaiBody),
@@ -63,13 +69,16 @@ export async function callCopilot(
63
69
  // Path A: forward the Anthropic body unchanged to Copilot's native endpoint.
64
70
  export async function callCopilotNative(anthropicBody: unknown): Promise<Response> {
65
71
  const bearer = await getCopilotToken();
66
- return fetch(`${COPILOT_BASE}/v1/messages`, {
72
+ const headers = {
73
+ ...COPILOT_HEADERS,
74
+ Authorization: `Bearer ${bearer}`,
75
+ "anthropic-version": "2023-06-01",
76
+ };
77
+ const url = `${COPILOT_BASE}/v1/messages`;
78
+ debugUpstreamRequest("POST", url, headers, anthropicBody);
79
+ return fetch(url, {
67
80
  method: "POST",
68
- headers: {
69
- ...COPILOT_HEADERS,
70
- Authorization: `Bearer ${bearer}`,
71
- "anthropic-version": "2023-06-01",
72
- },
81
+ headers,
73
82
  body: JSON.stringify(anthropicBody),
74
83
  });
75
84
  }
@@ -78,9 +87,12 @@ export async function callCopilotNative(anthropicBody: unknown): Promise<Respons
78
87
  // model_not_supported on free tier, not 404.
79
88
  export async function callCopilotResponses(responsesBody: unknown): Promise<Response> {
80
89
  const bearer = await getCopilotToken();
81
- return fetch(`${COPILOT_BASE}/responses`, {
90
+ const headers = { ...COPILOT_HEADERS, Authorization: `Bearer ${bearer}` };
91
+ const url = `${COPILOT_BASE}/responses`;
92
+ debugUpstreamRequest("POST", url, headers, responsesBody);
93
+ return fetch(url, {
82
94
  method: "POST",
83
- headers: { ...COPILOT_HEADERS, Authorization: `Bearer ${bearer}` },
95
+ headers,
84
96
  body: JSON.stringify(responsesBody),
85
97
  });
86
98
  }
@@ -90,13 +102,16 @@ export async function callCopilotResponses(responsesBody: unknown): Promise<Resp
90
102
  export async function countTokensUpstream(anthropicBody: unknown): Promise<number | null> {
91
103
  try {
92
104
  const bearer = await getCopilotToken();
93
- const res = await fetch(`${COPILOT_BASE}/v1/messages/count_tokens`, {
105
+ const headers = {
106
+ ...COPILOT_HEADERS,
107
+ Authorization: `Bearer ${bearer}`,
108
+ "anthropic-version": "2023-06-01",
109
+ };
110
+ const url = `${COPILOT_BASE}/v1/messages/count_tokens`;
111
+ debugUpstreamRequest("POST", url, headers, anthropicBody);
112
+ const res = await fetch(url, {
94
113
  method: "POST",
95
- headers: {
96
- ...COPILOT_HEADERS,
97
- Authorization: `Bearer ${bearer}`,
98
- "anthropic-version": "2023-06-01",
99
- },
114
+ headers,
100
115
  body: JSON.stringify(anthropicBody),
101
116
  });
102
117
  if (res.ok) return ((await res.json()) as any).input_tokens ?? null;