@dondetir/ccmux 0.1.0
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/LICENSE +21 -0
- package/README.md +150 -0
- package/bin/ccmux.mjs +460 -0
- package/package.json +47 -0
- package/src/env.ts +20 -0
- package/src/index.ts +273 -0
- package/src/models.ts +285 -0
- package/src/native-stream.ts +51 -0
- package/src/responses-stream.ts +231 -0
- package/src/sessions.ts +66 -0
- package/src/stream.ts +172 -0
- package/src/token.ts +130 -0
- package/src/translate-request.ts +142 -0
- package/src/translate-response.ts +39 -0
- package/src/translate-responses.ts +252 -0
- package/src/types.ts +30 -0
- package/src/upstream.ts +107 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// OpenAI Responses API SSE stream -> Anthropic SSE event protocol.
|
|
2
|
+
// Key differences from Chat Completions (stream.ts):
|
|
3
|
+
// - Items opened via response.output_item.added; routed by item.id (NOT call_id)
|
|
4
|
+
// - Text deltas: response.output_text.delta; tool arg deltas: response.function_call_arguments.delta
|
|
5
|
+
// - Items close via response.output_item.done
|
|
6
|
+
// - Usage arrives on response.completed (not a final empty-choices chunk)
|
|
7
|
+
|
|
8
|
+
import { sse } from "./stream.js";
|
|
9
|
+
import { responsesStatusToStopReason } from "./translate-responses.js";
|
|
10
|
+
|
|
11
|
+
export async function* translateResponsesStream(
|
|
12
|
+
upstream: ReadableStream<Uint8Array>,
|
|
13
|
+
onUsage: (usage: { input_tokens: number; output_tokens: number }) => void = () => {},
|
|
14
|
+
factor = 1,
|
|
15
|
+
onAbort: () => void = () => {},
|
|
16
|
+
): AsyncGenerator<string> {
|
|
17
|
+
const msgId = "msg_" + Date.now();
|
|
18
|
+
yield sse("message_start", {
|
|
19
|
+
type: "message_start",
|
|
20
|
+
message: {
|
|
21
|
+
id: msgId,
|
|
22
|
+
type: "message",
|
|
23
|
+
role: "assistant",
|
|
24
|
+
model: "",
|
|
25
|
+
content: [],
|
|
26
|
+
stop_reason: null,
|
|
27
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
yield sse("ping", { type: "ping" });
|
|
31
|
+
|
|
32
|
+
const reader = upstream.getReader();
|
|
33
|
+
const decoder = new TextDecoder();
|
|
34
|
+
let buf = "";
|
|
35
|
+
|
|
36
|
+
let nextIndex = 0;
|
|
37
|
+
const itemIdToIndex = new Map<string, number>(); // item.id -> Anthropic block index
|
|
38
|
+
let openBlock: number | null = null;
|
|
39
|
+
let stopReason = "end_turn";
|
|
40
|
+
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
|
+
let outTokens = 0;
|
|
44
|
+
let inTokens = 0;
|
|
45
|
+
|
|
46
|
+
const closeOpen = function* () {
|
|
47
|
+
if (openBlock !== null) {
|
|
48
|
+
yield sse("content_block_stop", { type: "content_block_stop", index: openBlock });
|
|
49
|
+
openBlock = null;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
while (true) {
|
|
55
|
+
const { done, value } = await reader.read();
|
|
56
|
+
if (done) break;
|
|
57
|
+
buf += decoder.decode(value, { stream: true });
|
|
58
|
+
const frames = buf.split("\n\n");
|
|
59
|
+
buf = frames.pop() ?? "";
|
|
60
|
+
|
|
61
|
+
for (const frame of frames) {
|
|
62
|
+
if (!frame.trim()) continue;
|
|
63
|
+
|
|
64
|
+
let eventType = "";
|
|
65
|
+
let dataStr = "";
|
|
66
|
+
for (const line of frame.split("\n")) {
|
|
67
|
+
if (line.startsWith("event: ")) eventType = line.slice(7).trim();
|
|
68
|
+
else if (line.startsWith("data: ")) dataStr = line.slice(6).trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!eventType || !dataStr || dataStr === "[DONE]") continue;
|
|
72
|
+
|
|
73
|
+
let ev: any;
|
|
74
|
+
try {
|
|
75
|
+
ev = JSON.parse(dataStr);
|
|
76
|
+
} catch {
|
|
77
|
+
continue; // malformed frame: skip, keep streaming
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
switch (eventType) {
|
|
81
|
+
case "response.output_item.added": {
|
|
82
|
+
const item = ev.item;
|
|
83
|
+
if (!item) break;
|
|
84
|
+
|
|
85
|
+
if (item.type === "reasoning") {
|
|
86
|
+
// No Anthropic equivalent; skip. output_item.done will no-op via idx === undefined.
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
yield* closeOpen();
|
|
91
|
+
const idx = nextIndex++;
|
|
92
|
+
itemIdToIndex.set(item.id, idx);
|
|
93
|
+
openBlock = idx;
|
|
94
|
+
|
|
95
|
+
if (item.type === "function_call") {
|
|
96
|
+
hadToolUse = true;
|
|
97
|
+
yield sse("content_block_start", {
|
|
98
|
+
type: "content_block_start",
|
|
99
|
+
index: idx,
|
|
100
|
+
content_block: {
|
|
101
|
+
type: "tool_use",
|
|
102
|
+
id: item.call_id, // Anthropic tool_use id matches the call_id we sent in
|
|
103
|
+
name: item.name ?? "",
|
|
104
|
+
input: {},
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
} else {
|
|
108
|
+
yield sse("content_block_start", {
|
|
109
|
+
type: "content_block_start",
|
|
110
|
+
index: idx,
|
|
111
|
+
content_block: { type: "text", text: "" },
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
case "response.output_text.delta": {
|
|
118
|
+
const idx = itemIdToIndex.get(ev.item_id);
|
|
119
|
+
if (idx === undefined) break;
|
|
120
|
+
if (openBlock !== idx) {
|
|
121
|
+
yield* closeOpen();
|
|
122
|
+
openBlock = idx;
|
|
123
|
+
}
|
|
124
|
+
if (ev.delta) {
|
|
125
|
+
emittedDelta.add(ev.item_id);
|
|
126
|
+
yield sse("content_block_delta", {
|
|
127
|
+
type: "content_block_delta",
|
|
128
|
+
index: idx,
|
|
129
|
+
delta: { type: "text_delta", text: ev.delta },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case "response.output_text.done": {
|
|
136
|
+
// 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 (idx !== undefined) {
|
|
140
|
+
yield sse("content_block_delta", {
|
|
141
|
+
type: "content_block_delta",
|
|
142
|
+
index: idx,
|
|
143
|
+
delta: { type: "text_delta", text: ev.text },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
case "response.function_call_arguments.delta": {
|
|
151
|
+
const idx = itemIdToIndex.get(ev.item_id);
|
|
152
|
+
if (idx === undefined) break;
|
|
153
|
+
if (openBlock !== idx) {
|
|
154
|
+
yield* closeOpen();
|
|
155
|
+
openBlock = idx;
|
|
156
|
+
}
|
|
157
|
+
if (ev.delta) {
|
|
158
|
+
emittedDelta.add(ev.item_id);
|
|
159
|
+
yield sse("content_block_delta", {
|
|
160
|
+
type: "content_block_delta",
|
|
161
|
+
index: idx,
|
|
162
|
+
delta: { type: "input_json_delta", partial_json: ev.delta },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case "response.function_call_arguments.done": {
|
|
169
|
+
// 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 (idx !== undefined) {
|
|
173
|
+
yield sse("content_block_delta", {
|
|
174
|
+
type: "content_block_delta",
|
|
175
|
+
index: idx,
|
|
176
|
+
delta: { type: "input_json_delta", partial_json: ev.arguments },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
case "response.output_item.done": {
|
|
184
|
+
const item = ev.item;
|
|
185
|
+
const idx = item ? itemIdToIndex.get(item.id) : undefined;
|
|
186
|
+
if (idx !== undefined) {
|
|
187
|
+
yield sse("content_block_stop", { type: "content_block_stop", index: idx });
|
|
188
|
+
if (openBlock === idx) openBlock = null;
|
|
189
|
+
}
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
case "response.reasoning_summary_text.delta":
|
|
194
|
+
break; // no Anthropic equivalent; drop
|
|
195
|
+
|
|
196
|
+
case "response.completed": {
|
|
197
|
+
const response = ev.response;
|
|
198
|
+
if (response?.usage) {
|
|
199
|
+
inTokens = response.usage.input_tokens ?? 0;
|
|
200
|
+
outTokens = response.usage.output_tokens ?? 0;
|
|
201
|
+
}
|
|
202
|
+
stopReason = responsesStatusToStopReason(response?.status, response?.incomplete_details);
|
|
203
|
+
// Tool calls need "tool_use" stop_reason so the agent loop continues.
|
|
204
|
+
if (response?.status === "completed" && !response?.incomplete_details && hadToolUse) {
|
|
205
|
+
stopReason = "tool_use";
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
case "error":
|
|
211
|
+
onAbort();
|
|
212
|
+
break;
|
|
213
|
+
|
|
214
|
+
default:
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
onAbort();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
yield* closeOpen();
|
|
224
|
+
onUsage({ input_tokens: inTokens, output_tokens: outTokens }); // raw, for cost tracking
|
|
225
|
+
yield sse("message_delta", {
|
|
226
|
+
type: "message_delta",
|
|
227
|
+
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
228
|
+
usage: { input_tokens: Math.round(inTokens * factor), output_tokens: outTokens },
|
|
229
|
+
});
|
|
230
|
+
yield sse("message_stop", { type: "message_stop" });
|
|
231
|
+
}
|
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { CONFIG_DIR } from "./env.js";
|
|
4
|
+
|
|
5
|
+
// Each `ccmux claude` launcher writes an empty file named by its PID here
|
|
6
|
+
// while its Claude Code session is alive, and removes it on exit. Launcher
|
|
7
|
+
// lifetime equals session lifetime, so PID liveness is session liveness.
|
|
8
|
+
export const SESSIONS_DIR = path.join(CONFIG_DIR, "sessions");
|
|
9
|
+
|
|
10
|
+
// Count live sessions, pruning stale PID files via kill(pid, 0).
|
|
11
|
+
export function liveSessions(dir = SESSIONS_DIR): number {
|
|
12
|
+
let files: string[];
|
|
13
|
+
try {
|
|
14
|
+
files = fs.readdirSync(dir);
|
|
15
|
+
} catch {
|
|
16
|
+
return 0; // dir not created yet = no sessions
|
|
17
|
+
}
|
|
18
|
+
let live = 0;
|
|
19
|
+
for (const f of files) {
|
|
20
|
+
const pid = Number(f);
|
|
21
|
+
let alive = false;
|
|
22
|
+
if (pid > 0) {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
alive = true;
|
|
26
|
+
} catch {
|
|
27
|
+
/* dead */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (alive) live++;
|
|
31
|
+
else {
|
|
32
|
+
try {
|
|
33
|
+
fs.unlinkSync(path.join(dir, f));
|
|
34
|
+
} catch {
|
|
35
|
+
/* race with another reaper — fine */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return live;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Managed mode (`ccmux claude`): self-exit after the grace window with no live
|
|
43
|
+
// sessions. A session restarting within the window reuses the warm proxy.
|
|
44
|
+
// `ccmux serve` / hook-started proxies don't set CCMUX_MANAGED and stay up.
|
|
45
|
+
export function startIdleReaper(
|
|
46
|
+
exit: () => void = () => process.exit(0),
|
|
47
|
+
count: () => number = liveSessions, // injectable for tests
|
|
48
|
+
): void {
|
|
49
|
+
if (process.env.CCMUX_MANAGED !== "1") return;
|
|
50
|
+
const grace = Number(process.env.CCMUX_IDLE_GRACE_MS ?? 120_000);
|
|
51
|
+
const boot = Date.now();
|
|
52
|
+
let emptySince = 0;
|
|
53
|
+
const t = setInterval(() => {
|
|
54
|
+
if (count() > 0) {
|
|
55
|
+
emptySince = 0;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (Date.now() - boot < grace) return; // startup grace window
|
|
59
|
+
if (!emptySince) emptySince = Date.now();
|
|
60
|
+
if (Date.now() - emptySince >= grace) {
|
|
61
|
+
console.log("ccmux: no active sessions; stopping idle proxy");
|
|
62
|
+
exit();
|
|
63
|
+
}
|
|
64
|
+
}, Math.min(30_000, grace)); // cap tick at 30s so small grace values (tests) tick faster
|
|
65
|
+
t.unref();
|
|
66
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { mapStopReason, THINKING_SIG } from "./models.js";
|
|
2
|
+
|
|
3
|
+
export function sse(event: string, data: unknown): string {
|
|
4
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// OpenAI SSE stream -> Anthropic SSE event protocol.
|
|
8
|
+
// Invariants: monotonic block indices, tool index map, RAW partial_json
|
|
9
|
+
// fragments (never parse/re-stringify), close-before-open, exactly one
|
|
10
|
+
// message_start/message_stop, upstream death -> close cleanly, never hang.
|
|
11
|
+
export async function* translateStream(
|
|
12
|
+
upstream: ReadableStream<Uint8Array>,
|
|
13
|
+
onUsage: (usage: { input_tokens: number; output_tokens: number }) => void = () => {},
|
|
14
|
+
factor = 1, // context-scale factor for client-facing input counts
|
|
15
|
+
onAbort: () => void = () => {}, // called when upstream dies mid-stream
|
|
16
|
+
): AsyncGenerator<string> {
|
|
17
|
+
const msgId = "msg_" + Date.now();
|
|
18
|
+
yield sse("message_start", {
|
|
19
|
+
type: "message_start",
|
|
20
|
+
message: {
|
|
21
|
+
id: msgId,
|
|
22
|
+
type: "message",
|
|
23
|
+
role: "assistant",
|
|
24
|
+
model: "",
|
|
25
|
+
content: [],
|
|
26
|
+
stop_reason: null,
|
|
27
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
// Anthropic sends a ping right after message_start; some clients expect early bytes.
|
|
31
|
+
yield sse("ping", { type: "ping" });
|
|
32
|
+
|
|
33
|
+
const reader = upstream.getReader();
|
|
34
|
+
const decoder = new TextDecoder();
|
|
35
|
+
let buf = "";
|
|
36
|
+
|
|
37
|
+
let nextIndex = 0;
|
|
38
|
+
let textIndex: number | null = null; // index of the open text block, if any
|
|
39
|
+
let thinkingIndex: number | null = null; // index of the thinking block (Ollama reasoning), if any
|
|
40
|
+
const toolIndexMap = new Map<number, number>(); // openai tool index -> anthropic block index
|
|
41
|
+
let openBlock: number | null = null; // currently open anthropic block index
|
|
42
|
+
let stopReason = "end_turn";
|
|
43
|
+
let outTokens = 0;
|
|
44
|
+
let inTokens = 0;
|
|
45
|
+
|
|
46
|
+
const closeOpen = function* () {
|
|
47
|
+
if (openBlock !== null) {
|
|
48
|
+
// A thinking block must carry a signature; emit it as the block's last event.
|
|
49
|
+
if (openBlock === thinkingIndex)
|
|
50
|
+
yield sse("content_block_delta", {
|
|
51
|
+
type: "content_block_delta",
|
|
52
|
+
index: thinkingIndex,
|
|
53
|
+
delta: { type: "signature_delta", signature: THINKING_SIG },
|
|
54
|
+
});
|
|
55
|
+
yield sse("content_block_stop", { type: "content_block_stop", index: openBlock });
|
|
56
|
+
openBlock = null;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
while (true) {
|
|
62
|
+
const { done, value } = await reader.read();
|
|
63
|
+
if (done) break;
|
|
64
|
+
buf += decoder.decode(value, { stream: true });
|
|
65
|
+
const frames = buf.split("\n\n");
|
|
66
|
+
buf = frames.pop() ?? "";
|
|
67
|
+
|
|
68
|
+
for (const frame of frames) {
|
|
69
|
+
const line = frame.replace(/^data:\s*/, "").trim();
|
|
70
|
+
if (!line || line === "[DONE]") continue;
|
|
71
|
+
let chunk: any;
|
|
72
|
+
try {
|
|
73
|
+
chunk = JSON.parse(line);
|
|
74
|
+
} catch {
|
|
75
|
+
continue; // malformed frame: skip, keep streaming
|
|
76
|
+
}
|
|
77
|
+
// Usage arrives on a final chunk whose choices array is EMPTY; capture
|
|
78
|
+
// it before the choice guard or output_tokens stays 0.
|
|
79
|
+
if (chunk.usage?.completion_tokens) outTokens = chunk.usage.completion_tokens;
|
|
80
|
+
if (chunk.usage?.prompt_tokens) inTokens = chunk.usage.prompt_tokens;
|
|
81
|
+
const choice = chunk.choices?.[0];
|
|
82
|
+
if (!choice) continue;
|
|
83
|
+
const delta = choice.delta ?? {};
|
|
84
|
+
|
|
85
|
+
// ---- thinking (Ollama reasoning) ----
|
|
86
|
+
// Surface delta.reasoning as an Anthropic thinking block; never reopen a stopped index.
|
|
87
|
+
if (delta.reasoning) {
|
|
88
|
+
if (thinkingIndex === null || openBlock !== thinkingIndex) {
|
|
89
|
+
yield* closeOpen();
|
|
90
|
+
thinkingIndex = nextIndex++;
|
|
91
|
+
openBlock = thinkingIndex;
|
|
92
|
+
yield sse("content_block_start", {
|
|
93
|
+
type: "content_block_start",
|
|
94
|
+
index: thinkingIndex,
|
|
95
|
+
content_block: { type: "thinking", thinking: "" },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
yield sse("content_block_delta", {
|
|
99
|
+
type: "content_block_delta",
|
|
100
|
+
index: thinkingIndex,
|
|
101
|
+
delta: { type: "thinking_delta", thinking: delta.reasoning },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- text ----
|
|
106
|
+
if (delta.content) {
|
|
107
|
+
if (textIndex === null) {
|
|
108
|
+
yield* closeOpen();
|
|
109
|
+
textIndex = nextIndex++;
|
|
110
|
+
openBlock = textIndex;
|
|
111
|
+
yield sse("content_block_start", {
|
|
112
|
+
type: "content_block_start",
|
|
113
|
+
index: textIndex,
|
|
114
|
+
content_block: { type: "text", text: "" },
|
|
115
|
+
});
|
|
116
|
+
} else if (openBlock !== textIndex) {
|
|
117
|
+
yield* closeOpen();
|
|
118
|
+
openBlock = textIndex;
|
|
119
|
+
}
|
|
120
|
+
yield sse("content_block_delta", {
|
|
121
|
+
type: "content_block_delta",
|
|
122
|
+
index: textIndex,
|
|
123
|
+
delta: { type: "text_delta", text: delta.content },
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- tool calls ----
|
|
128
|
+
for (const tc of delta.tool_calls ?? []) {
|
|
129
|
+
const oaIdx = tc.index ?? 0;
|
|
130
|
+
let aIdx = toolIndexMap.get(oaIdx);
|
|
131
|
+
if (aIdx === undefined) {
|
|
132
|
+
yield* closeOpen();
|
|
133
|
+
textIndex = null; // a new tool block ends the text run
|
|
134
|
+
aIdx = nextIndex++;
|
|
135
|
+
toolIndexMap.set(oaIdx, aIdx);
|
|
136
|
+
openBlock = aIdx;
|
|
137
|
+
yield sse("content_block_start", {
|
|
138
|
+
type: "content_block_start",
|
|
139
|
+
index: aIdx,
|
|
140
|
+
content_block: { type: "tool_use", id: tc.id, name: tc.function?.name ?? "", input: {} },
|
|
141
|
+
});
|
|
142
|
+
} else if (openBlock !== aIdx) {
|
|
143
|
+
yield* closeOpen();
|
|
144
|
+
openBlock = aIdx;
|
|
145
|
+
}
|
|
146
|
+
const argFrag = tc.function?.arguments;
|
|
147
|
+
if (argFrag) {
|
|
148
|
+
yield sse("content_block_delta", {
|
|
149
|
+
type: "content_block_delta",
|
|
150
|
+
index: aIdx,
|
|
151
|
+
delta: { type: "input_json_delta", partial_json: argFrag },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (choice.finish_reason) stopReason = mapStopReason(choice.finish_reason);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
onAbort();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
yield* closeOpen();
|
|
164
|
+
onUsage({ input_tokens: inTokens, output_tokens: outTokens }); // raw, for cost tracking
|
|
165
|
+
yield sse("message_delta", {
|
|
166
|
+
type: "message_delta",
|
|
167
|
+
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
168
|
+
// input_tokens scaled so Claude Code's context tracking matches the serving model's prompt limit.
|
|
169
|
+
usage: { input_tokens: Math.round(inTokens * factor), output_tokens: outTokens },
|
|
170
|
+
});
|
|
171
|
+
yield sse("message_stop", { type: "message_stop" });
|
|
172
|
+
}
|
package/src/token.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CONFIG_DIR } from "./env.js";
|
|
5
|
+
import { TOKEN_EXCHANGE_URL, COPILOT_HEADERS } from "./upstream.js";
|
|
6
|
+
|
|
7
|
+
// Copilot plugin OAuth app id used for device flow. Unofficial; community-standard.
|
|
8
|
+
const DEVICE_CLIENT_ID = "Iv1.b507a08c87ecfe98";
|
|
9
|
+
|
|
10
|
+
let copilotToken: string | null = null;
|
|
11
|
+
let expEpoch = 0;
|
|
12
|
+
let ghToken: string | null = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN ?? null;
|
|
13
|
+
|
|
14
|
+
// Exchange a GitHub token for a short-lived Copilot bearer.
|
|
15
|
+
// Returns null on 401/403/404 so the caller can try the next candidate.
|
|
16
|
+
async function exchange(gh: string): Promise<{ token: string; expires_at: number } | null> {
|
|
17
|
+
const res = await fetch(TOKEN_EXCHANGE_URL, {
|
|
18
|
+
headers: { Authorization: `token ${gh}`, "User-Agent": COPILOT_HEADERS["User-Agent"] },
|
|
19
|
+
});
|
|
20
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) return null;
|
|
21
|
+
if (!res.ok) throw new Error(`token exchange ${res.status}: ${await res.text()}`);
|
|
22
|
+
return (await res.json()) as { token: string; expires_at: number };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Returns GitHub tokens previously stored by VS Code's Copilot sign-in.
|
|
26
|
+
function appsJsonTokens(): string[] {
|
|
27
|
+
const tokens: string[] = [];
|
|
28
|
+
for (const file of ["apps.json", "hosts.json"]) {
|
|
29
|
+
try {
|
|
30
|
+
const p = path.join(os.homedir(), ".config", "github-copilot", file);
|
|
31
|
+
const data = JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, any>;
|
|
32
|
+
for (const v of Object.values(data)) if (v?.oauth_token) tokens.push(v.oauth_token);
|
|
33
|
+
} catch {
|
|
34
|
+
/* file missing — fine */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return tokens;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function deviceFlow(): Promise<string> {
|
|
41
|
+
const dc = (await fetch("https://github.com/login/device/code", {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
44
|
+
body: JSON.stringify({ client_id: DEVICE_CLIENT_ID, scope: "read:user" }),
|
|
45
|
+
}).then((r) => r.json())) as any;
|
|
46
|
+
if (!dc.device_code) throw new Error(`device flow start failed: ${JSON.stringify(dc)}`);
|
|
47
|
+
|
|
48
|
+
console.log(`\n Copilot login required: open ${dc.verification_uri} and enter code ${dc.user_code}\n`);
|
|
49
|
+
let interval = (dc.interval ?? 5) * 1000;
|
|
50
|
+
while (true) {
|
|
51
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
52
|
+
const res = (await fetch("https://github.com/login/oauth/access_token", {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
55
|
+
body: JSON.stringify({
|
|
56
|
+
client_id: DEVICE_CLIENT_ID,
|
|
57
|
+
device_code: dc.device_code,
|
|
58
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
59
|
+
}),
|
|
60
|
+
}).then((r) => r.json())) as any;
|
|
61
|
+
if (res.access_token) return res.access_token;
|
|
62
|
+
if (res.error === "slow_down") interval += 5000;
|
|
63
|
+
else if (res.error && res.error !== "authorization_pending")
|
|
64
|
+
throw new Error(`device flow: ${res.error}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function persistToken(gh: string): void {
|
|
69
|
+
try {
|
|
70
|
+
// 0o700 on the dir prevents a first-create race from leaving it world-readable.
|
|
71
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
72
|
+
try { fs.chmodSync(CONFIG_DIR, 0o700); } catch { /* fs ignores mode */ }
|
|
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.
|
|
76
|
+
try { fs.chmodSync(p, 0o600); } catch { /* non-posix fs */ }
|
|
77
|
+
console.log(`ccmux: saved GH_TOKEN to ${p} (one-time login)`);
|
|
78
|
+
} catch {
|
|
79
|
+
console.warn("could not persist GH_TOKEN; device flow will rerun next start");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fallback chain: env token -> apps.json -> device flow.
|
|
84
|
+
// Caches the Copilot bearer and refreshes ~2 minutes before expiry.
|
|
85
|
+
export async function getCopilotToken(): Promise<string> {
|
|
86
|
+
if (copilotToken && Date.now() / 1000 < expEpoch - 120) return copilotToken;
|
|
87
|
+
|
|
88
|
+
const candidates = [ghToken, ...appsJsonTokens()].filter(Boolean) as string[];
|
|
89
|
+
for (const gh of candidates) {
|
|
90
|
+
const data = await exchange(gh);
|
|
91
|
+
if (data) {
|
|
92
|
+
ghToken = gh;
|
|
93
|
+
copilotToken = data.token;
|
|
94
|
+
expEpoch = data.expires_at;
|
|
95
|
+
return copilotToken;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!process.stdout.isTTY) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
"no usable GitHub token (env GH_TOKEN rejected or missing, no apps.json) " +
|
|
102
|
+
"and cannot run device flow without a terminal — run `ccmux login` in a terminal once",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const gh = await deviceFlow();
|
|
106
|
+
const data = await exchange(gh);
|
|
107
|
+
if (!data) throw new Error("device-flow token failed the Copilot exchange; is Copilot enabled on this account?");
|
|
108
|
+
ghToken = gh;
|
|
109
|
+
copilotToken = data.token;
|
|
110
|
+
expEpoch = data.expires_at;
|
|
111
|
+
persistToken(gh);
|
|
112
|
+
return copilotToken;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Returns the GitHub OAuth token after running the exchange chain (for api.github.com calls).
|
|
116
|
+
export async function getGitHubToken(): Promise<string | null> {
|
|
117
|
+
await getCopilotToken().catch(() => {});
|
|
118
|
+
return ghToken;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Startup hint only; never throws. Device flow may still succeed on first request.
|
|
122
|
+
export function tokenStartupCheck(): void {
|
|
123
|
+
if (!ghToken && appsJsonTokens().length === 0) {
|
|
124
|
+
console.warn(
|
|
125
|
+
process.stdout.isTTY
|
|
126
|
+
? "no GitHub token found (env/apps.json) — device flow will start on first request"
|
|
127
|
+
: "no GitHub token found (env/apps.json) — run `ccmux login` in a terminal to authenticate",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|