pyric 0.1.0-alpha.11 → 0.1.0-alpha.13

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 (370) hide show
  1. package/README.md.orig +0 -1
  2. package/dist/{firestore-values/index.d.ts → firestore/internal/value-codec.d.ts} +2 -2
  3. package/dist/firestore/internal/value-codec.d.ts.map +1 -0
  4. package/dist/{firestore-values/index.js → firestore/internal/value-codec.js} +10 -10
  5. package/dist/firestore/internal/value-codec.js.map +1 -0
  6. package/dist/sandbox/admin-firestore/remote/value-codec.d.ts +2 -1
  7. package/dist/sandbox/admin-firestore/remote/value-codec.d.ts.map +1 -1
  8. package/dist/sandbox/admin-firestore/remote/value-codec.js +3 -2
  9. package/dist/sandbox/admin-firestore/remote/value-codec.js.map +1 -1
  10. package/dist/sandbox/persistence/chunk-format.js +1 -1
  11. package/dist/sandbox/persistence/chunk-format.js.map +1 -1
  12. package/dist/sandbox/persistence/serialize.d.ts +1 -1
  13. package/dist/sandbox/persistence/serialize.d.ts.map +1 -1
  14. package/dist/sandbox/persistence/serialize.js +6 -6
  15. package/dist/sandbox/persistence/serialize.js.map +1 -1
  16. package/package.json +6 -5
  17. package/src/ai/backend.ts +49 -0
  18. package/src/ai/broker/broker.ts +254 -0
  19. package/src/ai/broker/index.ts +59 -0
  20. package/src/ai/broker/openai-engine.ts +646 -0
  21. package/src/ai/broker/scripted-engine.ts +277 -0
  22. package/src/ai/broker/synthesizer.ts +340 -0
  23. package/src/ai/broker/types.ts +194 -0
  24. package/src/ai/enums.ts +193 -0
  25. package/src/ai/errors.ts +125 -0
  26. package/src/ai/index.ts +139 -0
  27. package/src/ai/instances.ts +136 -0
  28. package/src/ai/internal.ts +57 -0
  29. package/src/ai/models.ts +344 -0
  30. package/src/ai/request-helpers.ts +214 -0
  31. package/src/ai/response-helpers.ts +230 -0
  32. package/src/ai/sandbox-plane.ts +219 -0
  33. package/src/ai/schema.ts +247 -0
  34. package/src/ai/scripting.ts +114 -0
  35. package/src/ai/target.ts +36 -0
  36. package/src/ai/types.ts +491 -0
  37. package/src/app/diagnostics.test.ts +44 -0
  38. package/src/app/diagnostics.ts +105 -0
  39. package/src/app/dispatch.test.ts +142 -0
  40. package/src/app/index.ts +15 -0
  41. package/src/app/internal.ts +34 -0
  42. package/src/app/options.ts +18 -0
  43. package/src/app/package-resolution.test.ts +14 -0
  44. package/src/app/register.test.ts +31 -0
  45. package/src/app/register.ts +45 -0
  46. package/src/app/registry.ts +193 -0
  47. package/src/app/runtime.ts +148 -0
  48. package/src/app/types.ts +22 -0
  49. package/src/auth/action-code-url.ts +133 -0
  50. package/src/auth/action-codes.ts +465 -0
  51. package/src/auth/auth-errors.ts +24 -0
  52. package/src/auth/config-tokens.ts +101 -0
  53. package/src/auth/credentials.ts +228 -0
  54. package/src/auth/email-link.ts +207 -0
  55. package/src/auth/enums.ts +214 -0
  56. package/src/auth/index.ts +2 -0
  57. package/src/auth/instances.ts +127 -0
  58. package/src/auth/linking.ts +224 -0
  59. package/src/auth/modular.ts +118 -0
  60. package/src/auth/password-policy.ts +102 -0
  61. package/src/auth/providers.ts +250 -0
  62. package/src/auth/reauth.ts +194 -0
  63. package/src/auth/sandbox/driver.ts +142 -0
  64. package/src/auth/sandbox/persistence.ts +156 -0
  65. package/src/auth/sandbox/project-store.ts +31 -0
  66. package/src/auth/sandbox-auth-flow.ts +255 -0
  67. package/src/auth/sandbox-backend-types.ts +200 -0
  68. package/src/auth/sandbox-backend.ts +2259 -0
  69. package/src/auth/sandbox-credential-validators.ts +52 -0
  70. package/src/auth/sandbox-token.ts +32 -0
  71. package/src/auth/sign-in.ts +194 -0
  72. package/src/auth/target.ts +43 -0
  73. package/src/auth/tokens.ts +155 -0
  74. package/src/auth/types.ts +298 -0
  75. package/src/auth/user-lifecycle.ts +93 -0
  76. package/src/database/index.ts +8 -0
  77. package/src/database/listener-registry.ts +89 -0
  78. package/src/database/modular.ts +1483 -0
  79. package/src/database/replay.ts +213 -0
  80. package/src/database/sandbox/backend-for.ts +36 -0
  81. package/src/database/sandbox/backend.ts +1805 -0
  82. package/src/database/sandbox/data-tree.ts +322 -0
  83. package/src/database/sandbox/normalize.ts +207 -0
  84. package/src/database/sandbox/push-id.ts +86 -0
  85. package/src/database/sandbox/query.ts +383 -0
  86. package/src/database/sandbox/rules-eval.ts +202 -0
  87. package/src/database/sandbox/sentinels.ts +114 -0
  88. package/src/database/sandbox-controls.ts +33 -0
  89. package/src/firestore/aggregates.ts +104 -0
  90. package/src/firestore/equality.ts +32 -0
  91. package/src/firestore/field-values.ts +203 -0
  92. package/src/firestore/index.ts +60 -0
  93. package/src/firestore/instances.ts +292 -0
  94. package/src/firestore/internal/index.ts +14 -0
  95. package/src/firestore/internal/listener-routing.ts +3 -0
  96. package/src/firestore/internal/value-codec.ts +175 -0
  97. package/src/firestore/listeners.ts +280 -0
  98. package/src/firestore/persistence.ts +381 -0
  99. package/src/firestore/query-constraints.ts +283 -0
  100. package/src/firestore/reads.ts +67 -0
  101. package/src/firestore/refs.ts +190 -0
  102. package/src/firestore/sandbox/activity-bounded.ts +12 -0
  103. package/src/firestore/sandbox/activity-contract.ts +60 -0
  104. package/src/firestore/sandbox/activity-listener-retention.ts +21 -0
  105. package/src/firestore/sandbox/activity-monitor.ts +547 -0
  106. package/src/firestore/sandbox/activity-public-identity.ts +90 -0
  107. package/src/firestore/sandbox/activity-query-value.ts +60 -0
  108. package/src/firestore/sandbox/activity-structural-identity.ts +11 -0
  109. package/src/firestore/sandbox/activity-value-registry.ts +126 -0
  110. package/src/firestore/sandbox/admin-compat/batch.ts +86 -0
  111. package/src/firestore/sandbox/admin-compat/doc-ref.ts +184 -0
  112. package/src/firestore/sandbox/admin-compat/firestore.ts +154 -0
  113. package/src/firestore/sandbox/admin-compat/index.ts +96 -0
  114. package/src/firestore/sandbox/admin-compat/paths.ts +53 -0
  115. package/src/firestore/sandbox/admin-compat/query.ts +983 -0
  116. package/src/firestore/sandbox/admin-compat/read-translation.ts +66 -0
  117. package/src/firestore/sandbox/admin-compat/snapshots.ts +60 -0
  118. package/src/firestore/sandbox/admin-compat/transaction.ts +102 -0
  119. package/src/firestore/sandbox/admin-compat/types.ts +404 -0
  120. package/src/firestore/sandbox/admin-compat/value-order.ts +186 -0
  121. package/src/firestore/sandbox/auto-id.ts +40 -0
  122. package/src/firestore/sandbox/converters/bytes-geopoint.ts +100 -0
  123. package/src/firestore/sandbox/converters/fieldvalue.ts +172 -0
  124. package/src/firestore/sandbox/converters/reference.ts +59 -0
  125. package/src/firestore/sandbox/converters/timestamp.ts +65 -0
  126. package/src/firestore/sandbox/converters/user-timestamp.ts +72 -0
  127. package/src/firestore/sandbox/converters/vector.ts +51 -0
  128. package/src/firestore/sandbox/errors.ts +137 -0
  129. package/src/firestore/sandbox/event-bus.ts +117 -0
  130. package/src/firestore/sandbox/event-log.ts +134 -0
  131. package/src/firestore/sandbox/field-merge.ts +245 -0
  132. package/src/firestore/sandbox/history-controls.ts +98 -0
  133. package/src/firestore/sandbox/list-query-proof.ts +221 -0
  134. package/src/firestore/sandbox/listener-delivery.ts +38 -0
  135. package/src/firestore/sandbox/listener-dispatch.ts +914 -0
  136. package/src/firestore/sandbox/local-environment.ts +2012 -0
  137. package/src/firestore/sandbox/local-state.ts +538 -0
  138. package/src/firestore/sandbox/overlay-backing.ts +103 -0
  139. package/src/firestore/sandbox/reads.ts +18 -0
  140. package/src/firestore/sandbox/request-events.ts +119 -0
  141. package/src/firestore/sandbox/rules-evaluation.ts +95 -0
  142. package/src/firestore/sandbox/rules-read-engine.ts +499 -0
  143. package/src/firestore/sandbox/rules-state.ts +61 -0
  144. package/src/firestore/sandbox/sentinel-capture.ts +63 -0
  145. package/src/firestore/sandbox/snapshot-listeners.ts +482 -0
  146. package/src/firestore/sandbox/topk.ts +38 -0
  147. package/src/firestore/sandbox/transaction-merge.ts +157 -0
  148. package/src/firestore/sandbox/transaction-types.ts +160 -0
  149. package/src/firestore/sandbox/transaction.ts +143 -0
  150. package/src/firestore/sandbox/trigger-scope.ts +52 -0
  151. package/src/firestore/sandbox/value-equality.ts +34 -0
  152. package/src/firestore/sandbox/value-resolver.ts +289 -0
  153. package/src/firestore/sandbox/wire-encoder.ts +136 -0
  154. package/src/firestore/sandbox/writes.ts +91 -0
  155. package/src/firestore/sandbox-controls.ts +143 -0
  156. package/src/firestore/snapshots.ts +188 -0
  157. package/src/firestore/state.ts +341 -0
  158. package/src/firestore/tools.ts +386 -0
  159. package/src/firestore/transactions.ts +32 -0
  160. package/src/firestore/types.ts +119 -0
  161. package/src/firestore/writes.ts +106 -0
  162. package/src/messaging/broker/broker.test.ts +260 -0
  163. package/src/messaging/broker/broker.ts +482 -0
  164. package/src/messaging/broker/envelopes.ts +177 -0
  165. package/src/messaging/broker/index.ts +72 -0
  166. package/src/messaging/broker/tokens.ts +39 -0
  167. package/src/messaging/broker/types.ts +104 -0
  168. package/src/messaging/broker/validate.ts +206 -0
  169. package/src/messaging/client.ts +160 -0
  170. package/src/messaging/index.ts +2 -0
  171. package/src/messaging/instance.ts +199 -0
  172. package/src/messaging/internal.ts +20 -0
  173. package/src/messaging/sw.ts +110 -0
  174. package/src/project-scope.ts +4 -0
  175. package/src/rules/api/assert.ts +130 -0
  176. package/src/rules/api/case-types.ts +158 -0
  177. package/src/rules/api/errors.ts +51 -0
  178. package/src/rules/api/firestore.ts +173 -0
  179. package/src/rules/api/issue.ts +101 -0
  180. package/src/rules/api/lint.ts +69 -0
  181. package/src/rules/api/rtdb.ts +213 -0
  182. package/src/rules/api/values.ts +73 -0
  183. package/src/rules/generators/assembler.ts +325 -0
  184. package/src/rules/generators/expressions.ts +125 -0
  185. package/src/rules/generators/grid.ts +63 -0
  186. package/src/rules/grammar/FirestoreAST.ts +93 -0
  187. package/src/rules/grammar/FirestoreAssembler.ts +200 -0
  188. package/src/rules/grammar/FirestoreParser.ts +431 -0
  189. package/src/rules/grammar/FirestoreRules.ohm +236 -0
  190. package/src/rules/grammar/FirestoreRules.ohm.generated.ts +241 -0
  191. package/src/rules/grammar/FirestoreValidator.ts +502 -0
  192. package/src/rules/index.ts +151 -0
  193. package/src/rules/indexes/extract/annotation-collect.ts +90 -0
  194. package/src/rules/indexes/extract/annotations.ts +177 -0
  195. package/src/rules/indexes/extract/ast.ts +118 -0
  196. package/src/rules/indexes/extract/classify.ts +163 -0
  197. package/src/rules/indexes/extract/composite.ts +112 -0
  198. package/src/rules/indexes/extract/dataflow.ts +258 -0
  199. package/src/rules/indexes/extract/enumerate.ts +194 -0
  200. package/src/rules/indexes/extract/extractor.ts +311 -0
  201. package/src/rules/indexes/extract/types.ts +222 -0
  202. package/src/rules/indexes/extractHandler.ts +110 -0
  203. package/src/rules/indexes/extractTool.ts +73 -0
  204. package/src/rules/indexes/types.ts +83 -0
  205. package/src/rules/inspect/handler.ts +174 -0
  206. package/src/rules/inspect/spec.ts +35 -0
  207. package/src/rules/inspect/tools.ts +42 -0
  208. package/src/rules/internal/extract.ts +51 -0
  209. package/src/rules/internal/index.ts +239 -0
  210. package/src/rules/internal/node.ts +44 -0
  211. package/src/rules/internal/rtdb.ts +108 -0
  212. package/src/rules/linter/ast-utils.ts +401 -0
  213. package/src/rules/linter/hallucinations.ts +373 -0
  214. package/src/rules/linter/linter.ts +904 -0
  215. package/src/rules/modules/resolver-browser.ts +71 -0
  216. package/src/rules/modules/resolver-core.ts +382 -0
  217. package/src/rules/modules/resolver.ts +62 -0
  218. package/src/rules/modules/stdlib/STDLIB.md +271 -0
  219. package/src/rules/modules/stdlib/atomic.rules +52 -0
  220. package/src/rules/modules/stdlib/atomic.test.json +152 -0
  221. package/src/rules/modules/stdlib/auth.rules +7 -0
  222. package/src/rules/modules/stdlib/auth.test.json +53 -0
  223. package/src/rules/modules/stdlib/content.rules +50 -0
  224. package/src/rules/modules/stdlib/content.test.json +238 -0
  225. package/src/rules/modules/stdlib/counters.rules +41 -0
  226. package/src/rules/modules/stdlib/counters.test.json +254 -0
  227. package/src/rules/modules/stdlib/geometry.rules +43 -0
  228. package/src/rules/modules/stdlib/geometry.test.json +200 -0
  229. package/src/rules/modules/stdlib/joining.rules +49 -0
  230. package/src/rules/modules/stdlib/joining.test.json +287 -0
  231. package/src/rules/modules/stdlib/lifecycle.rules +51 -0
  232. package/src/rules/modules/stdlib/lifecycle.test.json +288 -0
  233. package/src/rules/modules/stdlib/lobby.rules +44 -0
  234. package/src/rules/modules/stdlib/lobby.test.json +32 -0
  235. package/src/rules/modules/stdlib/membership.rules +40 -0
  236. package/src/rules/modules/stdlib/membership.test.json +107 -0
  237. package/src/rules/modules/stdlib/spaces.rules +46 -0
  238. package/src/rules/modules/stdlib/spaces.test.json +392 -0
  239. package/src/rules/modules/stdlib/state.rules +28 -0
  240. package/src/rules/modules/stdlib/state.test.json +55 -0
  241. package/src/rules/modules/stdlib/timing.rules +20 -0
  242. package/src/rules/modules/stdlib/timing.test.json +106 -0
  243. package/src/rules/modules/stdlib/transitions.rules +34 -0
  244. package/src/rules/modules/stdlib/transitions.test.json +86 -0
  245. package/src/rules/modules/stdlib/turns.rules +27 -0
  246. package/src/rules/modules/stdlib/turns.test.json +64 -0
  247. package/src/rules/modules/stdlib/validation.rules +31 -0
  248. package/src/rules/modules/stdlib/validation.test.json +184 -0
  249. package/src/rules/modules/stdlib-content.ts +590 -0
  250. package/src/rules/rtdb/compiled-rules.ts +121 -0
  251. package/src/rules/rtdb/constraints/atoms.spec.ts +47 -0
  252. package/src/rules/rtdb/constraints/atoms.ts +69 -0
  253. package/src/rules/rtdb/constraints/compose.ts +24 -0
  254. package/src/rules/rtdb/constraints/data.ts +79 -0
  255. package/src/rules/rtdb/constraints/document.ts +162 -0
  256. package/src/rules/rtdb/constraints/game.spec.ts +32 -0
  257. package/src/rules/rtdb/constraints/game.ts +70 -0
  258. package/src/rules/rtdb/constraints/index.ts +32 -0
  259. package/src/rules/rtdb/constraints/policies.spec.ts +34 -0
  260. package/src/rules/rtdb/constraints/policies.ts +36 -0
  261. package/src/rules/rtdb/constraints/ruleset.ts +151 -0
  262. package/src/rules/rtdb/constraints/schema.ts +89 -0
  263. package/src/rules/rtdb/constraints/types.ts +23 -0
  264. package/src/rules/rtdb/constraints/write-rules-file.ts +35 -0
  265. package/src/rules/rtdb/expression-engine.ts +24 -0
  266. package/src/rules/rtdb/grammar/RtdbExpr.ohm +98 -0
  267. package/src/rules/rtdb/grammar/RtdbExpr.ohm.generated.ts +103 -0
  268. package/src/rules/rtdb/grammar/RtdbExprParser.ts +65 -0
  269. package/src/rules/rtdb/grammar/linter.ts +90 -0
  270. package/src/rules/rtdb/grammar/simulator.ts +340 -0
  271. package/src/rules/rtdb/grammar/validator.ts +113 -0
  272. package/src/rules/rtdb/simulation/handler.ts +430 -0
  273. package/src/rules/rtdb/simulation/spec.ts +48 -0
  274. package/src/rules/rtdb/types.ts +39 -0
  275. package/src/rules/simulator/evaluator.ts +1461 -0
  276. package/src/rules/simulator/expression/eval-errors.ts +136 -0
  277. package/src/rules/simulator/expression/evaluator.ts +425 -0
  278. package/src/rules/simulator/expression/lexer.ts +395 -0
  279. package/src/rules/simulator/expression/parser.ts +375 -0
  280. package/src/rules/simulator/expression/types.ts +252 -0
  281. package/src/rules/simulator/expression/walk-data.ts +216 -0
  282. package/src/rules/simulator/firestore-set.ts +90 -0
  283. package/src/rules/simulator/handler.ts +739 -0
  284. package/src/rules/simulator/mapdiff.ts +78 -0
  285. package/src/rules/simulator/project-after-state.ts +125 -0
  286. package/src/rules/simulator/query-proof.ts +513 -0
  287. package/src/rules/simulator/value-equality.ts +33 -0
  288. package/src/rules/simulator/wrappers/base.ts +143 -0
  289. package/src/rules/simulator/wrappers/bytes.ts +142 -0
  290. package/src/rules/simulator/wrappers/duration.ts +191 -0
  291. package/src/rules/simulator/wrappers/float.ts +92 -0
  292. package/src/rules/simulator/wrappers/latlng.ts +116 -0
  293. package/src/rules/simulator/wrappers/path.ts +166 -0
  294. package/src/rules/simulator/wrappers/reference.ts +161 -0
  295. package/src/rules/simulator/wrappers/timestamp.ts +228 -0
  296. package/src/rules/simulator/wrappers/vector.ts +88 -0
  297. package/src/rules/simulator-tools-impl.ts +544 -0
  298. package/src/rules/simulator.ts +36 -0
  299. package/src/rules/stdlib-modules.ts +1023 -0
  300. package/src/rules/stdlib-tools.ts +206 -0
  301. package/src/rules/test/handler.ts +272 -0
  302. package/src/rules/test/spec.ts +681 -0
  303. package/src/rules/tools.ts +159 -0
  304. package/src/rules/write/handler.ts +140 -0
  305. package/src/rules/write/spec.ts +7 -0
  306. package/src/sandbox/admin-compat.ts +39 -0
  307. package/src/sandbox/admin-firestore/error-translation.ts +254 -0
  308. package/src/sandbox/admin-firestore/get-admin-firestore.ts +104 -0
  309. package/src/sandbox/admin-firestore/get-firestore.ts +73 -0
  310. package/src/sandbox/admin-firestore/index.ts +96 -0
  311. package/src/sandbox/admin-firestore/listeners.ts +400 -0
  312. package/src/sandbox/admin-firestore/local-handle.ts +98 -0
  313. package/src/sandbox/admin-firestore/remote/batch.ts +41 -0
  314. package/src/sandbox/admin-firestore/remote/channel.ts +34 -0
  315. package/src/sandbox/admin-firestore/remote/doc-ref.ts +112 -0
  316. package/src/sandbox/admin-firestore/remote/errors.ts +38 -0
  317. package/src/sandbox/admin-firestore/remote/listeners.ts +130 -0
  318. package/src/sandbox/admin-firestore/remote/query.ts +245 -0
  319. package/src/sandbox/admin-firestore/remote/remote-firestore.ts +148 -0
  320. package/src/sandbox/admin-firestore/remote/snapshots.ts +50 -0
  321. package/src/sandbox/admin-firestore/remote/transaction.ts +128 -0
  322. package/src/sandbox/admin-firestore/remote/value-codec.ts +134 -0
  323. package/src/sandbox/admin-firestore/remote/wire-types.ts +65 -0
  324. package/src/sandbox/admin-firestore/types.ts +62 -0
  325. package/src/sandbox/branches/index.ts +378 -0
  326. package/src/sandbox/index.ts +141 -0
  327. package/src/sandbox/internal/client-app.ts +49 -0
  328. package/src/sandbox/internal/firebase-error.ts +23 -0
  329. package/src/sandbox/internal/index.ts +96 -0
  330. package/src/sandbox/internal/local-brand.ts +2 -0
  331. package/src/sandbox/internal/provenance.ts +176 -0
  332. package/src/sandbox/internal/sandbox-impl.ts +837 -0
  333. package/src/sandbox/operation-record.ts +194 -0
  334. package/src/sandbox/persistence/backends.ts +229 -0
  335. package/src/sandbox/persistence/chunk-format.ts +195 -0
  336. package/src/sandbox/persistence/controller.ts +537 -0
  337. package/src/sandbox/persistence/index.ts +12 -0
  338. package/src/sandbox/persistence/serialize.ts +131 -0
  339. package/src/sandbox/persistence/types.ts +118 -0
  340. package/src/sandbox/remote.ts +137 -0
  341. package/src/sandbox/replay/index.ts +326 -0
  342. package/src/sandbox/sandbox-context.ts +117 -0
  343. package/src/sandbox/tab-sync/index.ts +371 -0
  344. package/src/sandbox/types/auth-state.ts +22 -0
  345. package/src/sandbox/types/context.ts +34 -0
  346. package/src/sandbox/types/errors.ts +160 -0
  347. package/src/sandbox/types/events.ts +570 -0
  348. package/src/sandbox/types/index.ts +61 -0
  349. package/src/sandbox/types/operation.ts +64 -0
  350. package/src/sandbox/types/persistence.ts +150 -0
  351. package/src/sandbox/types/service.ts +392 -0
  352. package/src/storage/admin/api.ts +601 -0
  353. package/src/storage/admin/handler.ts +125 -0
  354. package/src/storage/admin/spec.ts +73 -0
  355. package/src/storage/admin/tools.ts +115 -0
  356. package/src/storage/download.ts +162 -0
  357. package/src/storage/enforce.ts +174 -0
  358. package/src/storage/errors.ts +95 -0
  359. package/src/storage/index.ts +95 -0
  360. package/src/storage/instances.ts +42 -0
  361. package/src/storage/internal.ts +21 -0
  362. package/src/storage/list.ts +96 -0
  363. package/src/storage/metadata.ts +224 -0
  364. package/src/storage/persistence.ts +282 -0
  365. package/src/storage/reference.ts +134 -0
  366. package/src/storage/rules.ts +1544 -0
  367. package/src/storage/service.ts +389 -0
  368. package/src/storage/upload.ts +291 -0
  369. package/dist/firestore-values/index.d.ts.map +0 -1
  370. package/dist/firestore-values/index.js.map +0 -1
