@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
package/src/env.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const CONFIG_DIR = path.join(os.homedir(), ".config", "ccmux");
|
|
6
|
+
|
|
7
|
+
// Minimal .env loader. Precedence: real env > ./.env > config dir.
|
|
8
|
+
function loadFile(p: string): void {
|
|
9
|
+
try {
|
|
10
|
+
for (const line of fs.readFileSync(p, "utf8").split("\n")) {
|
|
11
|
+
const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
12
|
+
if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2];
|
|
13
|
+
}
|
|
14
|
+
} catch {
|
|
15
|
+
/* missing — fine */
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
loadFile(path.join(process.cwd(), ".env"));
|
|
20
|
+
loadFile(path.join(CONFIG_DIR, "env"));
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import { stream } from "hono/streaming";
|
|
4
|
+
import { serve } from "@hono/node-server";
|
|
5
|
+
import {
|
|
6
|
+
PORT,
|
|
7
|
+
USE_NATIVE,
|
|
8
|
+
COPILOT_BASE,
|
|
9
|
+
COPILOT_HEADERS,
|
|
10
|
+
callCopilot,
|
|
11
|
+
callCopilotNative,
|
|
12
|
+
callCopilotResponses,
|
|
13
|
+
countTokensUpstream,
|
|
14
|
+
} from "./upstream.js";
|
|
15
|
+
import { getCopilotToken, tokenStartupCheck } from "./token.js";
|
|
16
|
+
import {
|
|
17
|
+
initModelCatalog,
|
|
18
|
+
loadOllamaCatalog,
|
|
19
|
+
loadCatalog,
|
|
20
|
+
copilotCatalogLoaded,
|
|
21
|
+
mapNativeModel,
|
|
22
|
+
clampMaxTokens,
|
|
23
|
+
ensureModelPolicy,
|
|
24
|
+
needsTranslation,
|
|
25
|
+
needsResponsesApi,
|
|
26
|
+
aliasModelId,
|
|
27
|
+
unaliasModel,
|
|
28
|
+
adaptBodyToModel,
|
|
29
|
+
contextScale,
|
|
30
|
+
scaleUsage,
|
|
31
|
+
modelCatalog,
|
|
32
|
+
NATIVE_MODEL,
|
|
33
|
+
} from "./models.js";
|
|
34
|
+
import { anthropicToOpenAI } from "./translate-request.js";
|
|
35
|
+
import { openAIToAnthropic } from "./translate-response.js";
|
|
36
|
+
import { translateStream } from "./stream.js";
|
|
37
|
+
import { rewriteNativeStream } from "./native-stream.js";
|
|
38
|
+
import { anthropicToResponses, translateResponsesResponse, ResponsesApiUnsupported } from "./translate-responses.js";
|
|
39
|
+
import { translateResponsesStream } from "./responses-stream.js";
|
|
40
|
+
import { startIdleReaper } from "./sessions.js";
|
|
41
|
+
|
|
42
|
+
const app = new Hono();
|
|
43
|
+
|
|
44
|
+
const NATIVE_ALLOWED = new Set([
|
|
45
|
+
"model", "max_tokens", "messages", "system", "tools", "tool_choice",
|
|
46
|
+
"temperature", "top_p", "top_k", "stop_sequences", "stream", "thinking",
|
|
47
|
+
"output_config",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// Models this plan turned out not to serve (mapped model -> fallback).
|
|
51
|
+
const downgraded = new Map<string, string>();
|
|
52
|
+
|
|
53
|
+
// Applies family mapping, then remembered per-plan downgrades.
|
|
54
|
+
function resolveModel(raw: string): string {
|
|
55
|
+
const mapped = mapNativeModel(raw);
|
|
56
|
+
return downgraded.get(mapped) ?? mapped;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Mirror upstream errors so Claude Code's retry logic keeps working: it backs
|
|
60
|
+
// off on 429/529 specifically. Never flatten to 502.
|
|
61
|
+
async function mirrorError(c: Context, up: Response, preRead?: string) {
|
|
62
|
+
const type =
|
|
63
|
+
up.status === 429 ? "rate_limit_error" : up.status >= 500 ? "api_error" : "invalid_request_error";
|
|
64
|
+
const ra = up.headers.get("retry-after");
|
|
65
|
+
if (ra) c.header("Retry-After", ra);
|
|
66
|
+
const text = preRead ?? (await up.text());
|
|
67
|
+
return c.json({ type: "error", error: { type, message: text } }, up.status as any);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
app.post("/v1/messages", async (c) => {
|
|
71
|
+
const body = await c.req.json();
|
|
72
|
+
// The /model picker may send an aliased id (claude-gpt-4.1); restore the real
|
|
73
|
+
// Copilot id so path selection + scaling use it.
|
|
74
|
+
body.model = unaliasModel(body.model ?? "");
|
|
75
|
+
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 ?? "")) {
|
|
79
|
+
body.model = resolveModel(body.model ?? "");
|
|
80
|
+
if (body.max_tokens) body.max_tokens = clampMaxTokens(body.model, body.max_tokens);
|
|
81
|
+
// Copilot's native endpoint (Vertex Claude) validates strictly and 400s on
|
|
82
|
+
// unknown fields. Allowlist instead of chasing each new field Claude Code adds.
|
|
83
|
+
for (const k of Object.keys(body))
|
|
84
|
+
if (!NATIVE_ALLOWED.has(k)) delete body[k];
|
|
85
|
+
// Rewrite thinking + output_config.effort to what the serving model supports.
|
|
86
|
+
adaptBodyToModel(body);
|
|
87
|
+
await ensureModelPolicy(body.model);
|
|
88
|
+
let up = await callCopilotNative(body);
|
|
89
|
+
// Downgrade once to NATIVE_MODEL if the plan doesn't serve the mapped model,
|
|
90
|
+
// and remember it so future requests don't repeat the probe.
|
|
91
|
+
if (up.status === 400 && body.model !== NATIVE_MODEL) {
|
|
92
|
+
const txt = await up.text();
|
|
93
|
+
if (txt.includes("model_not_supported")) {
|
|
94
|
+
console.warn(`${body.model} not available on this plan; falling back to ${NATIVE_MODEL}`);
|
|
95
|
+
downgraded.set(body.model, NATIVE_MODEL);
|
|
96
|
+
body.model = NATIVE_MODEL;
|
|
97
|
+
if (body.max_tokens) body.max_tokens = clampMaxTokens(body.model, body.max_tokens);
|
|
98
|
+
adaptBodyToModel(body);
|
|
99
|
+
up = await callCopilotNative(body);
|
|
100
|
+
} else {
|
|
101
|
+
return c.json({ type: "error", error: { type: "invalid_request_error", message: txt } }, 400);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!up.ok) {
|
|
105
|
+
const txt = await up.text();
|
|
106
|
+
return mirrorError(c, up, txt);
|
|
107
|
+
}
|
|
108
|
+
const factor = contextScale(body.model);
|
|
109
|
+
if (body.stream) {
|
|
110
|
+
c.header("Content-Type", "text/event-stream");
|
|
111
|
+
return stream(c, async (s) => {
|
|
112
|
+
for await (const evt of rewriteNativeStream(up.body!, factor, () => {}, () => {}))
|
|
113
|
+
await s.write(evt);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const json: any = await up.json();
|
|
117
|
+
scaleUsage(json.usage, factor);
|
|
118
|
+
return c.json(json);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Path C: Responses API for models that only expose /responses (gpt-5*, mai-code-1*).
|
|
122
|
+
if (needsResponsesApi(body.model ?? "")) {
|
|
123
|
+
let responsesPayload: any;
|
|
124
|
+
try {
|
|
125
|
+
responsesPayload = anthropicToResponses(body);
|
|
126
|
+
} catch (e: any) {
|
|
127
|
+
if (e instanceof ResponsesApiUnsupported) {
|
|
128
|
+
return c.json({ type: "error", error: { type: "invalid_request_error", message: e.message } }, 400);
|
|
129
|
+
}
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
const up = await callCopilotResponses(responsesPayload);
|
|
133
|
+
if (!up.ok) {
|
|
134
|
+
const txt = await up.text();
|
|
135
|
+
return mirrorError(c, up, txt);
|
|
136
|
+
}
|
|
137
|
+
const factor = contextScale(responsesPayload.model);
|
|
138
|
+
if (body.stream) {
|
|
139
|
+
c.header("Content-Type", "text/event-stream");
|
|
140
|
+
return stream(c, async (s) => {
|
|
141
|
+
for await (const evt of translateResponsesStream(up.body!, () => {}, factor, () => {}))
|
|
142
|
+
await s.write(evt);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const res = translateResponsesResponse(await up.json());
|
|
146
|
+
scaleUsage(res.usage, factor);
|
|
147
|
+
return c.json(res);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Path B: translation. Ollama models hit Ollama's OpenAI-compatible endpoint;
|
|
151
|
+
// everything else hits Copilot's.
|
|
152
|
+
const { payload, flags } = anthropicToOpenAI(body);
|
|
153
|
+
const provider = modelCatalog.get(body.model)?.provider ?? "copilot";
|
|
154
|
+
const up = await callCopilot(payload, flags, provider);
|
|
155
|
+
if (!up.ok) {
|
|
156
|
+
const txt = await up.text();
|
|
157
|
+
return mirrorError(c, up, txt);
|
|
158
|
+
}
|
|
159
|
+
const factor = contextScale(payload.model);
|
|
160
|
+
|
|
161
|
+
if (body.stream) {
|
|
162
|
+
c.header("Content-Type", "text/event-stream");
|
|
163
|
+
return stream(c, async (s) => {
|
|
164
|
+
for await (const evt of translateStream(up.body!, () => {}, factor, () => {}))
|
|
165
|
+
await s.write(evt);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const res = openAIToAnthropic(await up.json());
|
|
169
|
+
scaleUsage(res.usage, factor);
|
|
170
|
+
return c.json(res);
|
|
171
|
+
} catch (e: any) {
|
|
172
|
+
const msg = String(e?.message ?? e);
|
|
173
|
+
return c.json({ type: "error", error: { type: "proxy_error", message: msg } }, 500);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Claude Code calls this for context management. Must never 404.
|
|
178
|
+
app.post("/v1/messages/count_tokens", async (c) => {
|
|
179
|
+
const body = await c.req.json();
|
|
180
|
+
// Apply same model resolution as /v1/messages so counts match the serving model.
|
|
181
|
+
body.model = resolveModel(unaliasModel(body.model ?? ""));
|
|
182
|
+
const factor = contextScale(body.model);
|
|
183
|
+
// Ollama has no token-count endpoint; fall through to the local estimate.
|
|
184
|
+
if ((modelCatalog.get(body.model)?.provider ?? "copilot") !== "ollama") {
|
|
185
|
+
const upstream = await countTokensUpstream(body);
|
|
186
|
+
if (upstream !== null) return c.json({ input_tokens: Math.round(upstream * factor) });
|
|
187
|
+
}
|
|
188
|
+
// Fallback: ~4 chars/token. Include tools — they dominate Claude Code payloads
|
|
189
|
+
// and omitting them badly skews context management.
|
|
190
|
+
const text =
|
|
191
|
+
JSON.stringify(body.system ?? "") +
|
|
192
|
+
JSON.stringify(body.messages ?? []) +
|
|
193
|
+
JSON.stringify(body.tools ?? "");
|
|
194
|
+
return c.json({ input_tokens: Math.round((text.length / 4) * factor) });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
app.get("/v1/models", async (c) => {
|
|
198
|
+
let data: any;
|
|
199
|
+
try {
|
|
200
|
+
const t = await getCopilotToken();
|
|
201
|
+
const res = await fetch(`${COPILOT_BASE}/models`, {
|
|
202
|
+
headers: { ...COPILOT_HEADERS, Authorization: `Bearer ${t}` },
|
|
203
|
+
});
|
|
204
|
+
// Mirror upstream errors so proxyUp()'s auth-readiness gate fails correctly.
|
|
205
|
+
if (!res.ok) return mirrorError(c, res);
|
|
206
|
+
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);
|
|
211
|
+
} catch (e) {
|
|
212
|
+
// If Copilot has no credentials but Ollama is configured, serve the Ollama
|
|
213
|
+
// catalog alone so the proxy is usable Ollama-only. If the Copilot catalog
|
|
214
|
+
// was already loaded (transient failure), propagate instead of silently degrading.
|
|
215
|
+
const ollamaOnly = !copilotCatalogLoaded() && [...modelCatalog.values()].some((m) => m.provider === "ollama");
|
|
216
|
+
if (!ollamaOnly) throw e;
|
|
217
|
+
data = { data: [] };
|
|
218
|
+
}
|
|
219
|
+
// Push Ollama models so the alias loop below includes them in the picker.
|
|
220
|
+
for (const [key, entry] of modelCatalog) {
|
|
221
|
+
if (entry.provider !== "ollama") continue;
|
|
222
|
+
(data.data ??= []).push({
|
|
223
|
+
id: key,
|
|
224
|
+
name: key.slice("ollama/".length),
|
|
225
|
+
capabilities: { limits: { max_context_window_tokens: entry.maxPrompt, max_output_tokens: entry.maxOutput } },
|
|
226
|
+
_ccmux_backend: "ollama",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// Prefix non-Claude ids so Claude Code's gateway discovery lists gpt/gemini
|
|
230
|
+
// in /model (it filters to claude-/anthropic-). unaliasModel reverses it on
|
|
231
|
+
// inbound requests. Fold backend + context size into display_name for the picker.
|
|
232
|
+
for (const m of data?.data ?? []) {
|
|
233
|
+
if (!m?.id) continue;
|
|
234
|
+
const backend =
|
|
235
|
+
(m._ccmux_backend ?? modelCatalog.get(m.id)?.provider ?? "copilot") === "ollama" ? "ollama" : "copilot";
|
|
236
|
+
const ctx = m.capabilities?.limits?.max_context_window_tokens ?? m.capabilities?.limits?.max_prompt_tokens;
|
|
237
|
+
const ctxStr = ctx ? ` (${Math.round(ctx / 1000)}k ctx)` : "";
|
|
238
|
+
m.display_name = `${m.name ?? m.id} [${backend}]${ctxStr}`;
|
|
239
|
+
delete m._ccmux_backend;
|
|
240
|
+
m.id = aliasModelId(m.id);
|
|
241
|
+
}
|
|
242
|
+
return c.json(data);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Auth-only mode: resolve and persist a GitHub token, then exit without serving.
|
|
246
|
+
// The CLI runs this with a TTY so device flow can prompt; the proxy itself starts
|
|
247
|
+
// headless and cannot.
|
|
248
|
+
if (process.argv.includes("--login")) {
|
|
249
|
+
getCopilotToken()
|
|
250
|
+
.then(() => {
|
|
251
|
+
console.log("ccmux: GitHub Copilot authentication ready.");
|
|
252
|
+
process.exit(0);
|
|
253
|
+
})
|
|
254
|
+
.catch((e) => {
|
|
255
|
+
console.error("ccmux: login failed:", e?.message ?? e);
|
|
256
|
+
process.exit(1);
|
|
257
|
+
});
|
|
258
|
+
} else {
|
|
259
|
+
tokenStartupCheck();
|
|
260
|
+
initModelCatalog().catch(() => {});
|
|
261
|
+
loadOllamaCatalog().catch(() => {});
|
|
262
|
+
|
|
263
|
+
// Bind loopback only. This server forwards Copilot credentials and intentionally
|
|
264
|
+
// ignores the client's API key.
|
|
265
|
+
serve({ fetch: app.fetch, port: PORT, hostname: "127.0.0.1" }, () => {
|
|
266
|
+
console.log(
|
|
267
|
+
`ccmux on http://127.0.0.1:${PORT} (${USE_NATIVE ? "native" : "translation"} path)`,
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// When started by `ccmux claude` (MANAGED), shut down once all sessions end.
|
|
272
|
+
startIdleReaper();
|
|
273
|
+
}
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { COPILOT_BASE, COPILOT_HEADERS, OLLAMA_BASE, ollamaKey } from "./upstream.js";
|
|
2
|
+
import { getCopilotToken } from "./token.js";
|
|
3
|
+
|
|
4
|
+
// Populated once at startup from GET {COPILOT_BASE}/models. Empty catalog
|
|
5
|
+
// (offline/startup failure) means: no clamping, no scaling, default model ids.
|
|
6
|
+
export interface CatalogEntry {
|
|
7
|
+
maxOutput: number;
|
|
8
|
+
policy?: string;
|
|
9
|
+
maxPrompt?: number; // capabilities.limits.max_prompt_tokens (0/undefined = unknown)
|
|
10
|
+
adaptiveThinking?: boolean; // capabilities.supports.adaptive_thinking
|
|
11
|
+
minThinking?: number; // capabilities.supports.min_thinking_budget
|
|
12
|
+
maxThinking?: number; // capabilities.supports.max_thinking_budget
|
|
13
|
+
efforts?: string[]; // capabilities.supports.reasoning_effort values
|
|
14
|
+
supportedEndpoints?: string[]; // e.g. ["/chat/completions", "/responses"]
|
|
15
|
+
provider?: "copilot" | "ollama"; // backend that serves this id (default copilot)
|
|
16
|
+
}
|
|
17
|
+
export const modelCatalog = new Map<string, CatalogEntry>();
|
|
18
|
+
|
|
19
|
+
export let SMALL_MODEL = ""; // cheapest available chat model, resolved at startup
|
|
20
|
+
let OPUS = "claude-opus-4";
|
|
21
|
+
let SONNET = "claude-sonnet-4.5";
|
|
22
|
+
|
|
23
|
+
// Populate the catalog from a parsed GET /models payload. Extracted so the
|
|
24
|
+
// /v1/models route can self-heal an empty catalog on the first client request,
|
|
25
|
+
// preventing aliased non-Claude ids from misrouting to the native path.
|
|
26
|
+
export function loadCatalog(data: any): void {
|
|
27
|
+
for (const m of data?.data ?? []) {
|
|
28
|
+
const sup = m.capabilities?.supports ?? {};
|
|
29
|
+
modelCatalog.set(m.id, {
|
|
30
|
+
maxOutput: m.capabilities?.limits?.max_output_tokens ?? 4096,
|
|
31
|
+
policy: m.policy?.state,
|
|
32
|
+
maxPrompt:
|
|
33
|
+
m.capabilities?.limits?.max_prompt_tokens ??
|
|
34
|
+
m.capabilities?.limits?.max_context_window_tokens,
|
|
35
|
+
adaptiveThinking: sup.adaptive_thinking === true,
|
|
36
|
+
minThinking: sup.min_thinking_budget,
|
|
37
|
+
maxThinking: sup.max_thinking_budget,
|
|
38
|
+
efforts: Array.isArray(sup.reasoning_effort) ? sup.reasoning_effort : [],
|
|
39
|
+
supportedEndpoints: Array.isArray(m.supported_endpoints) ? m.supported_endpoints : undefined,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// Family defaults are Copilot-only: Ollama ids must not become SMALL_MODEL
|
|
43
|
+
// or they route background calls to the wrong backend.
|
|
44
|
+
const ids = [...modelCatalog.keys()].filter((id) => modelCatalog.get(id)?.provider !== "ollama");
|
|
45
|
+
SMALL_MODEL = ids.find((id) => /-mini|nano/i.test(id)) ?? ids[0] ?? ""; // not /mini/: matches "gemini"
|
|
46
|
+
if (!modelCatalog.has(OPUS)) OPUS = ids.find((id) => /opus/i.test(id)) ?? SONNET;
|
|
47
|
+
if (!modelCatalog.has(SONNET)) SONNET = ids.find((id) => /sonnet/i.test(id)) ?? ids[0] ?? SONNET;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// True once any Copilot model is in the catalog. Must NOT be satisfied by
|
|
51
|
+
// ollama-only entries: a bare size>0 check would skip repopulation after a
|
|
52
|
+
// failed Copilot startup fetch, breaking self-heal for the proxy's lifetime.
|
|
53
|
+
export function copilotCatalogLoaded(): boolean {
|
|
54
|
+
return [...modelCatalog.values()].some((e) => e.provider !== "ollama");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function initModelCatalog(): Promise<void> {
|
|
58
|
+
try {
|
|
59
|
+
const bearer = await getCopilotToken();
|
|
60
|
+
const res = await fetch(`${COPILOT_BASE}/models`, {
|
|
61
|
+
headers: { ...COPILOT_HEADERS, Authorization: `Bearer ${bearer}` },
|
|
62
|
+
});
|
|
63
|
+
if (!res.ok) throw new Error(`/models ${res.status}`);
|
|
64
|
+
loadCatalog((await res.json()) as any);
|
|
65
|
+
} catch (e: any) {
|
|
66
|
+
console.warn(`model catalog unavailable (${e?.message ?? e}); using defaults, no max_tokens clamping`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Ollama catalog: keyed `ollama/<id>` to avoid Copilot id collisions (prefix
|
|
71
|
+
// stripped on the wire). Context windows from a static table rather than a
|
|
72
|
+
// per-model fan-out at startup; gemma3:* skipped (no tool support).
|
|
73
|
+
const OLLAMA_DEFAULT_CTX = 262_144;
|
|
74
|
+
const OLLAMA_CTX: Record<string, number> = {
|
|
75
|
+
"deepseek-v4-flash": 1_048_576, "gemini-3-flash-preview": 1_048_576,
|
|
76
|
+
"glm-5.2": 1_000_000, "deepseek-v4-pro": 524_288, "minimax-m3": 524_288,
|
|
77
|
+
"minimax-m2.1": 204_800, "glm-4.7": 202_752, "glm-5": 202_752, "glm-5.1": 202_752,
|
|
78
|
+
"minimax-m2.5": 196_608, "minimax-m2.7": 196_608,
|
|
79
|
+
"deepseek-v3.1:671b": 163_840, "deepseek-v3.2": 163_840,
|
|
80
|
+
"gpt-oss:20b": 131_072, "gpt-oss:120b": 131_072, "rnj-1:8b": 32_768,
|
|
81
|
+
};
|
|
82
|
+
// Models with no thinking capability; reasoning_effort not advertised for these.
|
|
83
|
+
const OLLAMA_NONTHINK = new Set([
|
|
84
|
+
"devstral-2:123b", "devstral-small-2:24b", "ministral-3:3b", "ministral-3:8b",
|
|
85
|
+
"ministral-3:14b", "mistral-large-3:675b", "qwen3-coder-next", "qwen3-coder:480b",
|
|
86
|
+
"rnj-1:8b",
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
export async function loadOllamaCatalog(): Promise<void> {
|
|
90
|
+
if (!ollamaKey()) return; // no key -> Ollama off, zero change for Copilot-only users
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(`${OLLAMA_BASE}/models`, { headers: { Authorization: `Bearer ${ollamaKey()}` } });
|
|
93
|
+
if (!res.ok) throw new Error(`/models ${res.status}`);
|
|
94
|
+
const data: any = await res.json();
|
|
95
|
+
for (const m of data?.data ?? []) {
|
|
96
|
+
const id = m?.id;
|
|
97
|
+
if (!id || String(id).startsWith("gemma3:")) continue; // gemma3: no tool support
|
|
98
|
+
const ctx = OLLAMA_CTX[id] ?? OLLAMA_DEFAULT_CTX;
|
|
99
|
+
modelCatalog.set(`ollama/${id}`, {
|
|
100
|
+
// Ollama has no per-model output limit, only context_length. 32k covers
|
|
101
|
+
// agentic edits; min(ctx) guards tiny-context models.
|
|
102
|
+
maxOutput: Math.min(32_768, ctx),
|
|
103
|
+
maxPrompt: ctx,
|
|
104
|
+
efforts: OLLAMA_NONTHINK.has(id) ? [] : ["low", "medium", "high"],
|
|
105
|
+
provider: "ollama",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
} catch (e: any) {
|
|
109
|
+
console.warn(`ollama catalog unavailable (${e?.message ?? e}); ollama models disabled`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Path A fallback: model substituted when Claude Code sends an id Copilot
|
|
114
|
+
// doesn't know. Free tier default is haiku-4.5; override with NATIVE_MODEL.
|
|
115
|
+
export const NATIVE_MODEL = process.env.NATIVE_MODEL ?? "claude-haiku-4.5";
|
|
116
|
+
|
|
117
|
+
export function mapNativeModel(m: string): string {
|
|
118
|
+
if (modelCatalog.has(m)) return m; // exact Copilot id, pass through
|
|
119
|
+
if (process.env.NATIVE_MODEL) return process.env.NATIVE_MODEL; // explicit override wins
|
|
120
|
+
// Map Claude Code's Anthropic family ids to the newest Copilot Claude of
|
|
121
|
+
// that family (haiku/sonnet/opus/fable).
|
|
122
|
+
const fam = ["haiku", "sonnet", "opus", "fable"].find((f) => m.includes(f));
|
|
123
|
+
if (fam) {
|
|
124
|
+
const best = [...modelCatalog.keys()]
|
|
125
|
+
.filter((id) => id.startsWith("claude-") && id.includes(fam))
|
|
126
|
+
.sort()
|
|
127
|
+
.at(-1);
|
|
128
|
+
if (best) return best;
|
|
129
|
+
}
|
|
130
|
+
return NATIVE_MODEL;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Auto-enable Copilot's one-time per-model policy consent on first use, so
|
|
134
|
+
// paid-plan users don't hit model_not_supported unexpectedly. Non-fatal.
|
|
135
|
+
const policyAttempted = new Set<string>();
|
|
136
|
+
export async function ensureModelPolicy(model: string): Promise<void> {
|
|
137
|
+
const entry = modelCatalog.get(model);
|
|
138
|
+
if (!entry || entry.policy === undefined || entry.policy === "enabled" || policyAttempted.has(model)) return;
|
|
139
|
+
policyAttempted.add(model);
|
|
140
|
+
try {
|
|
141
|
+
const bearer = await getCopilotToken();
|
|
142
|
+
const res = await fetch(`${COPILOT_BASE}/models/${model}/policy`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: { ...COPILOT_HEADERS, Authorization: `Bearer ${bearer}` },
|
|
145
|
+
body: JSON.stringify({ state: "enabled" }),
|
|
146
|
+
});
|
|
147
|
+
if (res.ok) {
|
|
148
|
+
entry.policy = "enabled";
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
/* request still goes through; upstream error will surface if blocked */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function mapModel(m: string): string {
|
|
156
|
+
if (modelCatalog.has(m)) return m; // exact Copilot id, keep it
|
|
157
|
+
if (m.includes("haiku")) return SMALL_MODEL || SONNET;
|
|
158
|
+
if (m.includes("opus")) return OPUS;
|
|
159
|
+
return SONNET;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Non-Claude catalog ids (gpt-*, gemini-*) must use the OpenAI translation
|
|
163
|
+
// path; the native /v1/messages endpoint is Claude-only.
|
|
164
|
+
export function needsTranslation(m: string): boolean {
|
|
165
|
+
return modelCatalog.has(m) && !m.startsWith("claude-");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Gateway model discovery only surfaces ids starting with claude-/anthropic-.
|
|
169
|
+
// Alias every other id as `claude-<id>` so gpt/gemini appear in the /model picker.
|
|
170
|
+
export function aliasModelId(id: string): string {
|
|
171
|
+
return /^(claude|anthropic)-/.test(id) ? id : "claude-" + id;
|
|
172
|
+
}
|
|
173
|
+
// Strip the synthetic prefix back to the real Copilot id before routing, but
|
|
174
|
+
// ONLY when the stripped id is in the catalog (real Claude ids are left untouched).
|
|
175
|
+
// Requires a populated catalog; /v1/models self-heals an empty one before routing.
|
|
176
|
+
export function unaliasModel(m: string): string {
|
|
177
|
+
if (!m.startsWith("claude-")) return m;
|
|
178
|
+
const real = m.slice("claude-".length);
|
|
179
|
+
return modelCatalog.has(real) && !real.startsWith("claude-") ? real : m;
|
|
180
|
+
}
|
|
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.
|
|
184
|
+
export function needsResponsesApi(model: string): boolean {
|
|
185
|
+
const eps = modelCatalog.get(model)?.supportedEndpoints ?? [];
|
|
186
|
+
return eps.includes("/responses") && !eps.includes("/chat/completions");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function clampMaxTokens(model: string, requested: number): number {
|
|
190
|
+
const cap = modelCatalog.get(model)?.maxOutput;
|
|
191
|
+
return cap ? Math.min(requested, cap) : requested;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Claude Code assumes a 200k context window from the Anthropic model name.
|
|
195
|
+
// Copilot models often have smaller limits (e.g. haiku-4.5: 128k), so Claude
|
|
196
|
+
// Code would compact too late and hit upstream 400s. Scaling reported
|
|
197
|
+
// input-token counts by assumed/actual keeps context tracking accurate.
|
|
198
|
+
const ASSUMED_CONTEXT = Number(process.env.CLAUDE_CONTEXT_WINDOW ?? 200_000);
|
|
199
|
+
|
|
200
|
+
export function contextScale(model: string): number {
|
|
201
|
+
const prompt = modelCatalog.get(model)?.maxPrompt;
|
|
202
|
+
return prompt ? ASSUMED_CONTEXT / prompt : 1;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function scaleUsage(usage: any, factor: number): void {
|
|
206
|
+
if (!usage || factor === 1) return;
|
|
207
|
+
for (const k of ["input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"])
|
|
208
|
+
if (typeof usage[k] === "number") usage[k] = Math.round(usage[k] * factor);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Rewrite thinking + output_config.effort to what the serving model supports.
|
|
212
|
+
// Verified live: haiku-4.5 accepts thinking{enabled,budget_tokens}; the native
|
|
213
|
+
// endpoint validates output_config.effort per model (invalid_reasoning_effort).
|
|
214
|
+
const EFFORT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
215
|
+
|
|
216
|
+
export function nearestEffort(want: string, supported: string[]): string {
|
|
217
|
+
if (supported.includes(want)) return want;
|
|
218
|
+
const i = EFFORT_ORDER.indexOf(want);
|
|
219
|
+
if (i === -1) {
|
|
220
|
+
// Unknown future level: scan downward from max
|
|
221
|
+
for (let j = EFFORT_ORDER.length - 1; j >= 0; j--)
|
|
222
|
+
if (supported.includes(EFFORT_ORDER[j])) return EFFORT_ORDER[j];
|
|
223
|
+
return supported[0];
|
|
224
|
+
}
|
|
225
|
+
// Closest supported level at or below the requested one
|
|
226
|
+
for (let j = i; j >= 0; j--) if (supported.includes(EFFORT_ORDER[j])) return EFFORT_ORDER[j];
|
|
227
|
+
// Nothing at or below: take the lowest supported level above
|
|
228
|
+
for (let j = i + 1; j < EFFORT_ORDER.length; j++) if (supported.includes(EFFORT_ORDER[j])) return EFFORT_ORDER[j];
|
|
229
|
+
return supported[0];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Resolve effort to forward, or undefined to drop it. Only forwarded when the
|
|
233
|
+
// catalog confirms support; some Copilot endpoints 400 on unknown fields.
|
|
234
|
+
export function resolveEffort(model: string, want: unknown): string | undefined {
|
|
235
|
+
const efforts = modelCatalog.get(model)?.efforts ?? [];
|
|
236
|
+
if (typeof want === "string" && efforts.length) return nearestEffort(want, efforts);
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function adaptBodyToModel(body: any): void {
|
|
241
|
+
const entry = modelCatalog.get(body.model);
|
|
242
|
+
|
|
243
|
+
const th = body.thinking;
|
|
244
|
+
if (th) {
|
|
245
|
+
const minB = entry?.minThinking ?? 1024;
|
|
246
|
+
const capB = Math.min(entry?.maxThinking ?? Infinity, (body.max_tokens ?? Infinity) - 1);
|
|
247
|
+
if (th.type === "adaptive" && entry && !entry.adaptiveThinking) {
|
|
248
|
+
// model supports explicit budget but not adaptive (e.g. haiku-4.5)
|
|
249
|
+
if (entry.maxThinking && capB >= minB) body.thinking = { type: "enabled", budget_tokens: capB };
|
|
250
|
+
else delete body.thinking;
|
|
251
|
+
} else if (th.type === "enabled") {
|
|
252
|
+
const budget = Math.min(Math.max(th.budget_tokens ?? minB, minB), capB);
|
|
253
|
+
if (budget >= minB) th.budget_tokens = budget;
|
|
254
|
+
else delete body.thinking;
|
|
255
|
+
} else if (th.type === "adaptive" && !entry) {
|
|
256
|
+
delete body.thinking; // catalog unavailable; upstream may 400 on adaptive
|
|
257
|
+
} else if (th.type !== "adaptive" && th.type !== "disabled") {
|
|
258
|
+
delete body.thinking; // unknown future mode; drop to be safe
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (body.output_config !== undefined) {
|
|
263
|
+
const efforts = entry?.efforts ?? [];
|
|
264
|
+
const effort = body.output_config?.effort;
|
|
265
|
+
// keep only effort; other output_config fields are unverified upstream
|
|
266
|
+
if (typeof effort === "string" && efforts.length) {
|
|
267
|
+
body.output_config = { effort: nearestEffort(effort, efforts) };
|
|
268
|
+
} else {
|
|
269
|
+
delete body.output_config;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Placeholder signature for thinking blocks synthesized from Ollama `reasoning`.
|
|
275
|
+
// Claude Code requires a signature field; value is never verified upstream
|
|
276
|
+
// because anthropicToOpenAI drops thinking blocks on the return trip.
|
|
277
|
+
export const THINKING_SIG = "b2xsYW1h"; // base64 "ollama"
|
|
278
|
+
|
|
279
|
+
export function mapStopReason(f: string | null): string {
|
|
280
|
+
return (
|
|
281
|
+
({ stop: "end_turn", length: "max_tokens", tool_calls: "tool_use" } as Record<string, string>)[
|
|
282
|
+
f ?? ""
|
|
283
|
+
] ?? "end_turn"
|
|
284
|
+
);
|
|
285
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { scaleUsage } from "./models.js";
|
|
2
|
+
|
|
3
|
+
// Native pass-through stream: usage fields in message_start/message_delta are
|
|
4
|
+
// scaled by the context factor (so Claude Code's context tracking matches the
|
|
5
|
+
// serving model). Merged usage reported once via onUsage for cost tracking.
|
|
6
|
+
// Every other frame passes through BYTE-IDENTICAL; thinking signatures must not be re-serialized.
|
|
7
|
+
export async function* rewriteNativeStream(
|
|
8
|
+
upstream: ReadableStream<Uint8Array>,
|
|
9
|
+
factor: number,
|
|
10
|
+
onUsage: (usage: any) => void = () => {},
|
|
11
|
+
onAbort: () => void = () => {}, // called when upstream dies mid-stream
|
|
12
|
+
): AsyncGenerator<string> {
|
|
13
|
+
const reader = upstream.getReader();
|
|
14
|
+
const decoder = new TextDecoder();
|
|
15
|
+
let buf = "";
|
|
16
|
+
const merged: any = {};
|
|
17
|
+
|
|
18
|
+
const process = (frame: string): string => {
|
|
19
|
+
if (!frame.includes('"usage"')) return frame;
|
|
20
|
+
// locate the data field; frames may or may not carry an `event:` line
|
|
21
|
+
const at = frame.startsWith("data: ") ? 0 : frame.indexOf("\ndata: ") + 1;
|
|
22
|
+
if (at === 0 && !frame.startsWith("data: ")) return frame; // no data field
|
|
23
|
+
const split = at + "data: ".length;
|
|
24
|
+
try {
|
|
25
|
+
const evt = JSON.parse(frame.slice(split));
|
|
26
|
+
const usage =
|
|
27
|
+
evt.type === "message_start" ? evt.message?.usage : evt.type === "message_delta" ? evt.usage : null;
|
|
28
|
+
if (!usage) return frame;
|
|
29
|
+
Object.assign(merged, usage); // raw values, assigned before scaling
|
|
30
|
+
scaleUsage(usage, factor);
|
|
31
|
+
return frame.slice(0, split) + JSON.stringify(evt);
|
|
32
|
+
} catch {
|
|
33
|
+
return frame; // not parseable, pass through untouched
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
while (true) {
|
|
39
|
+
const { done, value } = await reader.read();
|
|
40
|
+
if (done) break;
|
|
41
|
+
buf += decoder.decode(value, { stream: true });
|
|
42
|
+
const frames = buf.split("\n\n");
|
|
43
|
+
buf = frames.pop() ?? "";
|
|
44
|
+
for (const frame of frames) yield process(frame) + "\n\n";
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
onAbort();
|
|
48
|
+
}
|
|
49
|
+
if (buf) yield process(buf);
|
|
50
|
+
if (Object.keys(merged).length) onUsage(merged);
|
|
51
|
+
}
|