discoclaw 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.
Files changed (393) hide show
  1. package/.context/README.md +42 -0
  2. package/.context/architecture.md +58 -0
  3. package/.context/bot-setup.md +24 -0
  4. package/.context/dev.md +230 -0
  5. package/.context/discord.md +144 -0
  6. package/.context/memory.md +257 -0
  7. package/.context/ops.md +59 -0
  8. package/.context/pa-safety.md +47 -0
  9. package/.context/pa.md +118 -0
  10. package/.context/project.md +43 -0
  11. package/.context/runtime.md +253 -0
  12. package/.context/tasks.md +71 -0
  13. package/.context/tools.md +75 -0
  14. package/.env.example +88 -0
  15. package/.env.example.full +378 -0
  16. package/LICENSE +21 -0
  17. package/README.md +220 -0
  18. package/dist/beads/auto-tag.js +2 -0
  19. package/dist/beads/auto-tag.test.js +62 -0
  20. package/dist/beads/bd-cli.js +9 -0
  21. package/dist/beads/bd-cli.test.js +495 -0
  22. package/dist/beads/bead-hooks-cli.js +149 -0
  23. package/dist/beads/bead-sync-cli.js +5 -0
  24. package/dist/beads/bead-sync-cli.test.js +72 -0
  25. package/dist/beads/bead-sync-coordinator.js +4 -0
  26. package/dist/beads/bead-sync-coordinator.test.js +239 -0
  27. package/dist/beads/bead-sync-watcher.js +2 -0
  28. package/dist/beads/bead-sync-watcher.test.js +96 -0
  29. package/dist/beads/bead-sync.js +7 -0
  30. package/dist/beads/bead-sync.test.js +876 -0
  31. package/dist/beads/bead-thread-cache.js +8 -0
  32. package/dist/beads/bead-thread-cache.test.js +91 -0
  33. package/dist/beads/discord-sync.js +18 -0
  34. package/dist/beads/discord-sync.test.js +782 -0
  35. package/dist/beads/find-bead-by-thread.test.js +36 -0
  36. package/dist/beads/forum-guard.js +2 -0
  37. package/dist/beads/forum-guard.test.js +204 -0
  38. package/dist/beads/initialize.js +3 -0
  39. package/dist/beads/initialize.test.js +304 -0
  40. package/dist/beads/types.js +10 -0
  41. package/dist/cli/daemon-installer.js +225 -0
  42. package/dist/cli/daemon-installer.test.js +289 -0
  43. package/dist/cli/index.js +42 -0
  44. package/dist/cli/init-wizard.js +374 -0
  45. package/dist/cli/init-wizard.test.js +191 -0
  46. package/dist/config.js +385 -0
  47. package/dist/config.test.js +589 -0
  48. package/dist/cron/auto-tag.js +100 -0
  49. package/dist/cron/auto-tag.test.js +91 -0
  50. package/dist/cron/cadence.js +74 -0
  51. package/dist/cron/cadence.test.js +53 -0
  52. package/dist/cron/cron-sync-coordinator.js +66 -0
  53. package/dist/cron/cron-sync-coordinator.test.js +118 -0
  54. package/dist/cron/cron-sync.js +165 -0
  55. package/dist/cron/cron-sync.test.js +228 -0
  56. package/dist/cron/cron-tag-map-watcher.js +128 -0
  57. package/dist/cron/cron-tag-map-watcher.test.js +155 -0
  58. package/dist/cron/default-timezone.js +23 -0
  59. package/dist/cron/default-timezone.test.js +30 -0
  60. package/dist/cron/discord-sync.js +205 -0
  61. package/dist/cron/discord-sync.test.js +353 -0
  62. package/dist/cron/executor.js +303 -0
  63. package/dist/cron/executor.test.js +614 -0
  64. package/dist/cron/forum-sync.js +347 -0
  65. package/dist/cron/forum-sync.test.js +539 -0
  66. package/dist/cron/job-lock.js +164 -0
  67. package/dist/cron/job-lock.test.js +178 -0
  68. package/dist/cron/parser.js +68 -0
  69. package/dist/cron/parser.test.js +115 -0
  70. package/dist/cron/run-control.js +24 -0
  71. package/dist/cron/run-control.test.js +27 -0
  72. package/dist/cron/run-stats.js +265 -0
  73. package/dist/cron/run-stats.test.js +160 -0
  74. package/dist/cron/scheduler.js +97 -0
  75. package/dist/cron/scheduler.test.js +112 -0
  76. package/dist/cron/tag-map.js +47 -0
  77. package/dist/cron/tag-map.test.js +64 -0
  78. package/dist/cron/types.js +1 -0
  79. package/dist/discoclaw-plan-format.test.js +137 -0
  80. package/dist/discoclaw-recipe-format.test.js +137 -0
  81. package/dist/discord/abort-registry.js +70 -0
  82. package/dist/discord/action-categories.js +36 -0
  83. package/dist/discord/action-types.js +1 -0
  84. package/dist/discord/action-utils.js +58 -0
  85. package/dist/discord/action-utils.test.js +58 -0
  86. package/dist/discord/actions-beads.js +1 -0
  87. package/dist/discord/actions-beads.test.js +372 -0
  88. package/dist/discord/actions-bot-profile.js +107 -0
  89. package/dist/discord/actions-bot-profile.test.js +138 -0
  90. package/dist/discord/actions-channels.js +427 -0
  91. package/dist/discord/actions-channels.test.js +697 -0
  92. package/dist/discord/actions-config.js +173 -0
  93. package/dist/discord/actions-config.test.js +322 -0
  94. package/dist/discord/actions-crons.js +586 -0
  95. package/dist/discord/actions-crons.test.js +499 -0
  96. package/dist/discord/actions-defer.js +60 -0
  97. package/dist/discord/actions-defer.test.js +134 -0
  98. package/dist/discord/actions-forge.js +134 -0
  99. package/dist/discord/actions-forge.test.js +206 -0
  100. package/dist/discord/actions-guild.js +301 -0
  101. package/dist/discord/actions-guild.test.js +386 -0
  102. package/dist/discord/actions-memory.js +106 -0
  103. package/dist/discord/actions-memory.test.js +248 -0
  104. package/dist/discord/actions-messaging.js +401 -0
  105. package/dist/discord/actions-messaging.test.js +738 -0
  106. package/dist/discord/actions-moderation.js +65 -0
  107. package/dist/discord/actions-moderation.test.js +88 -0
  108. package/dist/discord/actions-plan.js +445 -0
  109. package/dist/discord/actions-plan.test.js +610 -0
  110. package/dist/discord/actions-poll.js +38 -0
  111. package/dist/discord/actions-poll.test.js +93 -0
  112. package/dist/discord/actions-tasks.js +3 -0
  113. package/dist/discord/actions-tasks.test.js +418 -0
  114. package/dist/discord/actions.js +600 -0
  115. package/dist/discord/actions.test.js +522 -0
  116. package/dist/discord/allowed-mentions.js +3 -0
  117. package/dist/discord/allowed-mentions.test.js +17 -0
  118. package/dist/discord/allowlist.js +29 -0
  119. package/dist/discord/allowlist.test.js +24 -0
  120. package/dist/discord/audit-handler.js +191 -0
  121. package/dist/discord/audit-handler.test.js +361 -0
  122. package/dist/discord/bot.js +141 -0
  123. package/dist/discord/channel-context.js +181 -0
  124. package/dist/discord/defer-scheduler.js +45 -0
  125. package/dist/discord/destructive-confirmation.js +128 -0
  126. package/dist/discord/destructive-confirmation.test.js +49 -0
  127. package/dist/discord/discord-plan-auto-implement.test.js +18 -0
  128. package/dist/discord/durable-memory.js +145 -0
  129. package/dist/discord/durable-memory.test.js +281 -0
  130. package/dist/discord/durable-write-queue.js +4 -0
  131. package/dist/discord/file-download.js +308 -0
  132. package/dist/discord/file-download.test.js +303 -0
  133. package/dist/discord/forge-audit-verdict.js +140 -0
  134. package/dist/discord/forge-auto-implement.js +80 -0
  135. package/dist/discord/forge-auto-implement.test.js +110 -0
  136. package/dist/discord/forge-commands.js +698 -0
  137. package/dist/discord/forge-commands.test.js +1606 -0
  138. package/dist/discord/forge-plan-registry.js +68 -0
  139. package/dist/discord/forge-plan-registry.test.js +127 -0
  140. package/dist/discord/forum-count-sync.js +130 -0
  141. package/dist/discord/forum-count-sync.test.js +200 -0
  142. package/dist/discord/health-command.js +98 -0
  143. package/dist/discord/health-command.test.js +195 -0
  144. package/dist/discord/help-command.js +22 -0
  145. package/dist/discord/help-command.test.js +49 -0
  146. package/dist/discord/image-download.js +201 -0
  147. package/dist/discord/image-download.test.js +499 -0
  148. package/dist/discord/inflight-replies.js +228 -0
  149. package/dist/discord/inflight-replies.test.js +295 -0
  150. package/dist/discord/json-extract.js +110 -0
  151. package/dist/discord/keyed-queue.js +22 -0
  152. package/dist/discord/memory-commands.js +85 -0
  153. package/dist/discord/memory-commands.test.js +159 -0
  154. package/dist/discord/memory-timing.integration.test.js +159 -0
  155. package/dist/discord/message-coordinator.js +2347 -0
  156. package/dist/discord/message-coordinator.onboarding.test.js +183 -0
  157. package/dist/discord/message-coordinator.plan-run.test.js +264 -0
  158. package/dist/discord/message-history.js +53 -0
  159. package/dist/discord/message-history.test.js +95 -0
  160. package/dist/discord/models-command.js +59 -0
  161. package/dist/discord/models-command.test.js +150 -0
  162. package/dist/discord/nickname.test.js +76 -0
  163. package/dist/discord/onboarding-completion.js +55 -0
  164. package/dist/discord/onboarding-completion.test.js +176 -0
  165. package/dist/discord/output-common.js +178 -0
  166. package/dist/discord/output-common.test.js +198 -0
  167. package/dist/discord/output-utils.js +156 -0
  168. package/dist/discord/parse-identity-name.test.js +129 -0
  169. package/dist/discord/plan-commands.js +612 -0
  170. package/dist/discord/plan-commands.test.js +1622 -0
  171. package/dist/discord/plan-manager.js +1491 -0
  172. package/dist/discord/plan-manager.test.js +2380 -0
  173. package/dist/discord/plan-parser.js +110 -0
  174. package/dist/discord/plan-parser.test.js +63 -0
  175. package/dist/discord/plan-run-phase-start.js +20 -0
  176. package/dist/discord/plan-run-phase-start.test.js +29 -0
  177. package/dist/discord/platform-message.js +45 -0
  178. package/dist/discord/platform-message.test.js +110 -0
  179. package/dist/discord/prompt-common.js +240 -0
  180. package/dist/discord/prompt-common.test.js +423 -0
  181. package/dist/discord/reaction-handler.js +691 -0
  182. package/dist/discord/reaction-handler.test.js +1574 -0
  183. package/dist/discord/reaction-prompts.js +118 -0
  184. package/dist/discord/reaction-prompts.test.js +253 -0
  185. package/dist/discord/reply-reference.js +66 -0
  186. package/dist/discord/reply-reference.test.js +125 -0
  187. package/dist/discord/restart-command.js +143 -0
  188. package/dist/discord/restart-command.test.js +196 -0
  189. package/dist/discord/runtime-utils.js +43 -0
  190. package/dist/discord/runtime-utils.test.js +112 -0
  191. package/dist/discord/session-key.js +7 -0
  192. package/dist/discord/session-key.test.js +13 -0
  193. package/dist/discord/shortterm-memory.js +166 -0
  194. package/dist/discord/shortterm-memory.test.js +345 -0
  195. package/dist/discord/shutdown-context.js +122 -0
  196. package/dist/discord/shutdown-context.test.js +279 -0
  197. package/dist/discord/startup-profile.test.js +214 -0
  198. package/dist/discord/status-channel.js +190 -0
  199. package/dist/discord/status-channel.test.js +282 -0
  200. package/dist/discord/status-command.js +206 -0
  201. package/dist/discord/status-command.test.js +341 -0
  202. package/dist/discord/streaming-progress.js +107 -0
  203. package/dist/discord/streaming-progress.test.js +93 -0
  204. package/dist/discord/summarizer.js +89 -0
  205. package/dist/discord/summarizer.test.js +245 -0
  206. package/dist/discord/system-bootstrap.js +396 -0
  207. package/dist/discord/system-bootstrap.test.js +724 -0
  208. package/dist/discord/thread-context.js +169 -0
  209. package/dist/discord/thread-context.test.js +386 -0
  210. package/dist/discord/tool-aware-queue.js +116 -0
  211. package/dist/discord/tool-aware-queue.test.js +180 -0
  212. package/dist/discord/update-command.js +127 -0
  213. package/dist/discord/update-command.test.js +275 -0
  214. package/dist/discord/user-errors.js +40 -0
  215. package/dist/discord/user-errors.test.js +31 -0
  216. package/dist/discord/user-turn-to-durable.js +111 -0
  217. package/dist/discord/user-turn-to-durable.test.js +273 -0
  218. package/dist/discord-followup.test.js +677 -0
  219. package/dist/discord.channel-context.test.js +95 -0
  220. package/dist/discord.fail-closed.test.js +199 -0
  221. package/dist/discord.health-command.integration.test.js +140 -0
  222. package/dist/discord.js +190 -0
  223. package/dist/discord.prompt-context.test.js +1431 -0
  224. package/dist/discord.render.test.js +621 -0
  225. package/dist/discord.status-wiring.test.js +187 -0
  226. package/dist/engine/claudeCli.js +137 -0
  227. package/dist/engine/types.js +1 -0
  228. package/dist/group-queue.js +25 -0
  229. package/dist/health/credential-check.js +175 -0
  230. package/dist/health/credential-check.test.js +401 -0
  231. package/dist/health/startup-healing.js +139 -0
  232. package/dist/health/startup-healing.test.js +298 -0
  233. package/dist/identity.js +36 -0
  234. package/dist/index.js +1378 -0
  235. package/dist/logging/logger-like.js +1 -0
  236. package/dist/observability/memory-sampler.js +51 -0
  237. package/dist/observability/memory-sampler.test.js +93 -0
  238. package/dist/observability/metrics.js +88 -0
  239. package/dist/observability/metrics.test.js +42 -0
  240. package/dist/onboarding/onboarding-flow.js +246 -0
  241. package/dist/onboarding/onboarding-flow.test.js +238 -0
  242. package/dist/onboarding/onboarding-writer.js +102 -0
  243. package/dist/onboarding/onboarding-writer.test.js +143 -0
  244. package/dist/pidlock.js +187 -0
  245. package/dist/pidlock.test.js +128 -0
  246. package/dist/pipeline/engine.js +206 -0
  247. package/dist/pipeline/engine.test.js +771 -0
  248. package/dist/root-policy.js +21 -0
  249. package/dist/root-policy.test.js +55 -0
  250. package/dist/runtime/claude-code-cli.js +35 -0
  251. package/dist/runtime/claude-code-cli.test.js +1199 -0
  252. package/dist/runtime/cli-adapter.js +584 -0
  253. package/dist/runtime/cli-output-parsers.js +108 -0
  254. package/dist/runtime/cli-shared.js +96 -0
  255. package/dist/runtime/cli-shared.test.js +104 -0
  256. package/dist/runtime/cli-strategy.js +6 -0
  257. package/dist/runtime/codex-cli.js +16 -0
  258. package/dist/runtime/codex-cli.test.js +862 -0
  259. package/dist/runtime/concurrency-limit.js +80 -0
  260. package/dist/runtime/concurrency-limit.test.js +137 -0
  261. package/dist/runtime/gemini-cli.js +16 -0
  262. package/dist/runtime/gemini-cli.test.js +413 -0
  263. package/dist/runtime/long-running-process.js +415 -0
  264. package/dist/runtime/long-running-process.test.js +318 -0
  265. package/dist/runtime/model-smoke-helpers.js +160 -0
  266. package/dist/runtime/model-smoke.test.js +194 -0
  267. package/dist/runtime/model-tiers.js +33 -0
  268. package/dist/runtime/model-tiers.test.js +65 -0
  269. package/dist/runtime/openai-auth.js +151 -0
  270. package/dist/runtime/openai-auth.test.js +361 -0
  271. package/dist/runtime/openai-compat.js +178 -0
  272. package/dist/runtime/openai-compat.test.js +449 -0
  273. package/dist/runtime/process-pool.js +93 -0
  274. package/dist/runtime/process-pool.test.js +148 -0
  275. package/dist/runtime/registry.js +15 -0
  276. package/dist/runtime/registry.test.js +47 -0
  277. package/dist/runtime/session-scanner.js +186 -0
  278. package/dist/runtime/session-scanner.test.js +257 -0
  279. package/dist/runtime/strategies/claude-strategy.js +193 -0
  280. package/dist/runtime/strategies/codex-strategy.js +161 -0
  281. package/dist/runtime/strategies/gemini-strategy.js +64 -0
  282. package/dist/runtime/strategies/template-strategy.js +85 -0
  283. package/dist/runtime/tool-capabilities.js +27 -0
  284. package/dist/runtime/tool-capabilities.test.js +24 -0
  285. package/dist/runtime/tool-labels.js +48 -0
  286. package/dist/runtime/types.js +2 -0
  287. package/dist/sessionManager.js +47 -0
  288. package/dist/sessions.js +18 -0
  289. package/dist/tasks/architecture-contract.js +33 -0
  290. package/dist/tasks/architecture-contract.test.js +90 -0
  291. package/dist/tasks/auto-tag.js +50 -0
  292. package/dist/tasks/auto-tag.test.js +64 -0
  293. package/dist/tasks/bd-cli.js +164 -0
  294. package/dist/tasks/bd-cli.test.js +359 -0
  295. package/dist/tasks/bead-sync.js +1 -0
  296. package/dist/tasks/context-summary.js +27 -0
  297. package/dist/tasks/discord-sync.js +3 -0
  298. package/dist/tasks/discord-sync.test.js +685 -0
  299. package/dist/tasks/discord-types.js +4 -0
  300. package/dist/tasks/find-task-by-thread.test.js +36 -0
  301. package/dist/tasks/forum-guard.js +81 -0
  302. package/dist/tasks/forum-guard.test.js +192 -0
  303. package/dist/tasks/initialize.js +77 -0
  304. package/dist/tasks/initialize.test.js +263 -0
  305. package/dist/tasks/logger-types.js +1 -0
  306. package/dist/tasks/metrics-types.js +3 -0
  307. package/dist/tasks/migrate.js +33 -0
  308. package/dist/tasks/migrate.test.js +156 -0
  309. package/dist/tasks/path-defaults.js +67 -0
  310. package/dist/tasks/path-defaults.test.js +73 -0
  311. package/dist/tasks/runtime-types.js +1 -0
  312. package/dist/tasks/service.js +33 -0
  313. package/dist/tasks/service.test.js +51 -0
  314. package/dist/tasks/store.js +238 -0
  315. package/dist/tasks/store.test.js +417 -0
  316. package/dist/tasks/sync-context.js +1 -0
  317. package/dist/tasks/sync-contract.js +24 -0
  318. package/dist/tasks/sync-contract.test.js +25 -0
  319. package/dist/tasks/sync-coordinator-metrics.js +41 -0
  320. package/dist/tasks/sync-coordinator-retries.js +71 -0
  321. package/dist/tasks/sync-coordinator.js +96 -0
  322. package/dist/tasks/sync-coordinator.test.js +501 -0
  323. package/dist/tasks/sync-types.js +1 -0
  324. package/dist/tasks/sync-watcher.js +27 -0
  325. package/dist/tasks/sync-watcher.test.js +92 -0
  326. package/dist/tasks/tag-map.js +36 -0
  327. package/dist/tasks/tag-map.test.js +54 -0
  328. package/dist/tasks/task-action-contract.js +16 -0
  329. package/dist/tasks/task-action-contract.test.js +16 -0
  330. package/dist/tasks/task-action-executor.js +18 -0
  331. package/dist/tasks/task-action-executor.test.js +420 -0
  332. package/dist/tasks/task-action-mutation-helpers.js +17 -0
  333. package/dist/tasks/task-action-mutations.js +151 -0
  334. package/dist/tasks/task-action-prompt.js +62 -0
  335. package/dist/tasks/task-action-read-ops.js +73 -0
  336. package/dist/tasks/task-action-runner-types.js +1 -0
  337. package/dist/tasks/task-action-thread-sync.js +82 -0
  338. package/dist/tasks/task-actions.js +3 -0
  339. package/dist/tasks/task-cli.js +227 -0
  340. package/dist/tasks/task-context.js +1 -0
  341. package/dist/tasks/task-lifecycle.js +46 -0
  342. package/dist/tasks/task-lifecycle.test.js +35 -0
  343. package/dist/tasks/task-sync-apply-plan.js +95 -0
  344. package/dist/tasks/task-sync-apply-types.js +12 -0
  345. package/dist/tasks/task-sync-apply.js +319 -0
  346. package/dist/tasks/task-sync-cli.js +89 -0
  347. package/dist/tasks/task-sync-cli.test.js +70 -0
  348. package/dist/tasks/task-sync-engine.js +88 -0
  349. package/dist/tasks/task-sync-engine.test.js +934 -0
  350. package/dist/tasks/task-sync-phase-apply.js +171 -0
  351. package/dist/tasks/task-sync-pipeline.js +2 -0
  352. package/dist/tasks/task-sync-pipeline.test.js +265 -0
  353. package/dist/tasks/task-sync-reconcile-plan.js +182 -0
  354. package/dist/tasks/task-sync-reconcile.js +144 -0
  355. package/dist/tasks/task-sync.js +56 -0
  356. package/dist/tasks/task-sync.test.js +86 -0
  357. package/dist/tasks/thread-cache.js +42 -0
  358. package/dist/tasks/thread-cache.test.js +89 -0
  359. package/dist/tasks/thread-contracts.test.js +711 -0
  360. package/dist/tasks/thread-forum-ops.js +68 -0
  361. package/dist/tasks/thread-helpers.js +86 -0
  362. package/dist/tasks/thread-helpers.test.js +33 -0
  363. package/dist/tasks/thread-lifecycle-ops.js +144 -0
  364. package/dist/tasks/thread-ops-shared.js +21 -0
  365. package/dist/tasks/thread-ops.js +2 -0
  366. package/dist/tasks/types.js +20 -0
  367. package/dist/tasks/types.test.js +60 -0
  368. package/dist/test-setup.js +11 -0
  369. package/dist/test-setup.test.js +42 -0
  370. package/dist/transport/types.js +1 -0
  371. package/dist/validate.js +41 -0
  372. package/dist/validate.test.js +94 -0
  373. package/dist/version.js +15 -0
  374. package/dist/version.test.js +31 -0
  375. package/dist/webhook/server.js +199 -0
  376. package/dist/webhook/server.test.js +460 -0
  377. package/dist/workspace-bootstrap.js +135 -0
  378. package/dist/workspace-bootstrap.test.js +514 -0
  379. package/dist/workspace-permissions.js +134 -0
  380. package/dist/workspace-permissions.test.js +181 -0
  381. package/package.json +74 -0
  382. package/scripts/cron/cron-tag-map.json +9 -0
  383. package/scripts/tasks/tag-map.json +10 -0
  384. package/systemd/discoclaw.service +19 -0
  385. package/templates/recipes/integration.discoclaw-recipe.md +171 -0
  386. package/templates/workspace/AGENTS.md +217 -0
  387. package/templates/workspace/BOOTSTRAP.md +1 -0
  388. package/templates/workspace/HEARTBEAT.md +10 -0
  389. package/templates/workspace/IDENTITY.md +16 -0
  390. package/templates/workspace/MEMORY.md +24 -0
  391. package/templates/workspace/SOUL.md +52 -0
  392. package/templates/workspace/TOOLS.md +304 -0
  393. package/templates/workspace/USER.md +37 -0
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Returns true for models that require `max_completion_tokens` instead of `max_tokens`.
3
+ * Strips any provider namespace (e.g. "openai/") before matching.
4
+ */
5
+ export function useMaxCompletionTokens(model) {
6
+ const name = model.includes('/') ? model.slice(model.lastIndexOf('/') + 1) : model;
7
+ const lower = name.toLowerCase();
8
+ return lower.startsWith('o1') || lower.startsWith('o3') || lower.startsWith('gpt-5');
9
+ }
10
+ /** Extract the data payload from an SSE line, or undefined if not a data line. */
11
+ function parseSSEData(line) {
12
+ const trimmed = line.trim();
13
+ if (!trimmed || trimmed.startsWith(':'))
14
+ return undefined;
15
+ // SSE spec: space after colon is optional (data:payload and data: payload are both valid)
16
+ if (trimmed.startsWith('data: '))
17
+ return trimmed.slice('data: '.length);
18
+ if (trimmed.startsWith('data:'))
19
+ return trimmed.slice('data:'.length);
20
+ return undefined;
21
+ }
22
+ export function createOpenAICompatRuntime(opts) {
23
+ const capabilities = new Set(['streaming_text']);
24
+ return {
25
+ id: opts.id ?? 'openai',
26
+ capabilities,
27
+ defaultModel: opts.defaultModel,
28
+ invoke(params) {
29
+ return (async function* () {
30
+ const model = params.model || opts.defaultModel;
31
+ const url = `${opts.baseUrl}/chat/completions`;
32
+ const tokenField = params.maxTokens !== undefined
33
+ ? (useMaxCompletionTokens(model)
34
+ ? { max_completion_tokens: params.maxTokens }
35
+ : { max_tokens: params.maxTokens })
36
+ : {};
37
+ const body = JSON.stringify({
38
+ model,
39
+ messages: [{ role: 'user', content: params.prompt }],
40
+ stream: true,
41
+ ...tokenField,
42
+ });
43
+ const controller = new AbortController();
44
+ let timer;
45
+ if (params.timeoutMs) {
46
+ timer = setTimeout(() => controller.abort(), params.timeoutMs);
47
+ }
48
+ // Forward caller's AbortSignal into the controller.
49
+ const onCallerAbort = () => controller.abort();
50
+ params.signal?.addEventListener('abort', onCallerAbort, { once: true });
51
+ if (params.signal?.aborted)
52
+ controller.abort();
53
+ let accumulated = '';
54
+ try {
55
+ opts.log?.debug({ url, model }, 'openai-compat: request');
56
+ // Resolve bearer token: static key or dynamic OAuth
57
+ let bearerToken = opts.auth === 'chatgpt_oauth'
58
+ ? await opts.tokenProvider.getAccessToken()
59
+ : opts.apiKey;
60
+ let response = await fetch(url, {
61
+ method: 'POST',
62
+ headers: {
63
+ 'Authorization': `Bearer ${bearerToken}`,
64
+ 'Content-Type': 'application/json',
65
+ },
66
+ body,
67
+ signal: controller.signal,
68
+ });
69
+ // On 401 with OAuth, force-refresh the token and retry once
70
+ if (!response.ok && response.status === 401 && opts.auth === 'chatgpt_oauth') {
71
+ opts.log?.debug('openai-compat: 401 received, force-refreshing OAuth token');
72
+ bearerToken = await opts.tokenProvider.getAccessToken(true);
73
+ response = await fetch(url, {
74
+ method: 'POST',
75
+ headers: {
76
+ 'Authorization': `Bearer ${bearerToken}`,
77
+ 'Content-Type': 'application/json',
78
+ },
79
+ body,
80
+ signal: controller.signal,
81
+ });
82
+ }
83
+ if (!response.ok) {
84
+ yield { type: 'error', message: `OpenAI API error: ${response.status} ${response.statusText}` };
85
+ yield { type: 'done' };
86
+ return;
87
+ }
88
+ if (!response.body) {
89
+ yield { type: 'error', message: 'OpenAI API returned no response body' };
90
+ yield { type: 'done' };
91
+ return;
92
+ }
93
+ const reader = response.body.getReader();
94
+ const decoder = new TextDecoder();
95
+ let buffer = '';
96
+ // Process a single SSE line, returning 'done' if [DONE] sentinel was hit
97
+ const processLine = function* (line) {
98
+ const data = parseSSEData(line);
99
+ if (data === undefined)
100
+ return false;
101
+ if (data === '[DONE]') {
102
+ yield { type: 'text_final', text: accumulated };
103
+ yield { type: 'done' };
104
+ return true;
105
+ }
106
+ try {
107
+ const parsed = JSON.parse(data);
108
+ const content = parsed?.choices?.[0]?.delta?.content;
109
+ if (content) {
110
+ accumulated += content;
111
+ yield { type: 'text_delta', text: content };
112
+ }
113
+ }
114
+ catch {
115
+ // Skip unparseable lines
116
+ }
117
+ return false;
118
+ };
119
+ while (true) {
120
+ const { done, value } = await reader.read();
121
+ if (done)
122
+ break;
123
+ buffer += decoder.decode(value, { stream: true });
124
+ // Process complete lines
125
+ const lines = buffer.split('\n');
126
+ // Keep the last (possibly incomplete) line in the buffer
127
+ buffer = lines.pop() ?? '';
128
+ for (const line of lines) {
129
+ const result = processLine(line);
130
+ let step = result.next();
131
+ while (!step.done) {
132
+ yield step.value;
133
+ step = result.next();
134
+ }
135
+ if (step.value)
136
+ return; // [DONE] hit
137
+ }
138
+ }
139
+ // Process any remaining buffered content (stream ended without trailing newline)
140
+ if (buffer.trim()) {
141
+ const result = processLine(buffer);
142
+ let step = result.next();
143
+ while (!step.done) {
144
+ yield step.value;
145
+ step = result.next();
146
+ }
147
+ if (step.value)
148
+ return; // [DONE] hit
149
+ }
150
+ // Stream ended without [DONE] — emit what we have
151
+ yield { type: 'text_final', text: accumulated };
152
+ yield { type: 'done' };
153
+ }
154
+ catch (err) {
155
+ if (timer)
156
+ clearTimeout(timer);
157
+ if (controller.signal.aborted) {
158
+ if (params.signal?.aborted) {
159
+ yield { type: 'error', message: 'aborted' };
160
+ }
161
+ else {
162
+ yield { type: 'error', message: `openai-compat timed out after ${params.timeoutMs}ms` };
163
+ }
164
+ yield { type: 'done' };
165
+ return;
166
+ }
167
+ yield { type: 'error', message: String(err) };
168
+ yield { type: 'done' };
169
+ }
170
+ finally {
171
+ if (timer)
172
+ clearTimeout(timer);
173
+ params.signal?.removeEventListener('abort', onCallerAbort);
174
+ }
175
+ })();
176
+ },
177
+ };
178
+ }
@@ -0,0 +1,449 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { createOpenAICompatRuntime } from './openai-compat.js';
3
+ async function collectEvents(iter) {
4
+ const events = [];
5
+ for await (const evt of iter) {
6
+ events.push(evt);
7
+ }
8
+ return events;
9
+ }
10
+ function makeSSEResponse(chunks, status = 200, statusText = 'OK') {
11
+ const text = chunks.join('\n') + '\n';
12
+ const encoder = new TextEncoder();
13
+ const stream = new ReadableStream({
14
+ start(controller) {
15
+ controller.enqueue(encoder.encode(text));
16
+ controller.close();
17
+ },
18
+ });
19
+ return new Response(stream, { status, statusText });
20
+ }
21
+ /** Like makeSSEResponse but does NOT append a trailing newline — simulates EOF without \n. */
22
+ function makeSSEResponseRaw(rawText, status = 200, statusText = 'OK') {
23
+ const encoder = new TextEncoder();
24
+ const stream = new ReadableStream({
25
+ start(controller) {
26
+ controller.enqueue(encoder.encode(rawText));
27
+ controller.close();
28
+ },
29
+ });
30
+ return new Response(stream, { status, statusText });
31
+ }
32
+ function makeSSEData(content) {
33
+ return `data: ${JSON.stringify({ choices: [{ delta: { content } }] })}`;
34
+ }
35
+ describe('OpenAI-compat runtime adapter', () => {
36
+ const originalFetch = globalThis.fetch;
37
+ afterEach(() => {
38
+ globalThis.fetch = originalFetch;
39
+ });
40
+ it('happy path: SSE stream with 3 chunks + [DONE]', async () => {
41
+ globalThis.fetch = vi.fn().mockResolvedValue(makeSSEResponse([
42
+ makeSSEData('Hello'),
43
+ makeSSEData(' world'),
44
+ makeSSEData('!'),
45
+ 'data: [DONE]',
46
+ ]));
47
+ const rt = createOpenAICompatRuntime({
48
+ baseUrl: 'https://api.example.com/v1',
49
+ apiKey: 'test-key',
50
+ defaultModel: 'gpt-4o',
51
+ });
52
+ const events = await collectEvents(rt.invoke({
53
+ prompt: 'Hi',
54
+ model: '',
55
+ cwd: '/tmp',
56
+ }));
57
+ const deltas = events.filter((e) => e.type === 'text_delta');
58
+ expect(deltas).toHaveLength(3);
59
+ expect(deltas.map((d) => d.text)).toEqual(['Hello', ' world', '!']);
60
+ const final = events.find((e) => e.type === 'text_final');
61
+ expect(final).toBeDefined();
62
+ expect(final.text).toBe('Hello world!');
63
+ const done = events.find((e) => e.type === 'done');
64
+ expect(done).toBeDefined();
65
+ });
66
+ it('HTTP error (401) yields error + done', async () => {
67
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' }));
68
+ const rt = createOpenAICompatRuntime({
69
+ baseUrl: 'https://api.example.com/v1',
70
+ apiKey: 'bad-key',
71
+ defaultModel: 'gpt-4o',
72
+ });
73
+ const events = await collectEvents(rt.invoke({
74
+ prompt: 'Hi',
75
+ model: '',
76
+ cwd: '/tmp',
77
+ }));
78
+ const errorEvt = events.find((e) => e.type === 'error');
79
+ expect(errorEvt).toBeDefined();
80
+ expect(errorEvt.message).toContain('401');
81
+ expect(events[events.length - 1].type).toBe('done');
82
+ });
83
+ it('HTTP error (500) yields error + done', async () => {
84
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response('Server Error', { status: 500, statusText: 'Internal Server Error' }));
85
+ const rt = createOpenAICompatRuntime({
86
+ baseUrl: 'https://api.example.com/v1',
87
+ apiKey: 'test-key',
88
+ defaultModel: 'gpt-4o',
89
+ });
90
+ const events = await collectEvents(rt.invoke({
91
+ prompt: 'Hi',
92
+ model: '',
93
+ cwd: '/tmp',
94
+ }));
95
+ const errorEvt = events.find((e) => e.type === 'error');
96
+ expect(errorEvt).toBeDefined();
97
+ expect(errorEvt.message).toContain('500');
98
+ expect(events[events.length - 1].type).toBe('done');
99
+ });
100
+ it('network error yields error + done', async () => {
101
+ globalThis.fetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
102
+ const rt = createOpenAICompatRuntime({
103
+ baseUrl: 'https://api.example.com/v1',
104
+ apiKey: 'test-key',
105
+ defaultModel: 'gpt-4o',
106
+ });
107
+ const events = await collectEvents(rt.invoke({
108
+ prompt: 'Hi',
109
+ model: '',
110
+ cwd: '/tmp',
111
+ }));
112
+ const errorEvt = events.find((e) => e.type === 'error');
113
+ expect(errorEvt).toBeDefined();
114
+ expect(errorEvt.message).toContain('ECONNREFUSED');
115
+ expect(events[events.length - 1].type).toBe('done');
116
+ });
117
+ it('timeout yields error + done', async () => {
118
+ // Mock fetch that delays longer than the timeout
119
+ globalThis.fetch = vi.fn().mockImplementation((_url, init) => {
120
+ return new Promise((_resolve, reject) => {
121
+ const signal = init?.signal;
122
+ if (signal) {
123
+ signal.addEventListener('abort', () => {
124
+ reject(new DOMException('The operation was aborted', 'AbortError'));
125
+ });
126
+ }
127
+ });
128
+ });
129
+ const rt = createOpenAICompatRuntime({
130
+ baseUrl: 'https://api.example.com/v1',
131
+ apiKey: 'test-key',
132
+ defaultModel: 'gpt-4o',
133
+ });
134
+ const events = await collectEvents(rt.invoke({
135
+ prompt: 'Hi',
136
+ model: '',
137
+ cwd: '/tmp',
138
+ timeoutMs: 50,
139
+ }));
140
+ const errorEvt = events.find((e) => e.type === 'error');
141
+ expect(errorEvt).toBeDefined();
142
+ expect(errorEvt.message).toContain('timed out');
143
+ expect(events[events.length - 1].type).toBe('done');
144
+ });
145
+ it('empty stream (immediate [DONE]) yields empty text_final + done', async () => {
146
+ globalThis.fetch = vi.fn().mockResolvedValue(makeSSEResponse(['data: [DONE]']));
147
+ const rt = createOpenAICompatRuntime({
148
+ baseUrl: 'https://api.example.com/v1',
149
+ apiKey: 'test-key',
150
+ defaultModel: 'gpt-4o',
151
+ });
152
+ const events = await collectEvents(rt.invoke({
153
+ prompt: 'Hi',
154
+ model: '',
155
+ cwd: '/tmp',
156
+ }));
157
+ const final = events.find((e) => e.type === 'text_final');
158
+ expect(final).toBeDefined();
159
+ expect(final.text).toBe('');
160
+ expect(events[events.length - 1].type).toBe('done');
161
+ });
162
+ it('stream without [DONE] still emits text_final + done', async () => {
163
+ globalThis.fetch = vi.fn().mockResolvedValue(makeSSEResponse([
164
+ makeSSEData('Hello'),
165
+ makeSSEData(' there'),
166
+ // No [DONE] — stream just ends
167
+ ]));
168
+ const rt = createOpenAICompatRuntime({
169
+ baseUrl: 'https://api.example.com/v1',
170
+ apiKey: 'test-key',
171
+ defaultModel: 'gpt-4o',
172
+ });
173
+ const events = await collectEvents(rt.invoke({
174
+ prompt: 'Hi',
175
+ model: '',
176
+ cwd: '/tmp',
177
+ }));
178
+ const final = events.find((e) => e.type === 'text_final');
179
+ expect(final).toBeDefined();
180
+ expect(final.text).toBe('Hello there');
181
+ expect(events[events.length - 1].type).toBe('done');
182
+ });
183
+ it('model override: params.model takes precedence over defaultModel', async () => {
184
+ let capturedBody;
185
+ globalThis.fetch = vi.fn().mockImplementation((_url, init) => {
186
+ capturedBody = init?.body;
187
+ return Promise.resolve(makeSSEResponse(['data: [DONE]']));
188
+ });
189
+ const rt = createOpenAICompatRuntime({
190
+ baseUrl: 'https://api.example.com/v1',
191
+ apiKey: 'test-key',
192
+ defaultModel: 'gpt-4o',
193
+ });
194
+ await collectEvents(rt.invoke({
195
+ prompt: 'Hi',
196
+ model: 'gpt-4o-mini',
197
+ cwd: '/tmp',
198
+ }));
199
+ const parsed = JSON.parse(capturedBody);
200
+ expect(parsed.model).toBe('gpt-4o-mini');
201
+ });
202
+ it('ignores tools/sessions — no error, tools not in request body', async () => {
203
+ let capturedBody;
204
+ globalThis.fetch = vi.fn().mockImplementation((_url, init) => {
205
+ capturedBody = init?.body;
206
+ return Promise.resolve(makeSSEResponse(['data: [DONE]']));
207
+ });
208
+ const rt = createOpenAICompatRuntime({
209
+ baseUrl: 'https://api.example.com/v1',
210
+ apiKey: 'test-key',
211
+ defaultModel: 'gpt-4o',
212
+ });
213
+ const events = await collectEvents(rt.invoke({
214
+ prompt: 'Hi',
215
+ model: '',
216
+ cwd: '/tmp',
217
+ tools: ['Read', 'Glob', 'Grep'],
218
+ sessionKey: 'test-session',
219
+ }));
220
+ // Should complete without error
221
+ expect(events.find((e) => e.type === 'error')).toBeUndefined();
222
+ expect(events.find((e) => e.type === 'done')).toBeDefined();
223
+ // Request body should not contain tools
224
+ const parsed = JSON.parse(capturedBody);
225
+ expect(parsed.tools).toBeUndefined();
226
+ });
227
+ it('data: without space after colon is parsed correctly', async () => {
228
+ // SSE spec allows "data:payload" (no space) — some endpoints emit this form
229
+ const noSpaceData = `data:${JSON.stringify({ choices: [{ delta: { content: 'no-space' } }] })}`;
230
+ globalThis.fetch = vi.fn().mockResolvedValue(makeSSEResponse([noSpaceData, 'data:[DONE]']));
231
+ const rt = createOpenAICompatRuntime({
232
+ baseUrl: 'https://api.example.com/v1',
233
+ apiKey: 'test-key',
234
+ defaultModel: 'gpt-4o',
235
+ });
236
+ const events = await collectEvents(rt.invoke({
237
+ prompt: 'Hi',
238
+ model: '',
239
+ cwd: '/tmp',
240
+ }));
241
+ const deltas = events.filter((e) => e.type === 'text_delta');
242
+ expect(deltas).toHaveLength(1);
243
+ expect(deltas[0].text).toBe('no-space');
244
+ const final = events.find((e) => e.type === 'text_final');
245
+ expect(final).toBeDefined();
246
+ expect(final.text).toBe('no-space');
247
+ expect(events[events.length - 1].type).toBe('done');
248
+ });
249
+ // ---------------------------------------------------------------------------
250
+ // OAuth 401 retry tests
251
+ // ---------------------------------------------------------------------------
252
+ it('401 with OAuth: force-refresh token and retry succeeds', async () => {
253
+ const capturedHeaders = [];
254
+ let callCount = 0;
255
+ globalThis.fetch = vi.fn().mockImplementation((_url, init) => {
256
+ callCount++;
257
+ capturedHeaders.push(init?.headers && init.headers['Authorization'] || '');
258
+ if (callCount === 1) {
259
+ // First call returns 401
260
+ return Promise.resolve(new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' }));
261
+ }
262
+ // Retry succeeds
263
+ return Promise.resolve(makeSSEResponse([
264
+ makeSSEData('retried'),
265
+ 'data: [DONE]',
266
+ ]));
267
+ });
268
+ let forceRefreshCalled = false;
269
+ const tokenProvider = {
270
+ getAccessToken: vi.fn().mockImplementation((forceRefresh) => {
271
+ if (forceRefresh)
272
+ forceRefreshCalled = true;
273
+ return Promise.resolve(forceRefresh ? 'refreshed-token' : 'stale-token');
274
+ }),
275
+ };
276
+ const rt = createOpenAICompatRuntime({
277
+ baseUrl: 'https://api.example.com/v1',
278
+ auth: 'chatgpt_oauth',
279
+ tokenProvider,
280
+ defaultModel: 'gpt-4o',
281
+ });
282
+ const events = await collectEvents(rt.invoke({
283
+ prompt: 'Hi',
284
+ model: '',
285
+ cwd: '/tmp',
286
+ }));
287
+ expect(forceRefreshCalled).toBe(true);
288
+ expect(callCount).toBe(2);
289
+ // First request used the stale token, retry used the refreshed token
290
+ expect(capturedHeaders[0]).toBe('Bearer stale-token');
291
+ expect(capturedHeaders[1]).toBe('Bearer refreshed-token');
292
+ const final = events.find((e) => e.type === 'text_final');
293
+ expect(final).toBeDefined();
294
+ expect(final.text).toBe('retried');
295
+ expect(events[events.length - 1].type).toBe('done');
296
+ expect(events.find((e) => e.type === 'error')).toBeUndefined();
297
+ });
298
+ it('401 with OAuth: retry also fails yields error + done', async () => {
299
+ // Both calls return 401
300
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' }));
301
+ const tokenProvider = {
302
+ getAccessToken: vi.fn().mockImplementation((forceRefresh) => Promise.resolve(forceRefresh ? 'refreshed-token' : 'stale-token')),
303
+ };
304
+ const rt = createOpenAICompatRuntime({
305
+ baseUrl: 'https://api.example.com/v1',
306
+ auth: 'chatgpt_oauth',
307
+ tokenProvider,
308
+ defaultModel: 'gpt-4o',
309
+ });
310
+ const events = await collectEvents(rt.invoke({
311
+ prompt: 'Hi',
312
+ model: '',
313
+ cwd: '/tmp',
314
+ }));
315
+ // Should have called getAccessToken twice (initial + force refresh)
316
+ expect(tokenProvider.getAccessToken).toHaveBeenCalledTimes(2);
317
+ expect(tokenProvider.getAccessToken).toHaveBeenLastCalledWith(true);
318
+ // Should have made two HTTP attempts (initial + retry)
319
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
320
+ const errorEvt = events.find((e) => e.type === 'error');
321
+ expect(errorEvt).toBeDefined();
322
+ expect(errorEvt.message).toContain('401');
323
+ expect(events[events.length - 1].type).toBe('done');
324
+ });
325
+ it('401 with static API key: no retry, yields error + done', async () => {
326
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response('Unauthorized', { status: 401, statusText: 'Unauthorized' }));
327
+ const rt = createOpenAICompatRuntime({
328
+ baseUrl: 'https://api.example.com/v1',
329
+ apiKey: 'bad-key',
330
+ defaultModel: 'gpt-4o',
331
+ });
332
+ const events = await collectEvents(rt.invoke({
333
+ prompt: 'Hi',
334
+ model: '',
335
+ cwd: '/tmp',
336
+ }));
337
+ // fetch should only be called once — no retry for static API keys
338
+ expect(globalThis.fetch).toHaveBeenCalledTimes(1);
339
+ const errorEvt = events.find((e) => e.type === 'error');
340
+ expect(errorEvt).toBeDefined();
341
+ expect(errorEvt.message).toContain('401');
342
+ expect(events[events.length - 1].type).toBe('done');
343
+ });
344
+ it('adapter id defaults to "openai" when id is not provided', () => {
345
+ const rt = createOpenAICompatRuntime({
346
+ baseUrl: 'https://api.example.com/v1',
347
+ apiKey: 'test-key',
348
+ defaultModel: 'gpt-4o',
349
+ });
350
+ expect(rt.id).toBe('openai');
351
+ });
352
+ it('adapter id uses override when id is provided in opts', () => {
353
+ const rt = createOpenAICompatRuntime({
354
+ id: 'openrouter',
355
+ baseUrl: 'https://openrouter.ai/api/v1',
356
+ apiKey: 'test-key',
357
+ defaultModel: 'openai/gpt-4o',
358
+ });
359
+ expect(rt.id).toBe('openrouter');
360
+ });
361
+ // ---------------------------------------------------------------------------
362
+ // maxTokens field routing tests
363
+ // ---------------------------------------------------------------------------
364
+ it('maxTokens with standard model (gpt-4o) sends max_tokens', async () => {
365
+ let capturedBody;
366
+ globalThis.fetch = vi.fn().mockImplementation((_url, init) => {
367
+ capturedBody = init?.body;
368
+ return Promise.resolve(makeSSEResponse(['data: [DONE]']));
369
+ });
370
+ const rt = createOpenAICompatRuntime({
371
+ baseUrl: 'https://api.example.com/v1',
372
+ apiKey: 'test-key',
373
+ defaultModel: 'gpt-4o',
374
+ });
375
+ await collectEvents(rt.invoke({
376
+ prompt: 'Hi',
377
+ model: 'gpt-4o',
378
+ cwd: '/tmp',
379
+ maxTokens: 512,
380
+ }));
381
+ const parsed = JSON.parse(capturedBody);
382
+ expect(parsed.max_tokens).toBe(512);
383
+ expect(parsed.max_completion_tokens).toBeUndefined();
384
+ });
385
+ it('maxTokens with newer model (o3-mini) sends max_completion_tokens', async () => {
386
+ let capturedBody;
387
+ globalThis.fetch = vi.fn().mockImplementation((_url, init) => {
388
+ capturedBody = init?.body;
389
+ return Promise.resolve(makeSSEResponse(['data: [DONE]']));
390
+ });
391
+ const rt = createOpenAICompatRuntime({
392
+ baseUrl: 'https://api.example.com/v1',
393
+ apiKey: 'test-key',
394
+ defaultModel: 'gpt-4o',
395
+ });
396
+ await collectEvents(rt.invoke({
397
+ prompt: 'Hi',
398
+ model: 'o3-mini',
399
+ cwd: '/tmp',
400
+ maxTokens: 1024,
401
+ }));
402
+ const parsed = JSON.parse(capturedBody);
403
+ expect(parsed.max_completion_tokens).toBe(1024);
404
+ expect(parsed.max_tokens).toBeUndefined();
405
+ });
406
+ it('no maxTokens set: neither max_tokens nor max_completion_tokens in request body', async () => {
407
+ let capturedBody;
408
+ globalThis.fetch = vi.fn().mockImplementation((_url, init) => {
409
+ capturedBody = init?.body;
410
+ return Promise.resolve(makeSSEResponse(['data: [DONE]']));
411
+ });
412
+ const rt = createOpenAICompatRuntime({
413
+ baseUrl: 'https://api.example.com/v1',
414
+ apiKey: 'test-key',
415
+ defaultModel: 'gpt-4o',
416
+ });
417
+ await collectEvents(rt.invoke({
418
+ prompt: 'Hi',
419
+ model: 'gpt-4o',
420
+ cwd: '/tmp',
421
+ }));
422
+ const parsed = JSON.parse(capturedBody);
423
+ expect(parsed.max_tokens).toBeUndefined();
424
+ expect(parsed.max_completion_tokens).toBeUndefined();
425
+ });
426
+ it('stream ending without trailing newline still processes buffered data', async () => {
427
+ // Simulate a stream that ends with a data line but no trailing \n
428
+ const chunk = makeSSEData('buffered');
429
+ const rawText = `${chunk}`; // no trailing newline
430
+ globalThis.fetch = vi.fn().mockResolvedValue(makeSSEResponseRaw(rawText));
431
+ const rt = createOpenAICompatRuntime({
432
+ baseUrl: 'https://api.example.com/v1',
433
+ apiKey: 'test-key',
434
+ defaultModel: 'gpt-4o',
435
+ });
436
+ const events = await collectEvents(rt.invoke({
437
+ prompt: 'Hi',
438
+ model: '',
439
+ cwd: '/tmp',
440
+ }));
441
+ const deltas = events.filter((e) => e.type === 'text_delta');
442
+ expect(deltas).toHaveLength(1);
443
+ expect(deltas[0].text).toBe('buffered');
444
+ const final = events.find((e) => e.type === 'text_final');
445
+ expect(final).toBeDefined();
446
+ expect(final.text).toBe('buffered');
447
+ expect(events[events.length - 1].type).toBe('done');
448
+ });
449
+ });