mlx-bun 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/THIRD_PARTY_LICENSES.md +41 -0
  4. package/bin/mlx-bun.mjs +32 -0
  5. package/docs/curve-designer.html +303 -0
  6. package/docs/design/adapters-end-to-end.md +201 -0
  7. package/docs/design/compat-cli-surface-design.md +227 -0
  8. package/docs/design/docs-reorg-plan.md +152 -0
  9. package/docs/design/hlg-sampling.md +340 -0
  10. package/docs/design/memory-system.md +339 -0
  11. package/docs/design/optimization_plan.md +344 -0
  12. package/docs/design/parallel-slots.md +515 -0
  13. package/docs/design/runtime-isolation.md +247 -0
  14. package/docs/design/segmented-backward-training.md +508 -0
  15. package/docs/investigations/chunk-finetune-experiment.md +84 -0
  16. package/docs/investigations/curve-bisector-routing.md +123 -0
  17. package/docs/investigations/curve-runs/bisector-route.json +98 -0
  18. package/docs/investigations/curve-runs/bisector-trace.json +1393 -0
  19. package/docs/investigations/curve-runs/curve-terrain.json +1179 -0
  20. package/docs/investigations/curve-runs/curve-terrain.svg +42 -0
  21. package/docs/investigations/curve-terrain.html +105 -0
  22. package/docs/investigations/expert-offload-single-user-moe.md +292 -0
  23. package/docs/investigations/hlg-ab.html +34 -0
  24. package/docs/investigations/hlg-explorer.html +238 -0
  25. package/docs/investigations/hlg-figs/cliff.svg +1 -0
  26. package/docs/investigations/hlg-figs/cross.svg +1 -0
  27. package/docs/investigations/hlg-figs/edges.svg +1 -0
  28. package/docs/investigations/hlg-figs/k20-grids.svg +1 -0
  29. package/docs/investigations/hlg-figs/k20-peaks.svg +1 -0
  30. package/docs/investigations/hlg-figs/lw-curve.svg +1 -0
  31. package/docs/investigations/hlg-figs/terrain.svg +1 -0
  32. package/docs/investigations/hlg-report.html +262 -0
  33. package/docs/investigations/hlg-runs/hlg-ab.json +149 -0
  34. package/docs/investigations/hlg-runs/hlg-blind-prompts.md +87 -0
  35. package/docs/investigations/hlg-runs/hlg-blind.json +107 -0
  36. package/docs/investigations/hlg-runs/hlg-cliff.json +197 -0
  37. package/docs/investigations/hlg-runs/hlg-cross.json +505 -0
  38. package/docs/investigations/hlg-runs/hlg-edge.json +388 -0
  39. package/docs/investigations/hlg-runs/hlg-eval1.json +25 -0
  40. package/docs/investigations/hlg-runs/hlg-frontier.json +86 -0
  41. package/docs/investigations/hlg-runs/hlg-grid.json +242 -0
  42. package/docs/investigations/hlg-runs/hlg-grid2.json +343 -0
  43. package/docs/investigations/hlg-runs/hlg-lw-coarse.json +110 -0
  44. package/docs/investigations/hlg-runs/hlg-map.json +383 -0
  45. package/docs/investigations/hlg-runs/run0-original.log +159 -0
  46. package/docs/investigations/hlg-runs/runA-recommended.log +131 -0
  47. package/docs/investigations/hlg-runs/runAA-cliff-cpm5.log +26 -0
  48. package/docs/investigations/hlg-runs/runAB-grid2-e4b.log +18 -0
  49. package/docs/investigations/hlg-runs/runAC-peak-e4b-partial.log +25 -0
  50. package/docs/investigations/hlg-runs/runAD-ab-samples.log +8 -0
  51. package/docs/investigations/hlg-runs/runAE-edge-e4b.log +51 -0
  52. package/docs/investigations/hlg-runs/runAF-logit-calibration.log +10 -0
  53. package/docs/investigations/hlg-runs/runAG-eval-config2.log +16 -0
  54. package/docs/investigations/hlg-runs/runB-gain-variants.log +192 -0
  55. package/docs/investigations/hlg-runs/runC-pivots.log +162 -0
  56. package/docs/investigations/hlg-runs/runD-oetf.log +132 -0
  57. package/docs/investigations/hlg-runs/runE-eotf.log +162 -0
  58. package/docs/investigations/hlg-runs/runF-shaper-working.log +86 -0
  59. package/docs/investigations/hlg-runs/runG-shaper-sweep.log +101 -0
  60. package/docs/investigations/hlg-runs/runH-wsweep.log +176 -0
  61. package/docs/investigations/hlg-runs/runI-asweep.log +176 -0
  62. package/docs/investigations/hlg-runs/runJ-knobmap.log +49 -0
  63. package/docs/investigations/hlg-runs/runK-frontier.log +19 -0
  64. package/docs/investigations/hlg-runs/runL-widemap.log +65 -0
  65. package/docs/investigations/hlg-runs/runM-frontier-emb.log +15 -0
  66. package/docs/investigations/hlg-runs/runN-verify.log +116 -0
  67. package/docs/investigations/hlg-runs/runO-frontier-seed5000.log +15 -0
  68. package/docs/investigations/hlg-runs/runP-grid-AxSm.log +15 -0
  69. package/docs/investigations/hlg-runs/runQ-grid-tgW.log +15 -0
  70. package/docs/investigations/hlg-runs/runR-blind-transcript.log +79 -0
  71. package/docs/investigations/hlg-runs/runS-cross-coherence.log +33 -0
  72. package/docs/investigations/hlg-runs/runT-cross-cpm5.log +33 -0
  73. package/docs/investigations/hlg-runs/runU-cross-12b.log +33 -0
  74. package/docs/investigations/hlg-runs/runV-lw-e4b.log +22 -0
  75. package/docs/investigations/hlg-runs/runW-lw-12b.log +22 -0
  76. package/docs/investigations/hlg-runs/runX-lw-cpm5.log +22 -0
  77. package/docs/investigations/hlg-runs/runY-cliff-e4b.log +26 -0
  78. package/docs/investigations/hlg-runs/runZ-cliff-12b.log +26 -0
  79. package/docs/investigations/hlg-sampling-investigation.md +970 -0
  80. package/docs/investigations/lab-build-journal.md +157 -0
  81. package/docs/investigations/pi-builtin-investigation.md +308 -0
  82. package/docs/investigations/starter-model-port-handoff.md +323 -0
  83. package/docs/planning/IDEAS.md +26 -0
  84. package/docs/planning/PRODUCT_ROADMAP.md +426 -0
  85. package/docs/planning/ResearchTopics.md +119 -0
  86. package/docs/planning/chunk-task-roadmap.md +77 -0
  87. package/docs/planning/journal.md +155 -0
  88. package/docs/reference/distribution.md +121 -0
  89. package/docs/reference/embedding.md +168 -0
  90. package/docs/reference/library-api.md +153 -0
  91. package/docs/reference/server-api.md +224 -0
  92. package/docs/reference/server-config.md +243 -0
  93. package/docs/segmented-backward-handoff.md +148 -0
  94. package/package.json +68 -0
  95. package/src/anthropic.ts +491 -0
  96. package/src/chat-template.ts +186 -0
  97. package/src/cli.ts +1075 -0
  98. package/src/config.ts +229 -0
  99. package/src/curve-sampler.ts +122 -0
  100. package/src/dataset/generators.ts +1087 -0
  101. package/src/dataset/index.ts +12 -0
  102. package/src/dataset/job.ts +81 -0
  103. package/src/dataset/llm.ts +71 -0
  104. package/src/dataset/registry.ts +528 -0
  105. package/src/download.ts +283 -0
  106. package/src/eval/README.md +86 -0
  107. package/src/eval/kl-prompts.ts +29 -0
  108. package/src/eval/kl.ts +294 -0
  109. package/src/eval/quality-db.ts +92 -0
  110. package/src/eval/runner.ts +102 -0
  111. package/src/eval/score.ts +49 -0
  112. package/src/eval/tasks/bfcl.ts +500 -0
  113. package/src/eval/tasks/gsm8k.ts +85 -0
  114. package/src/eval/tasks/hashhop.ts +109 -0
  115. package/src/eval/tasks/humaneval.ts +252 -0
  116. package/src/eval/tasks/ifeval.ts +354 -0
  117. package/src/eval/tasks/mmlu.ts +145 -0
  118. package/src/evaldb.ts +199 -0
  119. package/src/expert-offload-build.ts +77 -0
  120. package/src/expert-offload.ts +61 -0
  121. package/src/expert-trace.ts +67 -0
  122. package/src/fit.ts +254 -0
  123. package/src/generate.ts +451 -0
  124. package/src/harness-pi.ts +177 -0
  125. package/src/hf-push.ts +628 -0
  126. package/src/index.ts +22 -0
  127. package/src/jobs/db.ts +139 -0
  128. package/src/jobs/index.ts +29 -0
  129. package/src/jobs/job-entry.ts +106 -0
  130. package/src/jobs/runner.ts +304 -0
  131. package/src/jobs/sse.ts +114 -0
  132. package/src/jobs/types.ts +58 -0
  133. package/src/kv-store.ts +185 -0
  134. package/src/lora.ts +314 -0
  135. package/src/md-modules.d.ts +7 -0
  136. package/src/mlx/array.ts +237 -0
  137. package/src/mlx/autograd.ts +267 -0
  138. package/src/mlx/checkpoint.ts +116 -0
  139. package/src/mlx/compile.ts +158 -0
  140. package/src/mlx/custom-vjp.ts +140 -0
  141. package/src/mlx/ffi.ts +433 -0
  142. package/src/mlx/metal-kernel.ts +130 -0
  143. package/src/mlx/ops.ts +707 -0
  144. package/src/mmap.ts +90 -0
  145. package/src/model/batched-mask.ts +204 -0
  146. package/src/model/batched-rotating.ts +300 -0
  147. package/src/model/compiled-decode.ts +681 -0
  148. package/src/model/factory.ts +27 -0
  149. package/src/model/fingerprint.ts +33 -0
  150. package/src/model/flash-attention.ts +674 -0
  151. package/src/model/fused-decode-kernel.ts +305 -0
  152. package/src/model/fused-geglu-kernel.ts +87 -0
  153. package/src/model/gemma4-base.ts +1616 -0
  154. package/src/model/gemma4.ts +1012 -0
  155. package/src/model/generated/gemma4-12b.ts +526 -0
  156. package/src/model/generated/gemma4-26b.ts +500 -0
  157. package/src/model/generated/gemma4-e4b.ts +757 -0
  158. package/src/model/generated/index.ts +18 -0
  159. package/src/model/minicpm5.ts +278 -0
  160. package/src/model/qwen3-delta.ts +224 -0
  161. package/src/model/qwen3_5.ts +429 -0
  162. package/src/model/support.ts +46 -0
  163. package/src/native-pack.ts +152 -0
  164. package/src/pi-provider.ts +95 -0
  165. package/src/pi-terminal.ts +264 -0
  166. package/src/pi-web.ts +924 -0
  167. package/src/preflight.ts +123 -0
  168. package/src/prompt-cache.ts +113 -0
  169. package/src/quantize/allocator.ts +627 -0
  170. package/src/quantize/calibration.ts +180 -0
  171. package/src/quantize/config-writer.ts +132 -0
  172. package/src/quantize/index.ts +57 -0
  173. package/src/quantize/job.ts +136 -0
  174. package/src/quantize/quantizer.ts +462 -0
  175. package/src/quantize/safetensors-writer.ts +171 -0
  176. package/src/quantize/sensitivity.ts +234 -0
  177. package/src/registry.ts +336 -0
  178. package/src/responses.ts +774 -0
  179. package/src/safetensors.ts +158 -0
  180. package/src/sampler.ts +517 -0
  181. package/src/serve/batch-scheduler.ts +362 -0
  182. package/src/serve/generation-gateway.ts +160 -0
  183. package/src/server.ts +1891 -0
  184. package/src/spec/drafter.ts +261 -0
  185. package/src/spec/generate.ts +221 -0
  186. package/src/status-page.html +497 -0
  187. package/src/tokenizer.ts +38 -0
  188. package/src/tool-call.ts +242 -0
  189. package/src/train/dataset.ts +461 -0
  190. package/src/train/export.ts +32 -0
  191. package/src/train/forward.ts +229 -0
  192. package/src/train/index.ts +43 -0
  193. package/src/train/job.ts +148 -0
  194. package/src/train/lora-params.ts +194 -0
  195. package/src/train/loss.ts +342 -0
  196. package/src/train/merge.ts +195 -0
  197. package/src/train/optimizer.ts +176 -0
  198. package/src/train/rank.ts +101 -0
  199. package/src/train/segmented.ts +520 -0
  200. package/src/train/trainer.ts +600 -0
  201. package/src/tui.ts +161 -0
  202. package/src/vision/embedder.ts +168 -0
  203. package/src/vision/preprocess.ts +247 -0
  204. package/src/vision/prompt.ts +158 -0
  205. package/src/vision/siglip.ts +599 -0
  206. package/src/web/app.html +2471 -0
  207. package/src/web/skills/web-research/SKILL.md +44 -0
  208. package/src/web/skills.ts +59 -0
  209. package/src/web-tools.ts +426 -0
  210. package/src/weights.ts +91 -0
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "mlx-bun",
3
+ "version": "0.0.4",
4
+ "description": "Native MLX inference for Bun on Apple Silicon — local LLM server (OpenAI/Anthropic-compatible) and TypeScript library. No Python.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/joshuarossi/mlx-bun.git"
10
+ },
11
+ "homepage": "https://github.com/joshuarossi/mlx-bun#readme",
12
+ "bugs": "https://github.com/joshuarossi/mlx-bun/issues",
13
+ "keywords": [
14
+ "mlx",
15
+ "llm",
16
+ "apple-silicon",
17
+ "metal",
18
+ "bun",
19
+ "local-inference",
20
+ "gemma",
21
+ "openai-compatible",
22
+ "anthropic"
23
+ ],
24
+ "bin": {
25
+ "mlx-bun": "bin/mlx-bun.mjs"
26
+ },
27
+ "exports": {
28
+ ".": "./src/index.ts",
29
+ "./package.json": "./package.json"
30
+ },
31
+ "files": [
32
+ "bin",
33
+ "src",
34
+ "docs",
35
+ "README.md",
36
+ "LICENSE",
37
+ "THIRD_PARTY_LICENSES.md"
38
+ ],
39
+ "engines": {
40
+ "bun": ">=1.3.14"
41
+ },
42
+ "os": [
43
+ "darwin"
44
+ ],
45
+ "cpu": [
46
+ "arm64"
47
+ ],
48
+ "scripts": {
49
+ "test": "bash scripts/test.sh",
50
+ "link-cli": "chmod +x src/cli.ts && ln -sf $PWD/src/cli.ts $HOME/.bun/bin/mlx-bun && mlx-bun --version",
51
+ "build:binary": "sh scripts/build-binary.sh",
52
+ "release": "PUBLISH=1 sh scripts/release-binary.sh",
53
+ "publish:npm": "bun publish"
54
+ },
55
+ "devDependencies": {
56
+ "@anthropic-ai/sdk": "^0.104.1",
57
+ "@types/bun": "^1.3.14",
58
+ "openai": "^6.42.0"
59
+ },
60
+ "dependencies": {
61
+ "@earendil-works/pi-coding-agent": "0.79.4",
62
+ "@earendil-works/pi-ai": "0.79.4",
63
+ "@huggingface/jinja": "^0.5.9",
64
+ "@huggingface/tokenizers": "^0.1.3",
65
+ "fast-png": "^8.0.0",
66
+ "typebox": "1.1.38"
67
+ }
68
+ }
@@ -0,0 +1,491 @@
1
+ // Anthropic Messages API translation layer for the mlx-bun server.
2
+ //
3
+ // Ported from: mlx-optiq optiq/anthropic_shim.py (MIT) —
4
+ // anthropic_to_openai_body / openai_to_anthropic_response /
5
+ // AnthropicStreamTranslator. The SSE event grammar is the oracle's
6
+ // exactly: message_start → [content_block_start/delta/stop]* →
7
+ // message_delta (stop_reason + output_tokens) → message_stop.
8
+ //
9
+ // Documented upgrades over the oracle (possible because our
10
+ // chat-completions layer is natively capable where mlx-lm's gemma path
11
+ // is not — same divergence class as the Phase 4 bidirectional-mask fix):
12
+ // - tools: Anthropic `tools`/`tool_use`/`tool_result` map onto our REAL
13
+ // OpenAI tools surface (token-level gemma tool calling) instead of
14
+ // the oracle's Qwen-style <tool_call> inline-text hack ("out of
15
+ // scope for v1" upstream). Streamed tool calls emit proper
16
+ // tool_use content blocks with input_json_delta.
17
+ // - images: Anthropic image blocks (base64 or url source) map to our
18
+ // image_url vision parts; the oracle emits "[image omitted]".
19
+ // - usage: real prompt/completion token counts from the final OpenAI
20
+ // chunk (the oracle counts text chunks); our prompt-cache hit count
21
+ // surfaces as cache_read_input_tokens.
22
+ // - prior-turn `thinking` blocks are dropped on re-ingest (Anthropic
23
+ // semantics) instead of the oracle's json.dumps fallback.
24
+
25
+ import type { ChatMessage, ToolDefinition } from "./chat-template";
26
+
27
+ // ---------------------------------------------------------------------
28
+ // Request translation: Anthropic → OpenAI (our ChatRequest shape)
29
+ // ---------------------------------------------------------------------
30
+
31
+ interface AnthropicContentBlock {
32
+ type?: string;
33
+ text?: string;
34
+ thinking?: string;
35
+ id?: string;
36
+ name?: string;
37
+ input?: Record<string, unknown>;
38
+ tool_use_id?: string;
39
+ content?: unknown;
40
+ source?: { type?: string; media_type?: string; data?: string; url?: string };
41
+ }
42
+
43
+ export interface AnthropicRequest {
44
+ model?: string;
45
+ system?: string | AnthropicContentBlock[];
46
+ messages?: Array<{ role?: string; content?: string | AnthropicContentBlock[] }>;
47
+ max_tokens?: number;
48
+ temperature?: number;
49
+ top_p?: number;
50
+ top_k?: number;
51
+ stop_sequences?: string[];
52
+ stream?: boolean;
53
+ tools?: Array<{
54
+ name?: string;
55
+ description?: string;
56
+ input_schema?: Record<string, unknown>;
57
+ type?: string;
58
+ }>;
59
+ tool_choice?: { type?: string };
60
+ }
61
+
62
+ /** Flatten text-bearing blocks (used for system and tool_result content). */
63
+ function flattenText(content: unknown): string {
64
+ if (content == null) return "";
65
+ if (typeof content === "string") return content;
66
+ if (!Array.isArray(content)) return String(content);
67
+ const parts: string[] = [];
68
+ for (const block of content as AnthropicContentBlock[]) {
69
+ if (typeof block !== "object" || block === null) parts.push(String(block));
70
+ else if (block.type === "text") parts.push(block.text ?? "");
71
+ }
72
+ return parts.join("\n");
73
+ }
74
+
75
+ function imagePartFromBlock(block: AnthropicContentBlock): Record<string, unknown> | null {
76
+ const src = block.source;
77
+ if (!src) return null;
78
+ if (src.type === "base64" && src.data)
79
+ return {
80
+ type: "image_url",
81
+ image_url: { url: `data:${src.media_type ?? "image/png"};base64,${src.data}` },
82
+ };
83
+ if (src.type === "url" && src.url)
84
+ return { type: "image_url", image_url: { url: src.url } };
85
+ return null;
86
+ }
87
+
88
+ /** Translate an Anthropic /v1/messages body into the OpenAI
89
+ * /v1/chat/completions body our server consumes. Throws on
90
+ * structurally invalid input (caller maps to invalid_request_error). */
91
+ export function anthropicToChatBody(body: AnthropicRequest): Record<string, unknown> {
92
+ if (!Array.isArray(body.messages) || body.messages.length === 0)
93
+ throw new Error("messages: at least one message is required");
94
+
95
+ const messages: ChatMessage[] = [];
96
+ if (body.system != null) {
97
+ const sys = typeof body.system === "string" ? body.system : flattenText(body.system);
98
+ if (sys) messages.push({ role: "system", content: sys });
99
+ }
100
+
101
+ for (const m of body.messages) {
102
+ const role = m.role === "assistant" ? "assistant" : "user";
103
+ if (typeof m.content === "string" || m.content == null) {
104
+ messages.push({ role, content: m.content ?? "" });
105
+ continue;
106
+ }
107
+ const textParts: string[] = [];
108
+ const imageParts: Record<string, unknown>[] = [];
109
+ const toolCalls: NonNullable<ChatMessage["tool_calls"]> = [];
110
+ for (const block of m.content) {
111
+ switch (block.type) {
112
+ case "text":
113
+ textParts.push(block.text ?? "");
114
+ break;
115
+ case "thinking":
116
+ break; // prior-turn thinking is not re-fed (Anthropic semantics)
117
+ case "image": {
118
+ const part = imagePartFromBlock(block);
119
+ if (part) imageParts.push(part);
120
+ break;
121
+ }
122
+ case "tool_use":
123
+ toolCalls.push({
124
+ id: block.id ?? `toolu_${crypto.randomUUID().slice(0, 12)}`,
125
+ type: "function",
126
+ function: {
127
+ name: block.name ?? "",
128
+ arguments: JSON.stringify(block.input ?? {}),
129
+ },
130
+ });
131
+ break;
132
+ case "tool_result":
133
+ // Anthropic packs tool results into the next user message;
134
+ // OpenAI (and the gemma template) want role:"tool" messages.
135
+ messages.push({
136
+ role: "tool",
137
+ tool_call_id: block.tool_use_id,
138
+ content: flattenText(block.content),
139
+ });
140
+ break;
141
+ default:
142
+ if (block.text) textParts.push(block.text);
143
+ }
144
+ }
145
+ if (role === "assistant") {
146
+ const msg: ChatMessage = { role, content: textParts.join("\n") || null };
147
+ if (toolCalls.length) msg.tool_calls = toolCalls;
148
+ if (msg.content || toolCalls.length) messages.push(msg);
149
+ } else if (imageParts.length) {
150
+ messages.push({
151
+ role,
152
+ content: [
153
+ ...textParts.filter((t) => t).map((t) => ({ type: "text", text: t })),
154
+ ...imageParts,
155
+ ],
156
+ });
157
+ } else if (textParts.length) {
158
+ messages.push({ role, content: textParts.join("\n") });
159
+ }
160
+ }
161
+
162
+ const oai: Record<string, unknown> = { messages };
163
+ if (body.model != null) oai.model = body.model;
164
+ if (body.max_tokens != null) oai.max_tokens = body.max_tokens;
165
+ if (body.temperature != null) oai.temperature = body.temperature;
166
+ if (body.top_p != null) oai.top_p = body.top_p;
167
+ if (body.top_k != null) oai.top_k = body.top_k;
168
+ if (body.stop_sequences?.length) oai.stop = body.stop_sequences;
169
+ if (body.stream) oai.stream = true;
170
+
171
+ // Client tools only (entries with name + input_schema); server-tool
172
+ // types (web_search etc.) have no local meaning and are dropped.
173
+ const tools: ToolDefinition[] = (body.tools ?? [])
174
+ .filter((t) => t.name && t.input_schema)
175
+ .map((t) => ({
176
+ type: "function" as const,
177
+ function: {
178
+ name: t.name!,
179
+ description: t.description,
180
+ parameters: t.input_schema,
181
+ },
182
+ }));
183
+ if (tools.length) oai.tools = tools;
184
+ if (body.tool_choice?.type === "none") oai.tool_choice = "none";
185
+
186
+ return oai;
187
+ }
188
+
189
+ // ---------------------------------------------------------------------
190
+ // Response translation: OpenAI → Anthropic
191
+ // ---------------------------------------------------------------------
192
+
193
+ const STOP_REASON_MAP: Record<string, string> = {
194
+ stop: "end_turn",
195
+ length: "max_tokens",
196
+ tool_calls: "tool_use",
197
+ function_call: "tool_use",
198
+ };
199
+
200
+ const genMessageId = () => `msg_${crypto.randomUUID().replaceAll("-", "").slice(0, 24)}`;
201
+
202
+ function usageFromOpenAi(usage: any): Record<string, number> {
203
+ const out: Record<string, number> = {
204
+ input_tokens: Number(usage?.prompt_tokens ?? 0),
205
+ output_tokens: Number(usage?.completion_tokens ?? 0),
206
+ };
207
+ const cached = usage?.prompt_tokens_details?.cached_tokens;
208
+ if (cached) out.cache_read_input_tokens = Number(cached);
209
+ return out;
210
+ }
211
+
212
+ /** Non-streaming: OpenAI chat.completion JSON → Anthropic message JSON. */
213
+ export function chatJsonToAnthropic(oai: any, model: string): Record<string, unknown> {
214
+ const choice = oai?.choices?.[0] ?? {};
215
+ const message = choice.message ?? {};
216
+ const content: Record<string, unknown>[] = [];
217
+ if (message.reasoning)
218
+ content.push({ type: "thinking", thinking: message.reasoning, signature: "" });
219
+ if (message.content) content.push({ type: "text", text: message.content });
220
+ let finish: string | null = choice.finish_reason ?? null;
221
+ for (const tc of message.tool_calls ?? []) {
222
+ let input: unknown;
223
+ try {
224
+ input = JSON.parse(tc.function?.arguments ?? "{}");
225
+ } catch {
226
+ input = { _raw: tc.function?.arguments ?? "" };
227
+ }
228
+ content.push({
229
+ type: "tool_use",
230
+ id: tc.id ?? `toolu_${crypto.randomUUID().slice(0, 12)}`,
231
+ name: tc.function?.name,
232
+ input,
233
+ });
234
+ finish = "tool_calls";
235
+ }
236
+ return {
237
+ id: genMessageId(),
238
+ type: "message",
239
+ role: "assistant",
240
+ content,
241
+ model,
242
+ stop_reason: STOP_REASON_MAP[finish ?? "stop"] ?? "end_turn",
243
+ stop_sequence: null,
244
+ usage: usageFromOpenAi(oai?.usage),
245
+ };
246
+ }
247
+
248
+ // ---------------------------------------------------------------------
249
+ // Streaming: OpenAI chunk events → Anthropic SSE event frames
250
+ // ---------------------------------------------------------------------
251
+
252
+ const sse = (event: string, data: unknown): string =>
253
+ `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
254
+
255
+ /** Incremental translator (oracle class of the same name). Feed OpenAI
256
+ * chunk objects one at a time; each call returns the Anthropic SSE
257
+ * frames to flush BEFORE the next upstream token arrives (first-token
258
+ * latency on a laptop blows SDK timeouts otherwise — oracle finding). */
259
+ export class AnthropicStreamTranslator {
260
+ readonly msgId = genMessageId();
261
+ #headersSent = false;
262
+ #thinkingStarted = false;
263
+ #thinkingClosed = false;
264
+ #textStarted = false;
265
+ #textIndex = 0;
266
+ #nextIndex = 0;
267
+ #stopReason = "end_turn";
268
+ #countedTokens = 0;
269
+ #usage: Record<string, number> | null = null;
270
+
271
+ constructor(readonly model: string) {}
272
+
273
+ #headers(out: string[]): void {
274
+ if (this.#headersSent) return;
275
+ this.#headersSent = true;
276
+ out.push(
277
+ sse("message_start", {
278
+ type: "message_start",
279
+ message: {
280
+ id: this.msgId, type: "message", role: "assistant",
281
+ model: this.model, content: [],
282
+ stop_reason: null, stop_sequence: null,
283
+ usage: { input_tokens: 0, output_tokens: 0 },
284
+ },
285
+ }),
286
+ );
287
+ }
288
+
289
+ #closeThinking(out: string[]): void {
290
+ out.push(
291
+ sse("content_block_delta", {
292
+ type: "content_block_delta",
293
+ index: 0,
294
+ // unsigned local reasoning: empty signature (spec allows it)
295
+ delta: { type: "signature_delta", signature: "" },
296
+ }),
297
+ sse("content_block_stop", { type: "content_block_stop", index: 0 }),
298
+ );
299
+ this.#thinkingClosed = true;
300
+ }
301
+
302
+ #closeText(out: string[]): void {
303
+ out.push(sse("content_block_stop", { type: "content_block_stop", index: this.#textIndex }));
304
+ this.#textStarted = false;
305
+ }
306
+
307
+ addChunk(chunk: any): string[] {
308
+ const out: string[] = [];
309
+ this.#headers(out);
310
+ const choice = chunk?.choices?.[0] ?? {};
311
+ const delta = choice.delta ?? {};
312
+ const fin = choice.finish_reason;
313
+
314
+ const reasoning = delta.reasoning;
315
+ if (reasoning) {
316
+ if (!this.#thinkingStarted) {
317
+ this.#thinkingStarted = true;
318
+ this.#nextIndex = 1;
319
+ out.push(
320
+ sse("content_block_start", {
321
+ type: "content_block_start",
322
+ index: 0,
323
+ content_block: { type: "thinking", thinking: "" },
324
+ }),
325
+ );
326
+ }
327
+ out.push(
328
+ sse("content_block_delta", {
329
+ type: "content_block_delta",
330
+ index: 0,
331
+ delta: { type: "thinking_delta", thinking: reasoning },
332
+ }),
333
+ );
334
+ }
335
+
336
+ const text = delta.content;
337
+ if (text) {
338
+ if (this.#thinkingStarted && !this.#thinkingClosed) this.#closeThinking(out);
339
+ if (!this.#textStarted) {
340
+ this.#textStarted = true;
341
+ this.#textIndex = this.#nextIndex++;
342
+ out.push(
343
+ sse("content_block_start", {
344
+ type: "content_block_start",
345
+ index: this.#textIndex,
346
+ content_block: { type: "text", text: "" },
347
+ }),
348
+ );
349
+ }
350
+ out.push(
351
+ sse("content_block_delta", {
352
+ type: "content_block_delta",
353
+ index: this.#textIndex,
354
+ delta: { type: "text_delta", text },
355
+ }),
356
+ );
357
+ this.#countedTokens++;
358
+ }
359
+
360
+ // Our server emits structured tool calls in one delta chunk; each
361
+ // becomes a complete tool_use block (start → input_json_delta → stop).
362
+ for (const tc of delta.tool_calls ?? []) {
363
+ if (this.#thinkingStarted && !this.#thinkingClosed) this.#closeThinking(out);
364
+ if (this.#textStarted) this.#closeText(out);
365
+ const idx = this.#nextIndex++;
366
+ out.push(
367
+ sse("content_block_start", {
368
+ type: "content_block_start",
369
+ index: idx,
370
+ content_block: {
371
+ type: "tool_use",
372
+ id: tc.id ?? `toolu_${crypto.randomUUID().slice(0, 12)}`,
373
+ name: tc.function?.name ?? "",
374
+ input: {},
375
+ },
376
+ }),
377
+ sse("content_block_delta", {
378
+ type: "content_block_delta",
379
+ index: idx,
380
+ delta: { type: "input_json_delta", partial_json: tc.function?.arguments ?? "{}" },
381
+ }),
382
+ sse("content_block_stop", { type: "content_block_stop", index: idx }),
383
+ );
384
+ this.#stopReason = "tool_use";
385
+ }
386
+
387
+ if (fin) {
388
+ if (this.#stopReason !== "tool_use")
389
+ this.#stopReason = STOP_REASON_MAP[fin] ?? "end_turn";
390
+ if (chunk.usage) this.#usage = usageFromOpenAi(chunk.usage);
391
+ }
392
+ return out;
393
+ }
394
+
395
+ finalize(): string[] {
396
+ const out: string[] = [];
397
+ this.#headers(out);
398
+ if (this.#thinkingStarted && !this.#thinkingClosed) this.#closeThinking(out);
399
+ if (this.#textStarted) this.#closeText(out);
400
+ else if (!this.#thinkingStarted && this.#nextIndex === 0) {
401
+ // empty generation: emit one empty text block (oracle behavior)
402
+ out.push(
403
+ sse("content_block_start", {
404
+ type: "content_block_start",
405
+ index: 0,
406
+ content_block: { type: "text", text: "" },
407
+ }),
408
+ sse("content_block_stop", { type: "content_block_stop", index: 0 }),
409
+ );
410
+ }
411
+ out.push(
412
+ sse("message_delta", {
413
+ type: "message_delta",
414
+ delta: { stop_reason: this.#stopReason, stop_sequence: null },
415
+ // real usage from the final OpenAI chunk when present; the
416
+ // oracle's chunk count is the fallback
417
+ usage: this.#usage ?? { output_tokens: this.#countedTokens },
418
+ }),
419
+ sse("message_stop", { type: "message_stop" }),
420
+ );
421
+ return out;
422
+ }
423
+ }
424
+
425
+ /** Wrap our OpenAI SSE byte stream as an Anthropic SSE byte stream.
426
+ * Frames translate as they arrive (per-chunk flush preserved — the
427
+ * upstream already rate-limits socket flushes). */
428
+ export function translateOpenAiSse(
429
+ upstream: ReadableStream<Uint8Array>,
430
+ model: string,
431
+ ): ReadableStream<Uint8Array> {
432
+ const translator = new AnthropicStreamTranslator(model);
433
+ const enc = new TextEncoder();
434
+ const dec = new TextDecoder();
435
+ let buf = "";
436
+ let finalized = false;
437
+ return new ReadableStream<Uint8Array>({
438
+ async start(controller) {
439
+ const emit = (frames: string[]) => {
440
+ for (const f of frames) controller.enqueue(enc.encode(f));
441
+ };
442
+ const reader = upstream.getReader();
443
+ try {
444
+ while (true) {
445
+ const { done, value } = await reader.read();
446
+ if (done) break;
447
+ buf += dec.decode(value, { stream: true });
448
+ let sep: number;
449
+ while ((sep = buf.indexOf("\n\n")) !== -1) {
450
+ const frame = buf.slice(0, sep);
451
+ buf = buf.slice(sep + 2);
452
+ const data = frame
453
+ .split("\n")
454
+ .find((l) => l.startsWith("data: "))
455
+ ?.slice(6);
456
+ if (data == null) continue;
457
+ if (data === "[DONE]" || data === '"[DONE]"') {
458
+ if (!finalized) {
459
+ finalized = true;
460
+ emit(translator.finalize());
461
+ }
462
+ continue;
463
+ }
464
+ const parsed = JSON.parse(data);
465
+ if (parsed?.error) {
466
+ emit([
467
+ sse("error", {
468
+ type: "error",
469
+ error: { type: "api_error", message: parsed.error.message ?? "generation failed" },
470
+ }),
471
+ ]);
472
+ continue;
473
+ }
474
+ emit(translator.addChunk(parsed));
475
+ }
476
+ }
477
+ if (!finalized) emit(translator.finalize());
478
+ } catch (e) {
479
+ emit([
480
+ sse("error", {
481
+ type: "error",
482
+ error: { type: "api_error", message: (e as Error).message },
483
+ }),
484
+ ]);
485
+ } finally {
486
+ reader.releaseLock();
487
+ controller.close();
488
+ }
489
+ },
490
+ });
491
+ }