@@ -0,0 +1,646 @@
1
+ /**
2
+ * The OpenAI AnswerEngine: Gemini wire in, OpenAI-compatible upstream
3
+ * (Ollama, llama.cpp, any /v1/chat/completions) out, Gemini wire back.
4
+ *
5
+ * The translation core was validated end-to-end against the real firebase/ai
6
+ * 2.12.0 SDK before landing here as pure, unit-testable functions; only
7
+ * `OpenAiEngine` does I/O.
8
+ *
9
+ * The five lossy edges that validation pinned, all handled here:
10
+ * 1. Gemini functionResponse has no tool_call_id → synthesized FIFO id
11
+ * matching per function name ({@link geminiToOpenAIRequest}).
12
+ * 2. OpenAI streams tool-call ARGUMENT FRAGMENTS; Gemini streams WHOLE
13
+ * functionCall parts → {@link ToolCallBuffer} accumulates fragments and
14
+ * the engine emits whole parts on the finish chunk.
15
+ * 3. Never forward OpenAI's `data: [DONE]` sentinel — the SDK JSON.parses
16
+ * every event and dies with PARSE_FAILED (engine skips it).
17
+ * 4. Thought parts are SKIPPED when replaying history to OpenAI
18
+ * (`part.thought` filter in the request walk).
19
+ * 5. thoughtSignature has no OpenAI equivalent → minted on the Gemini
20
+ * side by the synthesizer's decoration pass (production signs even
21
+ * trivial text parts, and rejects unsigned functionCall turns).
22
+ *
23
+ * Plus: `content_filter` maps to SAFETY (faithful — makes response.text()
24
+ * throw exactly like production).
25
+ */
26
+
27
+ import {
28
+ AiBrokerError,
29
+ Synthesizer,
30
+ errorEnvelope,
31
+ estimateTokens,
32
+ resolveModelVersion,
33
+ } from './synthesizer.js';
34
+ import { promptTextOf } from './scripted-engine.js';
35
+ import type {
36
+ AnswerEngine,
37
+ CountTokensRequest,
38
+ CountTokensResponse,
39
+ GenerateContentRequest,
40
+ WireChunk,
41
+ WirePart,
42
+ WireResponse,
43
+ } from './types.js';
44
+
45
+ // ── OpenAI-side wire types (minimal) ────────────────────────────────────────
46
+
47
+ export interface OpenAIToolCall {
48
+ id: string;
49
+ type: 'function';
50
+ function: { name: string; arguments: string };
51
+ }
52
+
53
+ export interface OpenAIMessage {
54
+ role: 'system' | 'user' | 'assistant' | 'tool';
55
+ content: string | null;
56
+ tool_calls?: OpenAIToolCall[];
57
+ tool_call_id?: string;
58
+ }
59
+
60
+ export interface OpenAIRequest {
61
+ model: string;
62
+ messages: OpenAIMessage[];
63
+ stream?: boolean;
64
+ stream_options?: { include_usage: boolean };
65
+ tools?: Array<{ type: 'function'; function: Record<string, unknown> }>;
66
+ tool_choice?: string;
67
+ temperature?: number;
68
+ top_p?: number;
69
+ max_tokens?: number;
70
+ stop?: string[];
71
+ response_format?:
72
+ | { type: 'json_object' }
73
+ | {
74
+ type: 'json_schema';
75
+ json_schema: { name: string; schema: Record<string, unknown>; strict?: boolean };
76
+ };
77
+ frequency_penalty?: number;
78
+ presence_penalty?: number;
79
+ }
80
+
81
+ export interface OpenAIResponse {
82
+ id: string;
83
+ model: string;
84
+ choices: Array<{
85
+ index: number;
86
+ message: {
87
+ role: string;
88
+ content: string | null;
89
+ tool_calls?: OpenAIToolCall[];
90
+ reasoning?: string;
91
+ reasoning_content?: string;
92
+ };
93
+ finish_reason: string | null;
94
+ }>;
95
+ usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
96
+ }
97
+
98
+ export interface OpenAIStreamChunk {
99
+ id: string;
100
+ model: string;
101
+ choices: Array<{
102
+ index: number;
103
+ delta: {
104
+ role?: string;
105
+ content?: string;
106
+ reasoning?: string;
107
+ reasoning_content?: string;
108
+ tool_calls?: Array<Partial<OpenAIToolCall> & { index: number }>;
109
+ };
110
+ finish_reason: string | null;
111
+ }>;
112
+ usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
113
+ }
114
+
115
+ // ── Request translation: Gemini → OpenAI (pure) ─────────────────────────────
116
+
117
+ /**
118
+ * Gemini functionResponse parts carry only a function *name*; OpenAI tool
119
+ * messages require the tool_call_id of the originating call. We synthesize
120
+ * deterministic ids when translating the model's functionCall parts and
121
+ * look them up by name (FIFO per name) for the responses.
122
+ */
123
+ function synthToolCallId(name: string, ordinal: number): string {
124
+ return `call_${name}_${ordinal}`;
125
+ }
126
+
127
+ export function geminiToOpenAIRequest(
128
+ model: string,
129
+ req: GenerateContentRequest,
130
+ stream: boolean,
131
+ ): OpenAIRequest {
132
+ const messages: OpenAIMessage[] = [];
133
+
134
+ // systemInstruction -> leading system message
135
+ if (req.systemInstruction) {
136
+ const text = (req.systemInstruction.parts ?? []).map((p) => p.text ?? '').join('');
137
+ if (text) messages.push({ role: 'system', content: text });
138
+ }
139
+
140
+ // Walk contents. Track pending tool-call ids per function name so a later
141
+ // functionResponse turn can reference the right id.
142
+ const pendingIdsByName = new Map<string, string[]>();
143
+ let callOrdinal = 0;
144
+
145
+ for (const content of req.contents ?? []) {
146
+ if (content.role === 'function') {
147
+ // functionResponse turn -> one OpenAI `tool` message per part
148
+ for (const part of content.parts) {
149
+ if (!part.functionResponse) continue;
150
+ const queue = pendingIdsByName.get(part.functionResponse.name) ?? [];
151
+ const id = queue.shift() ?? synthToolCallId(part.functionResponse.name, callOrdinal++);
152
+ messages.push({
153
+ role: 'tool',
154
+ tool_call_id: id,
155
+ content: JSON.stringify(part.functionResponse.response),
156
+ });
157
+ }
158
+ continue;
159
+ }
160
+
161
+ const role = content.role === 'model' ? 'assistant' : 'user';
162
+ const textParts: string[] = [];
163
+ const toolCalls: OpenAIToolCall[] = [];
164
+
165
+ for (const part of content.parts) {
166
+ // LOSSY EDGE: thought parts must not be replayed to OpenAI as
167
+ // assistant text; real Gemini also does not accept thought text back
168
+ // (only thoughtSignatures — which have no OpenAI equivalent and drop).
169
+ if (part.thought) continue;
170
+ if (typeof part.text === 'string') {
171
+ textParts.push(part.text);
172
+ } else if (part.functionCall) {
173
+ const id = synthToolCallId(part.functionCall.name, callOrdinal++);
174
+ const queue = pendingIdsByName.get(part.functionCall.name) ?? [];
175
+ queue.push(id);
176
+ pendingIdsByName.set(part.functionCall.name, queue);
177
+ toolCalls.push({
178
+ id,
179
+ type: 'function',
180
+ // LOSSY EDGE: Gemini args is an object; OpenAI wants a JSON string.
181
+ function: {
182
+ name: part.functionCall.name,
183
+ arguments: JSON.stringify(part.functionCall.args ?? {}),
184
+ },
185
+ });
186
+ } else if (part.inlineData) {
187
+ // Not exercised by the V1 broker; marked gap carried from the prototype.
188
+ textParts.push(`[unsupported inlineData part: ${part.inlineData.mimeType}]`);
189
+ }
190
+ }
191
+
192
+ const msg: OpenAIMessage = {
193
+ role,
194
+ content: textParts.length ? textParts.join('') : null,
195
+ };
196
+ if (toolCalls.length) msg.tool_calls = toolCalls;
197
+ messages.push(msg);
198
+ }
199
+
200
+ const out: OpenAIRequest = { model, messages, stream };
201
+ if (stream) out.stream_options = { include_usage: true };
202
+
203
+ // tools
204
+ const decls = (req.tools ?? []).flatMap((t) => t.functionDeclarations ?? []);
205
+ if (decls.length) {
206
+ out.tools = decls.map((d) => ({
207
+ type: 'function',
208
+ function: {
209
+ name: d.name,
210
+ description: d.description,
211
+ parameters: normalizeSchema(d.parameters) ?? { type: 'object', properties: {} },
212
+ },
213
+ }));
214
+ }
215
+
216
+ // toolConfig.mode -> tool_choice
217
+ const mode = req.toolConfig?.functionCallingConfig?.mode;
218
+ if (mode === 'ANY') out.tool_choice = 'required';
219
+ else if (mode === 'NONE') out.tool_choice = 'none';
220
+ else if (mode === 'AUTO') out.tool_choice = 'auto';
221
+
222
+ // generationConfig
223
+ const g = req.generationConfig;
224
+ if (g) {
225
+ if (g.temperature !== undefined) out.temperature = g.temperature;
226
+ if (g.topP !== undefined) out.top_p = g.topP;
227
+ if (g.maxOutputTokens !== undefined) out.max_tokens = g.maxOutputTokens;
228
+ if (g.stopSequences?.length) out.stop = g.stopSequences;
229
+ if (g.frequencyPenalty !== undefined) out.frequency_penalty = g.frequencyPenalty;
230
+ if (g.presencePenalty !== undefined) out.presence_penalty = g.presencePenalty;
231
+ // LOSSY EDGE: topK has no OpenAI equivalent; dropped.
232
+ if (g.responseMimeType === 'application/json') {
233
+ out.response_format = g.responseSchema
234
+ ? {
235
+ type: 'json_schema',
236
+ json_schema: {
237
+ name: 'response',
238
+ schema: normalizeSchema(g.responseSchema)!,
239
+ strict: true,
240
+ },
241
+ }
242
+ : { type: 'json_object' };
243
+ }
244
+ }
245
+
246
+ return out;
247
+ }
248
+
249
+ /**
250
+ * Gemini schemas allow uppercase type names ("STRING") and a `nullable`
251
+ * flag; OpenAI/JSON-Schema wants lowercase types. Normalize defensively and
252
+ * strip fields JSON Schema validators reject.
253
+ */
254
+ export function normalizeSchema(schema: unknown): Record<string, unknown> | undefined {
255
+ if (!schema || typeof schema !== 'object') return undefined;
256
+ const s = { ...(schema as Record<string, unknown>) };
257
+ if (typeof s.type === 'string') s.type = (s.type as string).toLowerCase();
258
+ delete s.nullable;
259
+ if (s.properties && typeof s.properties === 'object') {
260
+ s.properties = Object.fromEntries(
261
+ Object.entries(s.properties as Record<string, unknown>).map(([k, v]) => [
262
+ k,
263
+ normalizeSchema(v),
264
+ ]),
265
+ );
266
+ }
267
+ if (s.items) s.items = normalizeSchema(s.items);
268
+ return s;
269
+ }
270
+
271
+ // ── Response translation: OpenAI → Gemini (pure) ────────────────────────────
272
+
273
+ const FINISH_REASON_MAP: Record<string, string> = {
274
+ stop: 'STOP',
275
+ length: 'MAX_TOKENS',
276
+ // A tool call is a normal, expected stop in Gemini's model; mapping to
277
+ // anything in badFinishReasons would make the SDK throw on response.text().
278
+ tool_calls: 'STOP',
279
+ // Faithful mapping: SAFETY makes the SDK's response.text() throw — exactly
280
+ // the real backend's behavior for filtered content.
281
+ content_filter: 'SAFETY',
282
+ };
283
+
284
+ export function mapFinishReason(reason: string | null | undefined): string {
285
+ if (!reason) return 'STOP';
286
+ return FINISH_REASON_MAP[reason] ?? 'OTHER';
287
+ }
288
+
289
+ function messagePartsFromOpenAI(msg: {
290
+ content: string | null;
291
+ tool_calls?: OpenAIToolCall[];
292
+ reasoning?: string;
293
+ reasoning_content?: string;
294
+ }): WirePart[] {
295
+ const parts: WirePart[] = [];
296
+ // Ollama's OpenAI compat surfaces thinking-model output in a nonstandard
297
+ // `reasoning` field; llama-server / deepseek-format servers use
298
+ // `reasoning_content` instead. Gemini represents either as a thought part.
299
+ // `reasoning` wins if both are somehow present (shouldn't happen in practice).
300
+ const reasoning = msg.reasoning ?? msg.reasoning_content;
301
+ if (reasoning) parts.push({ text: reasoning, thought: true });
302
+ if (msg.content) parts.push({ text: msg.content });
303
+ for (const tc of msg.tool_calls ?? []) {
304
+ let args: Record<string, unknown> = {};
305
+ try {
306
+ // LOSSY EDGE in reverse: OpenAI arguments is a JSON *string*; Gemini
307
+ // wants an object.
308
+ args = JSON.parse(tc.function.arguments || '{}');
309
+ } catch {
310
+ args = { __malformed: tc.function.arguments };
311
+ }
312
+ parts.push({ functionCall: { name: tc.function.name, args } });
313
+ }
314
+ if (!parts.length) parts.push({ text: '' });
315
+ return parts;
316
+ }
317
+
318
+ export function openAIToGeminiResponse(resp: OpenAIResponse): WireResponse {
319
+ const choice = resp.choices[0]!;
320
+ const malformedToolArgs = (choice.message.tool_calls ?? []).some((tc) => {
321
+ try {
322
+ JSON.parse(tc.function.arguments || '{}');
323
+ return false;
324
+ } catch {
325
+ return true;
326
+ }
327
+ });
328
+ const out: WireResponse = {
329
+ candidates: [
330
+ {
331
+ index: 0,
332
+ content: { role: 'model', parts: messagePartsFromOpenAI(choice.message) },
333
+ finishReason: malformedToolArgs
334
+ ? 'MALFORMED_FUNCTION_CALL'
335
+ : mapFinishReason(choice.finish_reason),
336
+ },
337
+ ],
338
+ };
339
+ if (resp.usage) {
340
+ // Upstream-faithful counts only; the synthesizer's decorate pass adds the
341
+ // synthesized promptTokensDetails/serviceTier decoration.
342
+ out.usageMetadata = {
343
+ promptTokenCount: resp.usage.prompt_tokens,
344
+ candidatesTokenCount: resp.usage.completion_tokens,
345
+ totalTokenCount: resp.usage.total_tokens,
346
+ };
347
+ }
348
+ return out;
349
+ }
350
+
351
+ /**
352
+ * One OpenAI stream chunk → the TEXT parts of one Gemini chunk (or null for
353
+ * frames carrying nothing representable). Tool-call fragments are NOT
354
+ * handled here — they buffer in {@link ToolCallBuffer} and emit whole on the
355
+ * finish chunk (lossy edge #2).
356
+ */
357
+ export function openAIChunkToParts(chunk: OpenAIStreamChunk): WirePart[] {
358
+ const choice = chunk.choices?.[0];
359
+ const parts: WirePart[] = [];
360
+ const reasoning = choice?.delta?.reasoning ?? choice?.delta?.reasoning_content;
361
+ if (reasoning) parts.push({ text: reasoning, thought: true });
362
+ if (choice?.delta?.content) parts.push({ text: choice.delta.content });
363
+ return parts;
364
+ }
365
+
366
+ /**
367
+ * Accumulates OpenAI streamed tool-call fragments (indexed deltas whose
368
+ * `arguments` arrive as string pieces) into whole Gemini functionCall parts.
369
+ */
370
+ export class ToolCallBuffer {
371
+ private readonly byIndex = new Map<number, { name: string; args: string }>();
372
+
373
+ add(deltas: Array<Partial<OpenAIToolCall> & { index: number }> | undefined): void {
374
+ for (const d of deltas ?? []) {
375
+ const slot = this.byIndex.get(d.index) ?? { name: '', args: '' };
376
+ if (d.function?.name) slot.name += d.function.name;
377
+ if (d.function?.arguments) slot.args += d.function.arguments;
378
+ this.byIndex.set(d.index, slot);
379
+ }
380
+ }
381
+
382
+ get size(): number {
383
+ return this.byIndex.size;
384
+ }
385
+
386
+ /** Emit whole functionCall parts (args parsed to an object) and reset. */
387
+ flush(): WirePart[] {
388
+ const parts: WirePart[] = [];
389
+ const indexes = [...this.byIndex.keys()].sort((a, b) => a - b);
390
+ for (const i of indexes) {
391
+ const { name, args } = this.byIndex.get(i)!;
392
+ let parsed: Record<string, unknown> = {};
393
+ try {
394
+ parsed = JSON.parse(args || '{}');
395
+ } catch {
396
+ parsed = { __malformed: args };
397
+ }
398
+ parts.push({ functionCall: { name, args: parsed } });
399
+ }
400
+ this.byIndex.clear();
401
+ return parts;
402
+ }
403
+ }
404
+
405
+ // ── SSE parsing (pure, incremental) ─────────────────────────────────────────
406
+
407
+ /**
408
+ * Incremental SSE `data:` payload extractor. Push decoded text as it
409
+ * arrives; get back complete event payload strings. Handles both LF LF and
410
+ * CRLF CRLF separators. Emits the `[DONE]` sentinel verbatim — CALLERS must
411
+ * skip it (lossy edge #3); the engine never forwards it downstream.
412
+ */
413
+ export class SseParser {
414
+ private buffer = '';
415
+
416
+ push(text: string): string[] {
417
+ this.buffer += text;
418
+ const payloads: string[] = [];
419
+ for (;;) {
420
+ const lf = this.buffer.indexOf('\n\n');
421
+ const crlf = this.buffer.indexOf('\r\n\r\n');
422
+ const at = lf === -1 ? crlf : crlf === -1 ? lf : Math.min(lf, crlf);
423
+ if (at === -1) break;
424
+ const sepLen = at === crlf && crlf !== -1 && (lf === -1 || crlf < lf) ? 4 : 2;
425
+ const rawEvent = this.buffer.slice(0, at);
426
+ this.buffer = this.buffer.slice(at + sepLen);
427
+ for (const line of rawEvent.split(/\r?\n/)) {
428
+ if (line.startsWith('data: ')) payloads.push(line.slice('data: '.length));
429
+ else if (line.startsWith('data:')) payloads.push(line.slice('data:'.length));
430
+ }
431
+ }
432
+ return payloads;
433
+ }
434
+ }
435
+
436
+ export const DONE_SENTINEL = '[DONE]';
437
+
438
+ // ── The engine ──────────────────────────────────────────────────────────────
439
+
440
+ export interface OpenAiEngineOptions {
441
+ baseUrl: string;
442
+ model?: string;
443
+ modelMap?: Record<string, string>;
444
+ fetch?: typeof fetch;
445
+ synthesizer?: Synthesizer;
446
+ }
447
+
448
+ export class OpenAiEngine implements AnswerEngine {
449
+ private readonly baseUrl: string;
450
+ private readonly defaultModel: string | undefined;
451
+ private readonly modelMap: Record<string, string>;
452
+ /** Call-signature only (not `typeof fetch`): the wrapper below carries none
453
+ * of the runtime's static props (e.g. Bun's `fetch.preconnect`). */
454
+ private readonly fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
455
+ private readonly synth: Synthesizer;
456
+
457
+ private warnedDroppedFields = false;
458
+
459
+ constructor(options: OpenAiEngineOptions) {
460
+ this.baseUrl = options.baseUrl.replace(/\/$/, '');
461
+ this.defaultModel = options.model;
462
+ this.modelMap = options.modelMap ?? {};
463
+ // Wrapped, not stored bare: `this.fetchImpl(...)` would otherwise invoke
464
+ // the global fetch with the engine as its receiver, an Illegal invocation
465
+ // in browser/worker contexts (Node tolerates it, browsers do not).
466
+ this.fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
467
+ this.synth = options.synthesizer ?? new Synthesizer();
468
+ }
469
+
470
+ /**
471
+ * `thinkingConfig` and `topK` are visible on the request HERE, before
472
+ * {@link geminiToOpenAIRequest} silently drops them (no OpenAI-compat
473
+ * equivalent for either). Fires once per engine instance; production still
474
+ * honors both fields — only the local openai engine does not.
475
+ */
476
+ private warnDroppedFieldsOnce(req: GenerateContentRequest): void {
477
+ if (this.warnedDroppedFields) return;
478
+ const g = req.generationConfig;
479
+ const hasThinking = g?.thinkingConfig !== undefined;
480
+ const hasTopK = g?.topK !== undefined;
481
+ if (!hasThinking && !hasTopK) return;
482
+ this.warnedDroppedFields = true;
483
+ const fields = [hasThinking && 'thinkingConfig', hasTopK && 'topK'].filter(Boolean).join(' and ');
484
+ console.warn(
485
+ `[pyric/ai] the openai engine drops ${fields} — no OpenAI-compatible equivalent on the upstream request.\n` +
486
+ `Production still honors ${fields}; switch to a real Gemini-backed engine if this must take effect in development.`,
487
+ );
488
+ }
489
+
490
+ /** Gemini model id → upstream model: modelMap[model] ?? config.model ?? passthrough. */
491
+ resolveUpstreamModel(model: string): string {
492
+ const bare = model.startsWith('models/') ? model.slice('models/'.length) : model;
493
+ return this.modelMap[bare] ?? this.modelMap[model] ?? this.defaultModel ?? bare;
494
+ }
495
+
496
+ async generateContent(req: GenerateContentRequest, model: string): Promise<WireResponse> {
497
+ this.warnDroppedFieldsOnce(req);
498
+ const body = geminiToOpenAIRequest(this.resolveUpstreamModel(model), req, false);
499
+ const res = await this.post(body);
500
+ const upstream = (await res.json()) as OpenAIResponse;
501
+ const translated = openAIToGeminiResponse(upstream);
502
+ return this.synth.decorate(translated, { model, promptText: promptTextOf(req) });
503
+ }
504
+
505
+ streamGenerateContent(req: GenerateContentRequest, model: string): AsyncIterable<WireChunk> {
506
+ this.warnDroppedFieldsOnce(req);
507
+ const body = geminiToOpenAIRequest(this.resolveUpstreamModel(model), req, true);
508
+ const opts = { model, promptText: promptTextOf(req) };
509
+ const synth = this.synth;
510
+ const post = () => this.post(body);
511
+
512
+ return (async function* stream(): AsyncGenerator<WireChunk> {
513
+ const res = await post();
514
+ if (!res.body) throw new AiBrokerError(errorEnvelope(502, 'upstream returned no body', 'UNAVAILABLE'));
515
+
516
+ const modelVersion = resolveModelVersion(model);
517
+ const responseId = synth.nextResponseId();
518
+ const promptTokenCount = estimateTokens(opts.promptText);
519
+ const toolBuffer = new ToolCallBuffer();
520
+ const parser = new SseParser();
521
+ const decoder = new TextDecoder();
522
+ let emittedText = '';
523
+ let upstreamUsage: OpenAIStreamChunk['usage'];
524
+ let pendingFinish: string | null = null;
525
+
526
+ /**
527
+ * Frame one complete Gemini chunk. Wire-true semantics require
528
+ * usageMetadata on EVERY chunk, but OpenAI upstreams only send usage on
529
+ * the final frame (stream_options.include_usage) — so interim chunks
530
+ * carry synthesized estimates (by-design synthesized decoration,
531
+ * cdd-deltas #99.2) and the finish chunk uses real upstream numbers
532
+ * when they arrived.
533
+ */
534
+ function frame(parts: WirePart[], finishReason?: string): WireChunk {
535
+ const candidatesTokenCount = estimateTokens(emittedText);
536
+ const usage = upstreamUsage
537
+ ? {
538
+ promptTokenCount: upstreamUsage.prompt_tokens,
539
+ candidatesTokenCount: upstreamUsage.completion_tokens,
540
+ totalTokenCount: upstreamUsage.total_tokens,
541
+ }
542
+ : {
543
+ promptTokenCount,
544
+ candidatesTokenCount,
545
+ totalTokenCount: promptTokenCount + candidatesTokenCount,
546
+ };
547
+ return {
548
+ candidates: [
549
+ {
550
+ content: { parts: parts.length ? parts : [{ text: '' }], role: 'model' },
551
+ ...(finishReason ? { finishReason } : {}),
552
+ index: 0,
553
+ },
554
+ ],
555
+ usageMetadata: {
556
+ ...usage,
557
+ promptTokensDetails: [{ modality: 'TEXT', tokenCount: usage.promptTokenCount }],
558
+ serviceTier: 'standard',
559
+ },
560
+ modelVersion,
561
+ responseId,
562
+ };
563
+ }
564
+
565
+ const reader = res.body.getReader();
566
+ try {
567
+ for (;;) {
568
+ const { done, value } = await reader.read();
569
+ const payloads = done
570
+ ? parser.push('\n\n') // flush any trailing buffered event
571
+ : parser.push(decoder.decode(value, { stream: true }));
572
+
573
+ for (const payload of payloads) {
574
+ // LOSSY EDGE: never forward the [DONE] sentinel.
575
+ if (payload.trim() === DONE_SENTINEL) continue;
576
+ let chunk: OpenAIStreamChunk;
577
+ try {
578
+ chunk = JSON.parse(payload) as OpenAIStreamChunk;
579
+ } catch {
580
+ continue; // non-JSON keepalive/comment frames
581
+ }
582
+ if (chunk.usage) upstreamUsage = chunk.usage;
583
+ const choice = chunk.choices?.[0];
584
+ if (!choice) continue; // usage-only final frame folds into the finish chunk
585
+
586
+ toolBuffer.add(choice.delta?.tool_calls);
587
+ const parts = openAIChunkToParts(chunk);
588
+ emittedText += parts.map((p) => (p.thought ? '' : (p.text ?? ''))).join('');
589
+
590
+ if (choice.finish_reason != null) {
591
+ pendingFinish = choice.finish_reason;
592
+ // Hold the finish frame until the reader drains, so a trailing
593
+ // usage-only frame (stream_options.include_usage) lands in it.
594
+ if (parts.length) yield frame(parts);
595
+ continue;
596
+ }
597
+ if (parts.length) yield frame(parts);
598
+ }
599
+
600
+ if (done) break;
601
+ }
602
+ } finally {
603
+ reader.releaseLock();
604
+ }
605
+
606
+ // The stream always ends with ONE finishing chunk (mirrors the captured
607
+ // final frame: empty text part, signed, finishReason present).
608
+ // LOSSY EDGE: buffered tool-call fragments emit here as WHOLE
609
+ // functionCall parts, exactly once.
610
+ const flushed = toolBuffer.flush();
611
+ const finalParts = synth.signParts(flushed.length ? flushed : [{ text: '' }]);
612
+ yield frame(finalParts, mapFinishReason(pendingFinish));
613
+ })();
614
+ }
615
+
616
+ /** No OpenAI equivalent — deterministic synthesized estimate, wire-true envelope. */
617
+ async countTokens(req: CountTokensRequest, _model: string): Promise<CountTokensResponse> {
618
+ return this.synth.countTokens(promptTextOf(req));
619
+ }
620
+
621
+ private async post(body: OpenAIRequest): Promise<Response> {
622
+ let res: Response;
623
+ try {
624
+ res = await this.fetchImpl(`${this.baseUrl}/chat/completions`, {
625
+ method: 'POST',
626
+ headers: { 'content-type': 'application/json' },
627
+ body: JSON.stringify(body),
628
+ });
629
+ } catch (err) {
630
+ throw new AiBrokerError(
631
+ errorEnvelope(502, `openai engine: upstream fetch failed: ${String(err)}`, 'UNAVAILABLE'),
632
+ );
633
+ }
634
+ if (!res.ok) {
635
+ const text = await res.text().catch(() => '');
636
+ throw new AiBrokerError(
637
+ errorEnvelope(
638
+ res.status,
639
+ `openai engine: upstream ${res.status}: ${text.slice(0, 500)}`,
640
+ res.status === 404 ? 'NOT_FOUND' : 'INVALID_ARGUMENT',
641
+ ),
642
+ );
643
+ }
644
+ return res;
645
+ }
646
+ }