@yolk-sdk/emulators 0.1.0-canary.96

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 (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. package/src/xai.ts +341 -0
@@ -0,0 +1,924 @@
1
+ /**
2
+ * OpenAI-compatible Chat Completions emulator core (internal; not a package export).
3
+ *
4
+ * One `POST .../chat/completions` route with request parsing, `chat.completion.chunk` SSE
5
+ * framing (text, reasoning, tool-call argument fragments, a finish event, usage, and
6
+ * `data: [DONE]`), the non-streamed `chat.completion` JSON body, and scripted turns. Faults, the
7
+ * request ledger, the `/_emulate/*` control plane, evidence tagging, and route binding come from
8
+ * the shared kernel (`emulator-kernel.ts`).
9
+ *
10
+ * Each emulator subpath (`gateway`, `openai`) supplies what differs: the path and route evidence
11
+ * manifest, model lists, the error envelope and the unknown-model status and error, the 401
12
+ * error, the completion-token request field, whether reasoning is emulated, its scripted-turn
13
+ * schema, and its wire profile (`ChatWireProfile`: events per network chunk, usage placement, the
14
+ * reasoning field, and extra per-chunk fields). Without a profile the plain OpenAI Chat
15
+ * Completions wire is sent.
16
+ *
17
+ * Runtime-portable Web APIs only (`Request`, `Response`, `ReadableStream`, `TextEncoder`, `URL`);
18
+ * no Node builtins and no SDK imports.
19
+ */
20
+ import { Predicate, Result } from 'effect'
21
+ import * as Schema from 'effect/Schema'
22
+ import {
23
+ approximateTokens,
24
+ EmulatorErrorAfterChunksFault,
25
+ EmulatorFaultMatch,
26
+ EmulatorScriptedError,
27
+ EmulatorStatusFault,
28
+ EmulatorTruncateAfterChunksFault,
29
+ jsonField,
30
+ jsonResponse,
31
+ makeEmulatorKernel,
32
+ parseJson,
33
+ readText,
34
+ splitFragments,
35
+ stringField,
36
+ synthesizeValue,
37
+ type EmulatorApi,
38
+ type EmulatorCoverage,
39
+ type EmulatorFaultState,
40
+ type EmulatorRouteCoverage,
41
+ type KernelLedgerEntry
42
+ } from './emulator-kernel.ts'
43
+ import {
44
+ emulatorRouteKey,
45
+ type EmulatorEvidence,
46
+ type EmulatorRouteEvidence
47
+ } from './route-evidence.ts'
48
+
49
+ /** Optional fault filter; an omitted field matches every request. `path` ending in `*` is a prefix. */
50
+ export const ChatFaultMatch = EmulatorFaultMatch
51
+
52
+ export type ChatFaultMatch = typeof ChatFaultMatch.Type
53
+
54
+ /**
55
+ * Wire faults for emulated routes:
56
+ *
57
+ * - `status`: answer with this status, headers, and body instead of a
58
+ * completion (for example 429 with `retry-after`). The body defaults to the
59
+ * emulator's error envelope. Statuses that cannot carry a body (1xx, 204,
60
+ * 205, 304) and redirects (3xx) are rejected, as are invalid header names or
61
+ * values and a `location` header; scripted errors follow the same rules.
62
+ * - `error-after-chunks`: send `chunks` body chunks, then error the body
63
+ * stream (a dropped connection).
64
+ * - `truncate-after-chunks`: send `chunks` body chunks, then close the body
65
+ * cleanly (for example without `data: [DONE]`).
66
+ *
67
+ * A whole JSON body counts as one chunk. A chunk fault that cannot take
68
+ * effect (`error-after-chunks` beyond the chunk count, or
69
+ * `truncate-after-chunks` at or beyond it) answers 500 with an emulator error
70
+ * instead of silently doing nothing, and is not consumed.
71
+ */
72
+ export const ChatFault = Schema.Union([
73
+ EmulatorStatusFault,
74
+ EmulatorErrorAfterChunksFault,
75
+ EmulatorTruncateAfterChunksFault
76
+ ])
77
+
78
+ export type ChatFault = typeof ChatFault.Type
79
+
80
+ export type ChatFaultKind = ChatFault['kind']
81
+
82
+ const TokenCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
83
+
84
+ /** Usage for a scripted turn (sent as the wire `usage` object). */
85
+ export const ChatScriptedUsage = Schema.Struct({
86
+ promptTokens: TokenCount,
87
+ completionTokens: TokenCount,
88
+ reasoningTokens: Schema.optionalKey(TokenCount)
89
+ })
90
+
91
+ export type ChatScriptedUsage = typeof ChatScriptedUsage.Type
92
+
93
+ /** One scripted tool call; its JSON arguments stream as these fragments. */
94
+ export const ChatScriptedToolCall = Schema.Struct({
95
+ name: Schema.NonEmptyString,
96
+ argumentFragments: Schema.Array(Schema.String),
97
+ id: Schema.optionalKey(Schema.NonEmptyString)
98
+ })
99
+
100
+ export type ChatScriptedToolCall = typeof ChatScriptedToolCall.Type
101
+
102
+ /** Scripted completion fields every Chat Completions emulator accepts. */
103
+ export const chatScriptedCompletionFields = {
104
+ text: Schema.optionalKey(Schema.Array(Schema.String)),
105
+ toolCalls: Schema.optionalKey(Schema.Array(ChatScriptedToolCall)),
106
+ usage: Schema.optionalKey(Schema.NullOr(ChatScriptedUsage)),
107
+ finishReason: Schema.optionalKey(Schema.NonEmptyString)
108
+ }
109
+
110
+ /** Scripted completion fields for emulators that emulate streamed reasoning. */
111
+ export const chatScriptedReasoningFields = {
112
+ reasoning: Schema.optionalKey(Schema.Array(Schema.String)),
113
+ reasoningField: Schema.optionalKey(Schema.Literals(['reasoning_content', 'reasoning'])),
114
+ order: Schema.optionalKey(Schema.Literals(['reasoning-first', 'text-first']))
115
+ }
116
+
117
+ /**
118
+ * The reasoning variant of a scripted completion (the Gateway's shape; `/openai` uses the
119
+ * reasoning-free `OpenAiScriptedCompletion`). Every field is exact (nothing is
120
+ * filled in) except: `usage` omitted is synthesized when the request asks for
121
+ * usage, and `null` drops it; `finishReason` omitted is `tool_calls` with tool
122
+ * calls, else `stop`. `order` defaults to `reasoning-first`; `reasoningField`
123
+ * defaults to the emulator's wire profile field (`reasoning` for the Gateway,
124
+ * as recorded live; `reasoning_content` is the DeepSeek-native alternative).
125
+ */
126
+ export const ChatScriptedReasoningCompletion = Schema.Struct({
127
+ text: chatScriptedCompletionFields.text,
128
+ reasoning: chatScriptedReasoningFields.reasoning,
129
+ reasoningField: chatScriptedReasoningFields.reasoningField,
130
+ order: chatScriptedReasoningFields.order,
131
+ toolCalls: chatScriptedCompletionFields.toolCalls,
132
+ usage: chatScriptedCompletionFields.usage,
133
+ finishReason: chatScriptedCompletionFields.finishReason
134
+ })
135
+
136
+ export type ChatScriptedReasoningCompletion = typeof ChatScriptedReasoningCompletion.Type
137
+
138
+ /** A scripted error response: status, body (a string is sent as is), and optional headers. */
139
+ export const ChatScriptedError = EmulatorScriptedError
140
+
141
+ export type ChatScriptedError = typeof ChatScriptedError.Type
142
+
143
+ /**
144
+ * The reasoning variant of a scripted turn, queued for the next chat completion request. It is
145
+ * also the widest turn shape, so it bounds every emulator's turn type.
146
+ */
147
+ export const ChatScriptedReasoningTurn = Schema.Union([
148
+ ChatScriptedError,
149
+ ChatScriptedReasoningCompletion
150
+ ])
151
+
152
+ export type ChatScriptedReasoningTurn = typeof ChatScriptedReasoningTurn.Type
153
+
154
+ export type ChatLedgerEntry = {
155
+ /** 1-based arrival order since the last ledger clear or reset. */
156
+ readonly seq: number
157
+ readonly method: string
158
+ readonly path: string
159
+ /** Parsed JSON request body, when it was valid JSON. */
160
+ readonly body?: Schema.Json
161
+ readonly model?: string
162
+ readonly stream?: boolean
163
+ /**
164
+ * The output-token limit from the emulator's completion-token request field (`max_tokens` or
165
+ * `max_completion_tokens`), when it is a number. Recorded as sent; never validated.
166
+ */
167
+ readonly maxCompletionTokens?: number
168
+ readonly reasoningEffort?: string
169
+ readonly thinking?: Schema.Json
170
+ readonly toolNames: ReadonlyArray<string>
171
+ /** Kind of the fault that shaped the response. */
172
+ readonly fault?: ChatFaultKind
173
+ /** Why a matching fault could not take effect (the response was a 500 emulator error). */
174
+ readonly faultError?: string
175
+ /**
176
+ * Set when the emulator could not build the planned response; the request was
177
+ * answered with a 500 emulator error (still evidence-tagged) and no fault was used up.
178
+ */
179
+ readonly responseError?: string
180
+ /** Set when a scripted turn answered the request. */
181
+ readonly scripted?: 'completion' | 'error'
182
+ /** Evidence of the matched route; `unknown-route` for requests that failed closed. */
183
+ readonly evidence: EmulatorEvidence | 'unknown-route'
184
+ readonly status: number
185
+ /** Body chunks handed to the transport so far (each produced when pulled). */
186
+ readonly bodyChunks: number
187
+ }
188
+
189
+ export type ChatFaultState = EmulatorFaultState<ChatFault>
190
+
191
+ export type ChatRouteCoverage = EmulatorRouteCoverage
192
+
193
+ export type ChatCoverage = EmulatorCoverage
194
+
195
+ export type ChatCompletionsEmulator<Turn> = EmulatorApi<Turn, ChatFault, ChatLedgerEntry>
196
+
197
+ /** One wire error: the emulator's envelope renders it. */
198
+ export type ChatWireError = {
199
+ readonly message: string
200
+ readonly type: string
201
+ readonly code: string
202
+ }
203
+
204
+ /** The id and model every chunk of one emulated response repeats. */
205
+ export type ChatResponseIdentity = { readonly id: string; readonly model: string }
206
+
207
+ /** Token counts behind a wire `usage` object. */
208
+ export type ChatUsageCounts = {
209
+ readonly promptTokens: number
210
+ readonly completionTokens: number
211
+ readonly reasoningTokens: number | undefined
212
+ }
213
+
214
+ /**
215
+ * Per-service response details on top of the shared framing. Every field is optional; an omitted
216
+ * field keeps the plain OpenAI Chat Completions wire (the `/openai` emulator passes no profile).
217
+ */
218
+ export type ChatWireProfile = {
219
+ /**
220
+ * SSE events per network (body) chunk; default 1. Events are packed counting from the end, so
221
+ * the last chunk carries the finish event together with `data: [DONE]` and the first chunk may
222
+ * carry fewer. Chunk faults count these network chunks.
223
+ */
224
+ readonly eventsPerChunk?: number
225
+ /**
226
+ * Where streamed usage goes when `stream_options.include_usage` is set: a trailing chunk with
227
+ * empty `choices` (`usage-chunk`, default) or the finish event itself (`finish-event`).
228
+ */
229
+ readonly streamUsage?: 'usage-chunk' | 'finish-event'
230
+ /** The first streamed delta; default `{ role: 'assistant', content: '' }`. */
231
+ readonly openingDelta?: Schema.JsonObject
232
+ /** Extra fields on every streamed choice, placed before `finish_reason` (for example `logprobs`). */
233
+ readonly choiceFields?: Schema.JsonObject
234
+ /** The default reasoning field for default and scripted output; default `reasoning_content`. */
235
+ readonly reasoningField?: 'reasoning_content' | 'reasoning'
236
+ /** Streamed `reasoning` deltas also carry `reasoning_details` (`reasoning.text` entries). */
237
+ readonly reasoningDetails?: boolean
238
+ /** Extra top-level fields on every streamed chunk (for example `system_fingerprint`). */
239
+ readonly chunkFields?: (identity: ChatResponseIdentity) => Schema.JsonObject
240
+ /** Extra fields on the finish event: top-level (`chunk`) and inside its `delta`. */
241
+ readonly finishFields?: (identity: ChatResponseIdentity) => {
242
+ readonly chunk: Schema.JsonObject
243
+ readonly delta: Schema.JsonObject
244
+ }
245
+ /** Renders token counts as the wire `usage` object (streamed and JSON). */
246
+ readonly usage?: (counts: ChatUsageCounts) => Schema.JsonObject
247
+ }
248
+
249
+ /**
250
+ * What differs between Chat Completions emulators. The core owns everything else (framing,
251
+ * faults, scripting, ledger, control plane, evidence).
252
+ */
253
+ export type ChatCompletionsEmulatorConfig<Turn extends ChatScriptedReasoningTurn> = {
254
+ /** The chat completions path, for example `/v1/chat/completions`. */
255
+ readonly path: string
256
+ /** Route evidence manifest; must list exactly the chat completions route. */
257
+ readonly routes: ReadonlyArray<EmulatorRouteEvidence>
258
+ readonly knownModels: ReadonlyArray<string>
259
+ /**
260
+ * Streamed reasoning: `undefined` when the emulator does not emulate reasoning (default plans
261
+ * never reason and `/_emulate/state` omits `reasoningModels`); otherwise the models that stream
262
+ * reasoning when a request asks for it.
263
+ */
264
+ readonly reasoningModels: ReadonlyArray<string> | undefined
265
+ /** Renders a wire error as the service's JSON error envelope. */
266
+ readonly errorEnvelope: (error: ChatWireError) => Schema.Json
267
+ /**
268
+ * Status and answer for an unknown (or missing) model id: an `error` rendered with
269
+ * `errorEnvelope`, or a `body` built from the requested id (for a service whose unknown-model
270
+ * envelope differs from its other errors).
271
+ */
272
+ readonly unknownModel:
273
+ | { readonly status: number; readonly error: ChatWireError }
274
+ | { readonly status: number; readonly body: (model: string | undefined) => Schema.Json }
275
+ /**
276
+ * Requests authenticate with a non-empty `Authorization: Bearer` credential, never checked or
277
+ * stored; anything else answers 401 with `unauthorized`.
278
+ */
279
+ readonly auth: { readonly unauthorized: ChatWireError }
280
+ /** Request field carrying the output-token limit, recorded in the ledger. */
281
+ readonly completionTokenField: 'max_tokens' | 'max_completion_tokens'
282
+ /** Response id prefix; ids are `${prefix}-${seq}`. */
283
+ readonly responseIdPrefix: string
284
+ /** Default answer text deltas. */
285
+ readonly defaultText: ReadonlyArray<string>
286
+ /** The emulator's scripted-turn schema (decoded strictly). */
287
+ readonly turnSchema: Schema.Decoder<Turn>
288
+ /** Builds the error thrown by the JS API (`faults.add`, `script.enqueue`) for invalid input. */
289
+ readonly inputInvalid: (input: 'fault' | 'turn', reason: string) => Error
290
+ /** Per-service wire details; omitted: the plain OpenAI Chat Completions wire. */
291
+ readonly wire?: ChatWireProfile
292
+ }
293
+
294
+ const ChatTool = Schema.Struct({
295
+ type: Schema.optionalKey(Schema.String),
296
+ function: Schema.Struct({
297
+ name: Schema.String,
298
+ description: Schema.optionalKey(Schema.String),
299
+ parameters: Schema.optionalKey(Schema.Json)
300
+ })
301
+ })
302
+
303
+ const ChatRequest = Schema.Struct({
304
+ model: Schema.optionalKey(Schema.String),
305
+ messages: Schema.Array(Schema.Json),
306
+ stream: Schema.optionalKey(Schema.NullOr(Schema.Boolean)),
307
+ stream_options: Schema.optionalKey(
308
+ Schema.NullOr(
309
+ Schema.Struct({ include_usage: Schema.optionalKey(Schema.NullOr(Schema.Boolean)) })
310
+ )
311
+ ),
312
+ reasoning_effort: Schema.optionalKey(Schema.NullOr(Schema.String)),
313
+ thinking: Schema.optionalKey(Schema.Json),
314
+ tools: Schema.optionalKey(Schema.NullOr(Schema.Array(ChatTool))),
315
+ tool_choice: Schema.optionalKey(Schema.Json)
316
+ })
317
+
318
+ type ChatRequest = typeof ChatRequest.Type
319
+
320
+ const decodeChatRequest = Schema.decodeUnknownResult(ChatRequest)
321
+
322
+ // Synthetic creation time shared by every emulated response (deterministic output).
323
+ const syntheticCreated = 1790000000
324
+
325
+ // A non-empty bearer credential. The value is never checked or stored.
326
+ const bearerPattern = /^bearer\s+\S+/i
327
+
328
+ type CompletionPlan = {
329
+ readonly reasoning: ReadonlyArray<string>
330
+ readonly reasoningField: 'reasoning_content' | 'reasoning'
331
+ readonly order: 'reasoning-first' | 'text-first'
332
+ readonly text: ReadonlyArray<string>
333
+ readonly toolCalls: ReadonlyArray<{
334
+ readonly id: string
335
+ readonly name: string
336
+ readonly fragments: ReadonlyArray<string>
337
+ }>
338
+ /** `null`: never send usage; `undefined`: synthesize when requested. */
339
+ readonly usage: ChatScriptedUsage | null | undefined
340
+ readonly finishReason: string
341
+ }
342
+
343
+ const defaultReasoning = ['The user wants a short reply.', ' Keep it brief.']
344
+
345
+ const reasoningRequested = (request: ChatRequest): boolean => {
346
+ const effort = request.reasoning_effort?.trim().toLowerCase()
347
+
348
+ return (
349
+ (effort !== undefined && effort.length > 0 && effort !== 'none') ||
350
+ stringField(request.thinking, 'type') === 'enabled'
351
+ )
352
+ }
353
+
354
+ const chosenTool = (request: ChatRequest) => {
355
+ const tools = request.tools ?? []
356
+
357
+ if (tools.length === 0 || request.tool_choice === 'none') {
358
+ return undefined
359
+ }
360
+
361
+ const forced = stringField(jsonField(request.tool_choice, 'function'), 'name')
362
+
363
+ return tools.find(tool => tool.function.name === forced) ?? tools[0]
364
+ }
365
+
366
+ const defaultPlan = (
367
+ request: ChatRequest,
368
+ seq: number,
369
+ reasoningModels: ReadonlyArray<string>,
370
+ defaultText: ReadonlyArray<string>,
371
+ reasoningField: CompletionPlan['reasoningField']
372
+ ): CompletionPlan => {
373
+ const reasoning =
374
+ request.model !== undefined &&
375
+ reasoningModels.includes(request.model) &&
376
+ reasoningRequested(request)
377
+ ? defaultReasoning
378
+ : []
379
+
380
+ const tool = chosenTool(request)
381
+
382
+ const base: Pick<CompletionPlan, 'reasoning' | 'reasoningField' | 'order' | 'usage'> = {
383
+ reasoning,
384
+ reasoningField,
385
+ order: 'reasoning-first',
386
+ usage: undefined
387
+ }
388
+
389
+ if (tool === undefined) {
390
+ return { ...base, text: defaultText, toolCalls: [], finishReason: 'stop' }
391
+ }
392
+
393
+ const argumentsText = JSON.stringify(
394
+ synthesizeValue(tool.function.parameters ?? { type: 'object' }, tool.function.name, 0)
395
+ )
396
+
397
+ return {
398
+ ...base,
399
+ text: [],
400
+ toolCalls: [
401
+ {
402
+ id: `call_synthetic_${seq}_0`,
403
+ name: tool.function.name,
404
+ fragments: splitFragments(argumentsText, 3)
405
+ }
406
+ ],
407
+ finishReason: 'tool_calls'
408
+ }
409
+ }
410
+
411
+ const scriptedPlan = (
412
+ turn: ChatScriptedReasoningCompletion,
413
+ seq: number,
414
+ reasoningField: CompletionPlan['reasoningField']
415
+ ): CompletionPlan => {
416
+ const toolCalls = (turn.toolCalls ?? []).map((call, index) => ({
417
+ id: call.id ?? `call_synthetic_${seq}_${index}`,
418
+ name: call.name,
419
+ fragments: call.argumentFragments
420
+ }))
421
+
422
+ return {
423
+ reasoning: turn.reasoning ?? [],
424
+ reasoningField: turn.reasoningField ?? reasoningField,
425
+ order: turn.order ?? 'reasoning-first',
426
+ text: turn.text ?? [],
427
+ toolCalls,
428
+ usage: turn.usage,
429
+ finishReason: turn.finishReason ?? (toolCalls.length > 0 ? 'tool_calls' : 'stop')
430
+ }
431
+ }
432
+
433
+ // Wire shapes of the OpenAI-compatible Chat Completions responses the emulator sends. Profile
434
+ // fields (`ChatWireProfile`) add to them.
435
+ type WireToolCallDelta = {
436
+ readonly index: number
437
+ readonly id?: string
438
+ readonly type?: 'function'
439
+ readonly function: { readonly name?: string; readonly arguments: string }
440
+ }
441
+
442
+ type WireReasoningDetail = {
443
+ readonly type: 'reasoning.text'
444
+ readonly text: string
445
+ readonly format: 'unknown'
446
+ readonly index: number
447
+ }
448
+
449
+ type WireDelta = {
450
+ readonly role?: 'assistant'
451
+ readonly content?: string
452
+ readonly reasoning_content?: string
453
+ readonly reasoning?: string
454
+ readonly reasoning_details?: ReadonlyArray<WireReasoningDetail>
455
+ readonly tool_calls?: ReadonlyArray<WireToolCallDelta>
456
+ }
457
+
458
+ type WireStreamChoice = {
459
+ readonly index: number
460
+ readonly delta: WireDelta | Schema.JsonObject
461
+ readonly finish_reason: string | null
462
+ }
463
+
464
+ type WireMessage = {
465
+ role: 'assistant'
466
+ content: string | null
467
+ reasoning_content?: string
468
+ reasoning?: string
469
+ tool_calls?: ReadonlyArray<{
470
+ readonly id: string
471
+ readonly type: 'function'
472
+ readonly function: { readonly name: string; readonly arguments: string }
473
+ }>
474
+ }
475
+
476
+ type WireCompletion = {
477
+ id: string
478
+ object: 'chat.completion'
479
+ created: number
480
+ model: string
481
+ choices: ReadonlyArray<{
482
+ readonly index: number
483
+ readonly message: WireMessage
484
+ readonly finish_reason: string
485
+ }>
486
+ usage?: Schema.JsonObject
487
+ }
488
+
489
+ /** A wire profile with every default filled in. */
490
+ type ResolvedWireProfile = {
491
+ readonly eventsPerChunk: number
492
+ readonly streamUsage: 'usage-chunk' | 'finish-event'
493
+ readonly openingDelta: Schema.JsonObject
494
+ readonly choiceFields: Schema.JsonObject
495
+ readonly reasoningField: 'reasoning_content' | 'reasoning'
496
+ readonly reasoningDetails: boolean
497
+ readonly chunkFields: (identity: ChatResponseIdentity) => Schema.JsonObject
498
+ readonly finishFields: (identity: ChatResponseIdentity) => {
499
+ readonly chunk: Schema.JsonObject
500
+ readonly delta: Schema.JsonObject
501
+ }
502
+ readonly usage: (counts: ChatUsageCounts) => Schema.JsonObject
503
+ }
504
+
505
+ /** The plain OpenAI Chat Completions usage object. */
506
+ const openAiUsage = (counts: ChatUsageCounts): Schema.JsonObject => {
507
+ const usage = {
508
+ prompt_tokens: counts.promptTokens,
509
+ completion_tokens: counts.completionTokens,
510
+ total_tokens: counts.promptTokens + counts.completionTokens
511
+ }
512
+
513
+ return counts.reasoningTokens === undefined
514
+ ? usage
515
+ : { ...usage, completion_tokens_details: { reasoning_tokens: counts.reasoningTokens } }
516
+ }
517
+
518
+ const noFields = (): Schema.JsonObject => ({})
519
+
520
+ const resolveWireProfile = (profile: ChatWireProfile = {}): ResolvedWireProfile => ({
521
+ eventsPerChunk: profile.eventsPerChunk ?? 1,
522
+ streamUsage: profile.streamUsage ?? 'usage-chunk',
523
+ openingDelta: profile.openingDelta ?? { role: 'assistant', content: '' },
524
+ choiceFields: profile.choiceFields ?? {},
525
+ reasoningField: profile.reasoningField ?? 'reasoning_content',
526
+ reasoningDetails: profile.reasoningDetails ?? false,
527
+ chunkFields: profile.chunkFields ?? noFields,
528
+ finishFields: profile.finishFields ?? (() => ({ chunk: {}, delta: {} })),
529
+ usage: profile.usage ?? openAiUsage
530
+ })
531
+
532
+ const usageCounts = (plan: CompletionPlan, requestText: string): ChatUsageCounts | undefined => {
533
+ if (plan.usage === null) {
534
+ return undefined
535
+ }
536
+
537
+ if (plan.usage !== undefined) {
538
+ return {
539
+ promptTokens: plan.usage.promptTokens,
540
+ completionTokens: plan.usage.completionTokens,
541
+ reasoningTokens: plan.usage.reasoningTokens
542
+ }
543
+ }
544
+
545
+ const reasoningText = plan.reasoning.join('')
546
+
547
+ const outputText = [
548
+ ...plan.text,
549
+ ...plan.reasoning,
550
+ ...plan.toolCalls.flatMap(call => call.fragments)
551
+ ].join('')
552
+
553
+ return {
554
+ promptTokens: approximateTokens(requestText),
555
+ completionTokens: approximateTokens(outputText),
556
+ reasoningTokens: reasoningText.length > 0 ? approximateTokens(reasoningText) : undefined
557
+ }
558
+ }
559
+
560
+ /** One SSE event from its `data:` text (a JSON payload, or `[DONE]`). */
561
+ const sseEvent = (data: string): string => `data: ${data}\n\n`
562
+
563
+ /**
564
+ * Pack SSE events into network chunks of `perChunk` events, counting from the end: the last
565
+ * chunk always holds the final events (the finish event and `data: [DONE]` when `perChunk` is at
566
+ * least 2, as the live Gateway sends them), and the first chunk may hold fewer.
567
+ */
568
+ const packEvents = (events: ReadonlyArray<string>, perChunk: number): ReadonlyArray<string> => {
569
+ if (perChunk <= 1) {
570
+ return events
571
+ }
572
+
573
+ const chunks: Array<string> = []
574
+ const first = events.length % perChunk
575
+
576
+ if (first > 0) {
577
+ chunks.push(events.slice(0, first).join(''))
578
+ }
579
+
580
+ for (let index = first; index < events.length; index += perChunk) {
581
+ chunks.push(events.slice(index, index + perChunk).join(''))
582
+ }
583
+
584
+ return chunks
585
+ }
586
+
587
+ const reasoningDelta = (
588
+ field: CompletionPlan['reasoningField'],
589
+ text: string,
590
+ details: boolean
591
+ ): WireDelta =>
592
+ field === 'reasoning_content'
593
+ ? { reasoning_content: text }
594
+ : details
595
+ ? {
596
+ reasoning: text,
597
+ reasoning_details: [{ type: 'reasoning.text', text, format: 'unknown', index: 0 }]
598
+ }
599
+ : { reasoning: text }
600
+
601
+ /** SSE events for a streamed completion, one per array entry (packed into chunks later). */
602
+ const streamEvents = (
603
+ plan: CompletionPlan,
604
+ identity: ChatResponseIdentity,
605
+ usage: Schema.JsonObject | undefined,
606
+ wire: ResolvedWireProfile
607
+ ): ReadonlyArray<string> => {
608
+ const events: Array<string> = []
609
+ const extras = wire.chunkFields(identity)
610
+
611
+ const event = (
612
+ choices: ReadonlyArray<WireStreamChoice>,
613
+ fields: Schema.JsonObject = {},
614
+ trailing: Schema.JsonObject = {}
615
+ ) =>
616
+ events.push(
617
+ sseEvent(
618
+ JSON.stringify({
619
+ id: identity.id,
620
+ object: 'chat.completion.chunk',
621
+ created: syntheticCreated,
622
+ model: identity.model,
623
+ choices,
624
+ ...fields,
625
+ ...extras,
626
+ ...trailing
627
+ })
628
+ )
629
+ )
630
+
631
+ const choice = (
632
+ delta: WireDelta | Schema.JsonObject,
633
+ finishReason: string | null = null
634
+ ): WireStreamChoice => ({
635
+ index: 0,
636
+ delta,
637
+ ...wire.choiceFields,
638
+ finish_reason: finishReason
639
+ })
640
+
641
+ const reasoningEvents = () => {
642
+ for (const text of plan.reasoning) {
643
+ event([choice(reasoningDelta(plan.reasoningField, text, wire.reasoningDetails))])
644
+ }
645
+ }
646
+
647
+ const textEvents = () => {
648
+ for (const text of plan.text) {
649
+ event([choice({ content: text })])
650
+ }
651
+ }
652
+
653
+ event([choice(wire.openingDelta)])
654
+
655
+ if (plan.order === 'reasoning-first') {
656
+ reasoningEvents()
657
+ textEvents()
658
+ } else {
659
+ textEvents()
660
+ reasoningEvents()
661
+ }
662
+
663
+ plan.toolCalls.forEach((call, index) => {
664
+ event([
665
+ choice({
666
+ tool_calls: [
667
+ { index, id: call.id, type: 'function', function: { name: call.name, arguments: '' } }
668
+ ]
669
+ })
670
+ ])
671
+
672
+ for (const fragment of call.fragments) {
673
+ event([choice({ tool_calls: [{ index, function: { arguments: fragment } }] })])
674
+ }
675
+ })
676
+
677
+ const finish = wire.finishFields(identity)
678
+ const usageOnFinish = wire.streamUsage === 'finish-event' && usage !== undefined
679
+
680
+ const finishFields: Record<string, Schema.Json> = {}
681
+
682
+ if (usageOnFinish) {
683
+ finishFields.usage = usage
684
+ }
685
+
686
+ event([choice({ ...finish.delta }, plan.finishReason)], finishFields, finish.chunk)
687
+
688
+ if (!usageOnFinish && usage !== undefined) {
689
+ event([], { usage })
690
+ }
691
+
692
+ events.push(sseEvent('[DONE]'))
693
+
694
+ return events
695
+ }
696
+
697
+ /** The non-streamed `chat.completion` JSON body. */
698
+ const completionBody = (
699
+ plan: CompletionPlan,
700
+ identity: ChatResponseIdentity,
701
+ usage: Schema.JsonObject | undefined
702
+ ): WireCompletion => {
703
+ const message: WireMessage = {
704
+ role: 'assistant',
705
+ content: plan.text.length > 0 ? plan.text.join('') : null
706
+ }
707
+
708
+ if (plan.reasoning.length > 0 && plan.reasoningField === 'reasoning') {
709
+ message.reasoning = plan.reasoning.join('')
710
+ } else if (plan.reasoning.length > 0) {
711
+ message.reasoning_content = plan.reasoning.join('')
712
+ }
713
+
714
+ if (plan.toolCalls.length > 0) {
715
+ message.tool_calls = plan.toolCalls.map(call => ({
716
+ id: call.id,
717
+ type: 'function',
718
+ function: { name: call.name, arguments: call.fragments.join('') }
719
+ }))
720
+ }
721
+
722
+ const body: WireCompletion = {
723
+ id: identity.id,
724
+ object: 'chat.completion',
725
+ created: syntheticCreated,
726
+ model: identity.model,
727
+ choices: [{ index: 0, message, finish_reason: plan.finishReason }]
728
+ }
729
+
730
+ if (usage !== undefined) {
731
+ body.usage = usage
732
+ }
733
+
734
+ return body
735
+ }
736
+
737
+ type MutableLedgerEntry = KernelLedgerEntry<ChatFaultKind> & {
738
+ body?: Schema.Json
739
+ model?: string
740
+ stream?: boolean
741
+ maxCompletionTokens?: number
742
+ reasoningEffort?: string
743
+ thinking?: Schema.Json
744
+ toolNames: ReadonlyArray<string>
745
+ scripted?: 'completion' | 'error'
746
+ }
747
+
748
+ /**
749
+ * Create a Chat Completions emulator from its config. Each call has
750
+ * independent ledger, fault, and script state.
751
+ *
752
+ * Precedence per chat request: authentication and JSON validation, then the
753
+ * first matching fault if it is a `status` fault, then the next scripted
754
+ * turn, then model validation and defaults; a first matching chunk fault then
755
+ * shapes the body. Only the first matching fault (in insertion order)
756
+ * applies. Credential headers are never recorded, and the bearer value is
757
+ * never checked or stored.
758
+ */
759
+ export const makeChatCompletionsEmulator = <Turn extends ChatScriptedReasoningTurn>(
760
+ config: ChatCompletionsEmulatorConfig<Turn>
761
+ ): ChatCompletionsEmulator<Turn> => {
762
+ const knownModels = [...config.knownModels]
763
+ const reasoningModels = config.reasoningModels === undefined ? [] : [...config.reasoningModels]
764
+ const wire = resolveWireProfile(config.wire)
765
+
766
+ const unknownModelResponse = (model: string | undefined): Response =>
767
+ 'body' in config.unknownModel
768
+ ? jsonResponse(config.unknownModel.status, config.unknownModel.body(model))
769
+ : errorResponse(config.unknownModel.status, config.unknownModel.error)
770
+
771
+ const errorResponse = (status: number, error: ChatWireError): Response =>
772
+ jsonResponse(status, config.errorEnvelope(error))
773
+
774
+ const defaultFaultBody = (status: number): Schema.Json =>
775
+ status === 429
776
+ ? config.errorEnvelope({
777
+ message: 'Synthetic rate limit: too many requests.',
778
+ type: 'rate_limit_exceeded',
779
+ code: 'rate_limit_exceeded'
780
+ })
781
+ : config.errorEnvelope({
782
+ message: `Synthetic upstream error (${status}).`,
783
+ type: 'api_error',
784
+ code: 'upstream_error'
785
+ })
786
+
787
+ const invalidRequest = (message: string, code: string): Response =>
788
+ errorResponse(400, { message, type: 'invalid_request_error', code })
789
+
790
+ const kernel = makeEmulatorKernel({
791
+ routes: config.routes,
792
+ faultSchema: ChatFault,
793
+ turnSchema: config.turnSchema,
794
+ newEntry: (base): MutableLedgerEntry => ({ ...base, toolNames: [] }),
795
+ snapshotEntry: (entry): ChatLedgerEntry => ({ ...entry, toolNames: [...entry.toolNames] }),
796
+ unknownRoute: () =>
797
+ errorResponse(404, {
798
+ message: 'Synthetic: no emulated route.',
799
+ type: 'not_found_error',
800
+ code: 'not_found'
801
+ }),
802
+ stateFields: () =>
803
+ config.reasoningModels === undefined ? { knownModels } : { knownModels, reasoningModels },
804
+ inputInvalid: config.inputInvalid
805
+ })
806
+
807
+ const authorized = (request: Request): boolean =>
808
+ bearerPattern.test(request.headers.get('authorization') ?? '')
809
+
810
+ const chatCompletion = async (
811
+ request: Request,
812
+ entry: MutableLedgerEntry,
813
+ path: string
814
+ ): Promise<Response> => {
815
+ if (!authorized(request)) {
816
+ entry.status = 401
817
+
818
+ return errorResponse(401, config.auth.unauthorized)
819
+ }
820
+
821
+ const text = await readText(request)
822
+ const json = text === undefined ? undefined : parseJson(text)
823
+
824
+ if (text === undefined || json === undefined) {
825
+ entry.status = 400
826
+
827
+ return invalidRequest('Synthetic: the request body is not valid JSON.', 'invalid_json')
828
+ }
829
+
830
+ entry.body = json
831
+
832
+ const decoded = decodeChatRequest(json)
833
+
834
+ if (Result.isFailure(decoded)) {
835
+ entry.status = 400
836
+
837
+ return invalidRequest(
838
+ 'Synthetic: the request body is not a chat completion request.',
839
+ 'invalid_request'
840
+ )
841
+ }
842
+
843
+ const chat = decoded.success
844
+ const seq = entry.seq
845
+
846
+ if (chat.model !== undefined) entry.model = chat.model
847
+
848
+ entry.stream = chat.stream === true
849
+
850
+ const tokenLimit = jsonField(json, config.completionTokenField)
851
+
852
+ if (Predicate.isNumber(tokenLimit)) entry.maxCompletionTokens = tokenLimit
853
+
854
+ entry.toolNames = (chat.tools ?? []).map(tool => tool.function.name)
855
+
856
+ if (chat.reasoning_effort !== undefined && chat.reasoning_effort !== null) {
857
+ entry.reasoningEffort = chat.reasoning_effort
858
+ }
859
+
860
+ if (chat.thinking !== undefined) entry.thinking = chat.thinking
861
+
862
+ const fault = kernel.takeFault(path, chat.model)
863
+
864
+ if (fault !== undefined && fault.fault.kind === 'status') {
865
+ return kernel.respondWithStatusFault(entry, fault, fault.fault, defaultFaultBody)
866
+ }
867
+
868
+ // A scripted turn is used up when its request arrives. Header and status validation at
869
+ // `script.enqueue` time means its response can always be built.
870
+ const turn = kernel.nextTurn()
871
+
872
+ if (turn !== undefined && 'error' in turn) {
873
+ entry.scripted = 'error'
874
+
875
+ return kernel.respondWithScriptedError(entry, turn.error, fault)
876
+ }
877
+
878
+ if (turn === undefined && (chat.model === undefined || !knownModels.includes(chat.model))) {
879
+ entry.status = config.unknownModel.status
880
+
881
+ return unknownModelResponse(chat.model)
882
+ }
883
+
884
+ if (turn !== undefined) entry.scripted = 'completion'
885
+
886
+ const plan =
887
+ turn === undefined
888
+ ? defaultPlan(chat, seq, reasoningModels, config.defaultText, wire.reasoningField)
889
+ : scriptedPlan(turn, seq, wire.reasoningField)
890
+
891
+ const identity: ChatResponseIdentity = {
892
+ id: `${config.responseIdPrefix}-${seq}`,
893
+ model: chat.model ?? 'unknown'
894
+ }
895
+
896
+ const counts = usageCounts(plan, text)
897
+ const usage = counts === undefined ? undefined : wire.usage(counts)
898
+
899
+ if (chat.stream === true) {
900
+ const streamUsage = chat.stream_options?.include_usage === true ? usage : undefined
901
+
902
+ // Chunk faults count these network chunks (several events each when packed).
903
+ return kernel.respondWithBody(
904
+ entry,
905
+ 200,
906
+ { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
907
+ packEvents(streamEvents(plan, identity, streamUsage, wire), wire.eventsPerChunk),
908
+ fault
909
+ )
910
+ }
911
+
912
+ return kernel.respondWithBody(
913
+ entry,
914
+ 200,
915
+ { 'content-type': 'application/json' },
916
+ [JSON.stringify(completionBody(plan, identity, usage))],
917
+ fault
918
+ )
919
+ }
920
+
921
+ // Every manifest route maps to its own handler; construction throws `EmulatorRouteUnmapped`
922
+ // when a manifest route has no handler (or a handler has no manifest route).
923
+ return kernel.serve(new Map([[emulatorRouteKey('POST', config.path), chatCompletion]]))
924
+ }