@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 +32 -0
- package/bin/ccmux.mjs +46 -6
- package/package.json +1 -1
- package/src/debug.ts +108 -0
- package/src/index.ts +72 -15
- package/src/models.ts +5 -4
- package/src/responses-stream.ts +15 -14
- package/src/token.ts +9 -2
- package/src/translate-request.ts +15 -3
- package/src/translate-responses.ts +12 -3
- package/src/upstream.ts +32 -17
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
|
-
|
|
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",
|
|
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
|
|
373
|
-
|
|
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
|
-
|
|
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
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
|
-
|
|
77
|
-
//
|
|
78
|
-
if (
|
|
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(
|
|
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
|
|
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
|
|
122
|
-
if (
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
|
|
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
|
-
//
|
|
183
|
-
//
|
|
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
|
-
|
|
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 {
|
package/src/responses-stream.ts
CHANGED
|
@@ -34,12 +34,14 @@ export async function* translateResponsesStream(
|
|
|
34
34
|
let buf = "";
|
|
35
35
|
|
|
36
36
|
let nextIndex = 0;
|
|
37
|
-
|
|
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
|
|
42
|
-
const emittedDelta = new Set<
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
138
|
-
const idx =
|
|
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 =
|
|
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.
|
|
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.
|
|
171
|
-
const idx =
|
|
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
|
|
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
|
-
|
|
75
|
-
//
|
|
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 {
|
package/src/translate-request.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|