@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.
@@ -0,0 +1,142 @@
1
+ import { mapModel, clampMaxTokens, resolveEffort } from "./models.js";
2
+ import type { AnthropicBody, AnthropicMessage, TranslateFlags } from "./types.js";
3
+
4
+ // Collapse an Anthropic system prompt (string | text-block[]) to a plain string.
5
+ export function extractSystemText(system: AnthropicBody["system"]): string {
6
+ if (typeof system === "string") return system;
7
+ return (system ?? []).map((b: any) => b.text ?? "").join("\n");
8
+ }
9
+
10
+ // Last message carries a tool_result -> agent-initiated turn. Copilot accounts
11
+ // these differently (X-Initiator header); shared by both request translators.
12
+ export function isAgentTurn(messages: AnthropicMessage[] | undefined): boolean {
13
+ const last = messages?.[messages.length - 1];
14
+ return Array.isArray(last?.content) && last.content.some((b: any) => b.type === "tool_result");
15
+ }
16
+
17
+ // Anthropic Messages body -> OpenAI Chat Completions body.
18
+ // Anthropic-only fields (cache_control, thinking, metadata, betas) are stripped
19
+ // by construction: only known fields are copied onto fresh objects.
20
+ export function anthropicToOpenAI(body: AnthropicBody): { payload: any; flags: TranslateFlags } {
21
+ const messages: any[] = [];
22
+ const flags: TranslateFlags = { vision: false, agent: false };
23
+
24
+ if (body.system) {
25
+ const sys = extractSystemText(body.system);
26
+ if (sys) messages.push({ role: "system", content: sys });
27
+ }
28
+
29
+ for (const msg of body.messages ?? []) {
30
+ if (typeof msg.content === "string") {
31
+ if (msg.content) messages.push({ role: msg.role, content: msg.content });
32
+ continue;
33
+ }
34
+ if (!Array.isArray(msg.content) || msg.content.length === 0) continue;
35
+
36
+ const parts: any[] = []; // text / image_url content parts
37
+ const toolCalls: any[] = [];
38
+ const toolMsgs: any[] = [];
39
+
40
+ for (const block of msg.content) {
41
+ switch (block.type) {
42
+ case "text":
43
+ parts.push({ type: "text", text: block.text });
44
+ break;
45
+ case "image":
46
+ flags.vision = true;
47
+ parts.push({
48
+ type: "image_url",
49
+ image_url: { url: `data:${block.source?.media_type};base64,${block.source?.data}` },
50
+ });
51
+ break;
52
+ case "tool_use":
53
+ toolCalls.push({
54
+ id: block.id,
55
+ type: "function",
56
+ function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) },
57
+ });
58
+ break;
59
+ case "tool_result": {
60
+ const content = Array.isArray(block.content)
61
+ ? block.content
62
+ .filter((p: any) => p.type === "text")
63
+ .map((p: any) => p.text)
64
+ .join("\n")
65
+ : (block.content ?? "");
66
+ if (Array.isArray(block.content) && block.content.some((p: any) => p.type === "image"))
67
+ console.warn("tool_result image dropped (no OpenAI tool-message equivalent)");
68
+ toolMsgs.push({ role: "tool", tool_call_id: block.tool_use_id, content });
69
+ break;
70
+ }
71
+ default:
72
+ break; // thinking / redacted_thinking / unknown: drop
73
+ }
74
+ }
75
+
76
+ // OpenAI tool messages must directly follow the assistant tool_calls message.
77
+ messages.push(...toolMsgs);
78
+
79
+ const text = parts.filter((p) => p.type === "text").map((p) => p.text).join("");
80
+ const hasImage = parts.some((p) => p.type === "image_url");
81
+ if (msg.role === "assistant") {
82
+ if (text || toolCalls.length) {
83
+ const m: any = { role: "assistant", content: text || null }; // null, not "" per OpenAI spec
84
+ if (toolCalls.length) m.tool_calls = toolCalls;
85
+ messages.push(m);
86
+ }
87
+ } else if (hasImage) {
88
+ messages.push({ role: msg.role, content: parts });
89
+ } else if (text) {
90
+ messages.push({ role: msg.role, content: text });
91
+ }
92
+ }
93
+
94
+ flags.agent = isAgentTurn(body.messages);
95
+
96
+ const model = mapModel(body.model ?? "");
97
+ const payload: any = {
98
+ model,
99
+ messages,
100
+ max_tokens: clampMaxTokens(model, body.max_tokens ?? 4096),
101
+ };
102
+ if (body.temperature !== undefined) payload.temperature = body.temperature;
103
+ if (body.stream) {
104
+ payload.stream = true;
105
+ payload.stream_options = { include_usage: true }; // omitting this leaves output_tokens at 0
106
+ }
107
+ if (body.stop_sequences?.length) payload.stop = body.stop_sequences;
108
+
109
+ // Keep only client tools; server tools (web_search_20250305, etc.) have no input_schema and 400 upstream.
110
+ const allTools = body.tools ?? [];
111
+ let tools = allTools.filter((t: any) => !t.type || t.type === "custom");
112
+ if (tools.length !== allTools.length)
113
+ console.warn(`dropped ${allTools.length - tools.length} server tool(s)`);
114
+ if (tools.length > 128) {
115
+ // OpenAI-format endpoint hard-caps tools at 128; Claude Code lists built-ins before MCP tools.
116
+ console.warn(`tool list capped at 128 (was ${tools.length}) for OpenAI-format upstream`);
117
+ tools = tools.slice(0, 128);
118
+ }
119
+ if (tools.length) {
120
+ payload.tools = tools.map((t: any) => ({
121
+ type: "function",
122
+ function: { name: t.name, description: t.description, parameters: t.input_schema },
123
+ }));
124
+ }
125
+
126
+ // Only forward reasoning_effort when the catalog confirms support; some Copilot
127
+ // endpoints 400 on unknown fields, and catalog absence means we can't confirm.
128
+ const effort = resolveEffort(model, body.output_config?.effort);
129
+ if (effort !== undefined) payload.reasoning_effort = effort;
130
+
131
+ if (body.tool_choice && tools.length) {
132
+ const tc = body.tool_choice;
133
+ payload.tool_choice =
134
+ tc.type === "any"
135
+ ? "required"
136
+ : tc.type === "tool"
137
+ ? { type: "function", function: { name: tc.name } }
138
+ : "auto";
139
+ }
140
+
141
+ return { payload, flags };
142
+ }
@@ -0,0 +1,39 @@
1
+ import { mapStopReason, THINKING_SIG } from "./models.js";
2
+
3
+ // OpenAI Chat Completions response -> Anthropic Messages response (non-streaming).
4
+ export function openAIToAnthropic(data: any) {
5
+ const choice = data.choices?.[0] ?? { message: {} };
6
+ const content: any[] = [];
7
+ // Ollama thinking models return chain-of-thought in message.reasoning; map to
8
+ // an Anthropic thinking block with a placeholder signature, ahead of the text.
9
+ if (choice.message?.reasoning)
10
+ content.push({ type: "thinking", thinking: choice.message.reasoning, signature: THINKING_SIG });
11
+ if (choice.message?.content) content.push({ type: "text", text: choice.message.content });
12
+ for (const tc of choice.message?.tool_calls ?? []) {
13
+ let parsedInput: any = {};
14
+ try {
15
+ parsedInput = JSON.parse(tc.function.arguments || "{}");
16
+ } catch {
17
+ console.warn(`ccmux: failed to parse tool_call arguments for ${tc.function?.name}; using {}`);
18
+ }
19
+ content.push({
20
+ type: "tool_use",
21
+ id: tc.id,
22
+ name: tc.function.name,
23
+ input: parsedInput,
24
+ });
25
+ }
26
+ return {
27
+ id: data.id ?? "msg_unknown",
28
+ type: "message",
29
+ role: "assistant",
30
+ model: data.model ?? "",
31
+ content,
32
+ stop_reason: mapStopReason(choice.finish_reason ?? null),
33
+ stop_sequence: null,
34
+ usage: {
35
+ input_tokens: data.usage?.prompt_tokens ?? 0,
36
+ output_tokens: data.usage?.completion_tokens ?? 0,
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,252 @@
1
+ import { mapModel, clampMaxTokens, resolveEffort } from "./models.js";
2
+ import { extractSystemText } from "./translate-request.js";
3
+ import type { AnthropicBody } from "./types.js";
4
+
5
+ // Thrown for fields that have no /responses equivalent and must not be silently dropped. Caller returns HTTP 400.
6
+ export class ResponsesApiUnsupported extends Error {
7
+ constructor(msg: string) {
8
+ super(msg);
9
+ this.name = "ResponsesApiUnsupported";
10
+ }
11
+ }
12
+
13
+ // Anthropic Messages body -> OpenAI Responses API body (POST .../responses).
14
+ // Key differences from Chat Completions: input[] not messages[], instructions not
15
+ // system, max_output_tokens, flat tool schema, tool_result -> function_call_output,
16
+ // reasoning.effort not reasoning_effort.
17
+ export function anthropicToResponses(body: AnthropicBody): any {
18
+ if (body.stop_sequences?.length) {
19
+ throw new ResponsesApiUnsupported(
20
+ "stop_sequences is not supported by /responses models; remove it or use a different model",
21
+ );
22
+ }
23
+
24
+ if ((body as any).top_k !== undefined) {
25
+ console.warn("ccmux: top_k has no /responses equivalent and will be dropped");
26
+ }
27
+
28
+ const model = mapModel(body.model ?? "");
29
+ const input: any[] = [];
30
+
31
+ const systemText = extractSystemText(body.system);
32
+
33
+ // Consecutive text blocks from the same role merge into one input item;
34
+ // function_call and function_call_output are standalone items (Responses API
35
+ // infers role from item type for those).
36
+ for (const msg of body.messages ?? []) {
37
+ if (typeof msg.content === "string") {
38
+ if (msg.content) input.push({ role: msg.role, content: msg.content });
39
+ continue;
40
+ }
41
+ if (!Array.isArray(msg.content) || msg.content.length === 0) continue;
42
+
43
+ let pendingText = ""; // accumulated text for the current role
44
+
45
+ const flushText = () => {
46
+ if (pendingText) {
47
+ input.push({ role: msg.role, content: pendingText });
48
+ pendingText = "";
49
+ }
50
+ };
51
+
52
+ for (const block of msg.content) {
53
+ switch (block.type) {
54
+ case "text":
55
+ pendingText += block.text ?? "";
56
+ break;
57
+
58
+ case "tool_use":
59
+ flushText();
60
+ // id = item id for streaming delta routing; call_id = the Anthropic tool_use id.
61
+ // Responses API preserves whatever call_id you provide, including toolu_* ids.
62
+ input.push({
63
+ type: "function_call",
64
+ id: block.id,
65
+ call_id: block.id,
66
+ name: block.name,
67
+ arguments: JSON.stringify(block.input ?? {}),
68
+ });
69
+ break;
70
+
71
+ case "tool_result":
72
+ flushText();
73
+ // call_id maps 1:1 to tool_use_id; Responses API is stateless and never reassigns call_ids.
74
+ {
75
+ let outputText: string;
76
+ if (Array.isArray(block.content)) {
77
+ let hasImage = false;
78
+ const parts: string[] = [];
79
+ for (const p of block.content) {
80
+ if (p.type === "text") parts.push(p.text ?? "");
81
+ else if (p.type === "image") hasImage = true;
82
+ }
83
+ if (hasImage)
84
+ console.warn(
85
+ "ccmux: tool_result image content dropped; function_call_output only accepts string output",
86
+ );
87
+ outputText = parts.join("\n");
88
+ } else {
89
+ outputText = block.content ?? "";
90
+ }
91
+ if (block.is_error) outputText = "[tool_error] " + outputText;
92
+ input.push({
93
+ type: "function_call_output",
94
+ call_id: block.tool_use_id,
95
+ output: outputText,
96
+ });
97
+ }
98
+ break;
99
+
100
+ case "image": {
101
+ flushText(); // flush before image so it appears in its own input item
102
+ const src = block.source;
103
+ if (src?.type === "url") {
104
+ input.push({ role: msg.role, content: [{ type: "input_image", image_url: src.url }] });
105
+ } else if (src?.type === "base64") {
106
+ const dataUrl = `data:${src.media_type};base64,${src.data}`;
107
+ input.push({ role: msg.role, content: [{ type: "input_image", image_url: dataUrl }] });
108
+ } else {
109
+ console.warn("ccmux: unsupported image source type; image block dropped");
110
+ }
111
+ break;
112
+ }
113
+
114
+ case "thinking":
115
+ case "redacted_thinking":
116
+ break; // no /responses equivalent; drop
117
+
118
+ default:
119
+ break;
120
+ }
121
+ }
122
+
123
+ flushText();
124
+ }
125
+
126
+ const payload: any = {
127
+ model,
128
+ input,
129
+ max_output_tokens: clampMaxTokens(model, body.max_tokens ?? 4096),
130
+ };
131
+
132
+ if (systemText) payload.instructions = systemText;
133
+ if (body.stream) payload.stream = true;
134
+ if (body.temperature !== undefined) payload.temperature = body.temperature;
135
+ if ((body as any).top_p !== undefined) payload.top_p = (body as any).top_p;
136
+
137
+ // Tool schema is flat (no function wrapper); parameters renamed from input_schema.
138
+ const allTools = body.tools ?? [];
139
+ const tools = allTools.filter((t: any) => !t.type || t.type === "custom");
140
+ if (tools.length !== allTools.length) {
141
+ console.warn(
142
+ `ccmux: dropped ${allTools.length - tools.length} server tool(s) for /responses`,
143
+ );
144
+ }
145
+ if (tools.length) {
146
+ payload.tools = tools.map((t: any) => ({
147
+ type: "function",
148
+ name: t.name,
149
+ description: t.description,
150
+ parameters: t.input_schema,
151
+ }));
152
+ }
153
+
154
+ // tool_choice: Anthropic {type:"auto"|"any"|"tool",name?} -> Responses API "auto"|"required"|{type:"function",name}.
155
+ const tc = (body as any).tool_choice;
156
+ if (tc) {
157
+ if (tc.type === "auto") {
158
+ payload.tool_choice = "auto";
159
+ } else if (tc.type === "any") {
160
+ payload.tool_choice = "required";
161
+ } else if (tc.type === "tool" && tc.name) {
162
+ payload.tool_choice = { type: "function", name: tc.name };
163
+ } else if (tc.type === "none") {
164
+ payload.tool_choice = "none";
165
+ }
166
+ }
167
+
168
+ // Effort maps to reasoning.effort (nested object, unlike Chat Completions reasoning_effort).
169
+ const effort = resolveEffort(model, body.output_config?.effort);
170
+ if (effort !== undefined) payload.reasoning = { effort };
171
+
172
+ return payload;
173
+ }
174
+
175
+ // Maps Responses API status + incomplete_details to an Anthropic stop_reason.
176
+ export function responsesStatusToStopReason(
177
+ status: string | undefined,
178
+ incompleteDetails?: { reason?: string },
179
+ ): string {
180
+ if (incompleteDetails?.reason === "max_output_tokens") return "max_tokens";
181
+ // "failed" maps to "end_turn" because "error" is not a valid Anthropic stop_reason;
182
+ // upstream sends failed status in-band, not as an HTTP error.
183
+ const map: Record<string, string> = { completed: "end_turn", incomplete: "max_tokens", failed: "end_turn" };
184
+ return map[status ?? ""] ?? "end_turn";
185
+ }
186
+
187
+ // Non-streaming: OpenAI Responses API response -> Anthropic Messages response.
188
+ export function translateResponsesResponse(res: any): any {
189
+ const content: any[] = [];
190
+
191
+ for (const item of res.output ?? []) {
192
+ switch (item.type) {
193
+ case "message":
194
+ for (const part of item.content ?? []) {
195
+ if (part.type === "output_text") {
196
+ content.push({ type: "text", text: part.text });
197
+ } else if (part.type === "refusal") {
198
+ content.push({ type: "text", text: part.refusal });
199
+ }
200
+ }
201
+ break;
202
+
203
+ case "function_call":
204
+ {
205
+ let parsedInput: any = {};
206
+ try {
207
+ parsedInput = JSON.parse(item.arguments ?? "{}");
208
+ } catch {
209
+ console.warn(
210
+ `ccmux: failed to parse function_call arguments for ${item.name}; using {}`,
211
+ );
212
+ }
213
+ content.push({
214
+ type: "tool_use",
215
+ id: item.call_id,
216
+ name: item.name,
217
+ input: parsedInput,
218
+ });
219
+ }
220
+ break;
221
+
222
+ case "reasoning":
223
+ break; // no Anthropic equivalent; drop
224
+
225
+ default:
226
+ break;
227
+ }
228
+ }
229
+
230
+ let stopReason = responsesStatusToStopReason(res.status, res.incomplete_details);
231
+ // Override to "tool_use" so Claude Code enters the tool-execution loop.
232
+ if (
233
+ res.status === "completed" &&
234
+ !res.incomplete_details &&
235
+ content.some((b: any) => b.type === "tool_use")
236
+ ) {
237
+ stopReason = "tool_use";
238
+ }
239
+
240
+ return {
241
+ id: res.id ?? "msg_" + Date.now(),
242
+ type: "message",
243
+ role: "assistant",
244
+ content,
245
+ stop_reason: stopReason,
246
+ stop_sequence: null,
247
+ usage: {
248
+ input_tokens: res.usage?.input_tokens ?? 0, // Responses API already uses Anthropic-compatible field names
249
+ output_tokens: res.usage?.output_tokens ?? 0,
250
+ },
251
+ };
252
+ }
package/src/types.ts ADDED
@@ -0,0 +1,30 @@
1
+ // Shared types. Kept loose on purpose: the proxy passes through fields it
2
+ // doesn't understand and strips the ones upstream rejects (allowed-field rule).
3
+
4
+ export interface AnthropicContentBlock {
5
+ type: string; // text | image | tool_use | tool_result | thinking | redacted_thinking
6
+ [key: string]: any;
7
+ }
8
+
9
+ export interface AnthropicMessage {
10
+ role: "user" | "assistant";
11
+ content: string | AnthropicContentBlock[];
12
+ }
13
+
14
+ export interface AnthropicBody {
15
+ model: string;
16
+ max_tokens: number;
17
+ messages: AnthropicMessage[];
18
+ system?: string | AnthropicContentBlock[];
19
+ tools?: any[];
20
+ tool_choice?: { type: string; name?: string };
21
+ stop_sequences?: string[];
22
+ temperature?: number;
23
+ stream?: boolean;
24
+ [key: string]: any;
25
+ }
26
+
27
+ export interface TranslateFlags {
28
+ vision: boolean; // any image block present -> Copilot-Vision-Request header
29
+ agent: boolean; // last message contains a tool_result -> X-Initiator: agent
30
+ }
@@ -0,0 +1,107 @@
1
+ import "./env.js"; // load .env BEFORE any env reads below
2
+ import { getCopilotToken } from "./token.js";
3
+ import type { TranslateFlags } from "./types.js";
4
+
5
+ export const PORT = Number(process.env.PORT ?? 4141);
6
+
7
+ // Set USE_NATIVE=1 to use Path A (native pass-through). Default uses translation.
8
+ export const USE_NATIVE = process.env.USE_NATIVE === "1";
9
+
10
+ // ⚠️ UNOFFICIAL — not in GitHub's public API docs. Community defaults.
11
+ // Env-overridable so tests can point at a mock upstream.
12
+ export const TOKEN_EXCHANGE_URL =
13
+ process.env.TOKEN_EXCHANGE_URL ?? "https://api.github.com/copilot_internal/v2/token";
14
+ // Live plan/quota snapshot (token_based_billing, quota_snapshots, reset date).
15
+ export const COPILOT_USER_URL =
16
+ process.env.COPILOT_USER_URL ?? "https://api.github.com/copilot_internal/user";
17
+ export const COPILOT_BASE = process.env.COPILOT_BASE ?? "https://api.githubcopilot.com";
18
+
19
+ // Ollama Cloud: OpenAI-compatible backend, static API key (no exchange/expiry).
20
+ // Empty key = Ollama disabled.
21
+ export const OLLAMA_BASE = process.env.OLLAMA_BASE ?? "https://ollama.com/v1";
22
+ // Lazy read so a key saved by `ccmux login` is picked up without a module reload.
23
+ export const ollamaKey = (): string => process.env.OLLAMA_API_KEY ?? "";
24
+
25
+ // Client-identity headers Copilot's backend expects. Versions drift; if you get
26
+ // a 4xx that mentions editor/version, bump these.
27
+ export const COPILOT_HEADERS: Record<string, string> = {
28
+ "Copilot-Integration-Id": "vscode-chat",
29
+ "Editor-Version": "vscode/1.99.0",
30
+ "Editor-Plugin-Version": "copilot-chat/0.26.0",
31
+ "User-Agent": "GitHubCopilotChat/0.26.0",
32
+ "Openai-Intent": "conversation-panel",
33
+ "Content-Type": "application/json",
34
+ };
35
+
36
+ // Path B: send an OpenAI-format body to the chat endpoint.
37
+ export async function callCopilot(
38
+ openaiBody: unknown,
39
+ flags: Partial<TranslateFlags> = {},
40
+ provider: "copilot" | "ollama" = "copilot",
41
+ ): Promise<Response> {
42
+ // Strip the `ollama/` namespace prefix before sending on the wire.
43
+ if (provider === "ollama") {
44
+ const body = openaiBody as any;
45
+ const wire = { ...body, model: String(body.model ?? "").replace(/^ollama\//, "") };
46
+ return fetch(`${OLLAMA_BASE}/chat/completions`, {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${ollamaKey()}` },
49
+ body: JSON.stringify(wire),
50
+ });
51
+ }
52
+ const bearer = await getCopilotToken();
53
+ const headers: Record<string, string> = { ...COPILOT_HEADERS, Authorization: `Bearer ${bearer}` };
54
+ headers["X-Initiator"] = flags.agent ? "agent" : "user";
55
+ if (flags.vision) headers["Copilot-Vision-Request"] = "true";
56
+ return fetch(`${COPILOT_BASE}/chat/completions`, {
57
+ method: "POST",
58
+ headers,
59
+ body: JSON.stringify(openaiBody),
60
+ });
61
+ }
62
+
63
+ // Path A: forward the Anthropic body unchanged to Copilot's native endpoint.
64
+ export async function callCopilotNative(anthropicBody: unknown): Promise<Response> {
65
+ const bearer = await getCopilotToken();
66
+ return fetch(`${COPILOT_BASE}/v1/messages`, {
67
+ method: "POST",
68
+ headers: {
69
+ ...COPILOT_HEADERS,
70
+ Authorization: `Bearer ${bearer}`,
71
+ "anthropic-version": "2023-06-01",
72
+ },
73
+ body: JSON.stringify(anthropicBody),
74
+ });
75
+ }
76
+
77
+ // Path C: Responses API. Endpoint is /responses (no /v1 prefix); returns 400
78
+ // model_not_supported on free tier, not 404.
79
+ export async function callCopilotResponses(responsesBody: unknown): Promise<Response> {
80
+ const bearer = await getCopilotToken();
81
+ return fetch(`${COPILOT_BASE}/responses`, {
82
+ method: "POST",
83
+ headers: { ...COPILOT_HEADERS, Authorization: `Bearer ${bearer}` },
84
+ body: JSON.stringify(responsesBody),
85
+ });
86
+ }
87
+
88
+ // Proxy to the native count endpoint; returns null so the caller uses a local
89
+ // estimate. The route must never 404.
90
+ export async function countTokensUpstream(anthropicBody: unknown): Promise<number | null> {
91
+ try {
92
+ const bearer = await getCopilotToken();
93
+ const res = await fetch(`${COPILOT_BASE}/v1/messages/count_tokens`, {
94
+ method: "POST",
95
+ headers: {
96
+ ...COPILOT_HEADERS,
97
+ Authorization: `Bearer ${bearer}`,
98
+ "anthropic-version": "2023-06-01",
99
+ },
100
+ body: JSON.stringify(anthropicBody),
101
+ });
102
+ if (res.ok) return ((await res.json()) as any).input_tokens ?? null;
103
+ } catch {
104
+ /* fall through to local estimate */
105
+ }
106
+ return null;
107
+ }