@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/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
+ }