@cartanova/qgrid-cli 2.10.9 → 2.11.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 (318) hide show
  1. package/bundle/dist/_virtual/_rolldown/runtime.js +1 -10
  2. package/bundle/dist/application/monit/log-buffer.js +12 -6
  3. package/bundle/dist/application/monit/monit.frame.js +11 -3
  4. package/bundle/dist/application/monit/monit.types.js +1 -1
  5. package/bundle/dist/application/qgrid/conv-routing.js +9 -6
  6. package/bundle/dist/application/qgrid/expired-token-reminder.js +2 -2
  7. package/bundle/dist/application/qgrid/oauth.js +31 -4
  8. package/bundle/dist/application/qgrid/qgrid-image-generation.js +1 -1
  9. package/bundle/dist/application/qgrid/qgrid-response-format.js +22 -20
  10. package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +43 -34
  11. package/bundle/dist/application/qgrid/qgrid.dispatcher.js +128 -93
  12. package/bundle/dist/application/qgrid/qgrid.frame.js +930 -49
  13. package/bundle/dist/application/qgrid/qgrid.types.js +212 -5
  14. package/bundle/dist/application/qgrid/schema-prompt.js +75 -48
  15. package/bundle/dist/application/qgrid/slack-user-map.js +1 -1
  16. package/bundle/dist/application/qgrid/token-death.js +1 -1
  17. package/bundle/dist/application/qgrid/token-subscriber.js +83 -30
  18. package/bundle/dist/application/qgrid/token-trigger-setup.js +1 -1
  19. package/bundle/dist/application/qgrid/token-window-keepalive.js +8 -6
  20. package/bundle/dist/application/qgrid/tool-emulation-schema.js +13 -13
  21. package/bundle/dist/application/qgrid/tool-emulation.js +19 -15
  22. package/bundle/dist/application/request-log/json-schema-type-text.js +29 -12
  23. package/bundle/dist/application/request-log/request-log.model.js +83 -69
  24. package/bundle/dist/application/request-log/request-log.types.js +8 -2
  25. package/bundle/dist/application/request-log/zod-code-format.js +1 -1
  26. package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
  27. package/bundle/dist/application/request-log-step/request-log-step.types.js +1 -1
  28. package/bundle/dist/application/setting/setting.constant.js +1 -1
  29. package/bundle/dist/application/setting/setting.model.js +2 -2
  30. package/bundle/dist/application/setting/setting.store.js +2 -2
  31. package/bundle/dist/application/setting/setting.types.js +1 -1
  32. package/bundle/dist/application/sonamu.generated.js +40 -16
  33. package/bundle/dist/application/sonamu.generated.sso.js +2 -1
  34. package/bundle/dist/application/token/token.model.js +26 -9
  35. package/bundle/dist/application/token/token.types.js +20 -3
  36. package/bundle/dist/i18n/en.js +15 -1
  37. package/bundle/dist/i18n/ko.js +15 -1
  38. package/bundle/dist/i18n/sd.generated.js +2 -1
  39. package/bundle/dist/migrations/20260807133553_manual__backfill_request_log_cost.js +8 -6
  40. package/bundle/dist/migrations/20261018215031_alter_request_logs_add1_alter1.js +15 -0
  41. package/bundle/dist/server-bootstrap.js +4 -2
  42. package/bundle/dist/server-error-handler.js +1 -1
  43. package/bundle/dist/sonamu.config.js +37 -8
  44. package/bundle/dist/startup-migrations.js +17 -7
  45. package/bundle/dist/testing/global.js +1 -1
  46. package/bundle/dist/testing/schema-bench.js +0 -0
  47. package/bundle/dist/testing/test-database-name.js +1 -1
  48. package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +1 -1
  49. package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +11 -5
  50. package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +4 -4
  51. package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +1 -1
  52. package/bundle/dist/utils/providers/anthropic/claude-session.js +20 -19
  53. package/bundle/dist/utils/providers/anthropic/fence-strip.js +1 -1
  54. package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +49 -23
  55. package/bundle/dist/utils/providers/common/credentials.js +5 -1
  56. package/bundle/dist/utils/providers/common/effort.js +3 -3
  57. package/bundle/dist/utils/providers/common/model-cost.js +69 -14
  58. package/bundle/dist/utils/providers/common/provider-types.js +29 -1
  59. package/bundle/dist/utils/providers/common/schema-validation.js +78 -40
  60. package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +1 -1
  61. package/bundle/dist/utils/providers/common/strictifier.js +62 -43
  62. package/bundle/dist/utils/providers/common/thinking.js +3 -2
  63. package/bundle/dist/utils/providers/common/ttft.js +1 -1
  64. package/bundle/dist/utils/providers/grok/grok-backend-protocol.js +427 -0
  65. package/bundle/dist/utils/providers/grok/grok-constants.js +55 -0
  66. package/bundle/dist/utils/providers/grok/grok-direct-client.js +196 -0
  67. package/bundle/dist/utils/providers/grok/grok-dispatcher.js +1313 -0
  68. package/bundle/dist/utils/providers/grok/grok-headers.js +118 -0
  69. package/bundle/dist/utils/providers/grok/grok-media-client.js +420 -0
  70. package/bundle/dist/utils/providers/grok/grok-models.js +114 -0
  71. package/bundle/dist/utils/providers/grok/grok-oauth.js +455 -0
  72. package/bundle/dist/utils/providers/grok/grok-quota.js +119 -0
  73. package/bundle/dist/utils/providers/grok/grok-refresh.js +192 -0
  74. package/bundle/dist/utils/providers/grok/grok-types.js +186 -0
  75. package/bundle/dist/utils/providers/grok/grok-voice-client.js +199 -0
  76. package/bundle/dist/utils/providers/grok/index.js +11 -0
  77. package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +111 -83
  78. package/bundle/dist/utils/providers/openai/openai-callback-relay.js +3 -2
  79. package/bundle/dist/utils/providers/openai/openai-constants.js +1 -1
  80. package/bundle/dist/utils/providers/openai/openai-direct-client.js +23 -7
  81. package/bundle/dist/utils/providers/openai/openai-dispatcher.js +143 -106
  82. package/bundle/dist/utils/providers/openai/openai-image-generation.js +48 -33
  83. package/bundle/dist/utils/providers/openai/openai-oauth.js +28 -13
  84. package/bundle/dist/utils/providers/openai/openai-quota.js +38 -27
  85. package/bundle/dist/utils/providers/openai/openai-refresh.js +17 -9
  86. package/bundle/dist/utils/providers/openai/openai-sse.js +1 -1
  87. package/bundle/dist/utils/providers/openai/openai-transport-config.js +1 -1
  88. package/bundle/dist/utils/quiet-hours.js +1 -1
  89. package/bundle/dist/utils/slack-notify.js +14 -8
  90. package/bundle/dist/utils/subset-loaders.js +4 -5
  91. package/bundle/src/application/monit/log-buffer.ts +23 -9
  92. package/bundle/src/application/monit/monit.frame.ts +22 -5
  93. package/bundle/src/application/monit/monit.types.ts +6 -0
  94. package/bundle/src/application/qgrid/conv-routing.test.ts +76 -4
  95. package/bundle/src/application/qgrid/conv-routing.ts +35 -9
  96. package/bundle/src/application/qgrid/expired-token-reminder.ts +9 -1
  97. package/bundle/src/application/qgrid/oauth.ts +51 -15
  98. package/bundle/src/application/qgrid/qgrid-image-generation.ts +9 -0
  99. package/bundle/src/application/qgrid/qgrid-response-format.ts +50 -25
  100. package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +33 -0
  101. package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +87 -40
  102. package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +97 -0
  103. package/bundle/src/application/qgrid/qgrid.dispatcher.ts +274 -126
  104. package/bundle/src/application/qgrid/qgrid.frame.test.ts +1013 -3
  105. package/bundle/src/application/qgrid/qgrid.frame.ts +1415 -60
  106. package/bundle/src/application/qgrid/qgrid.types.ts +335 -0
  107. package/bundle/src/application/qgrid/schema-prompt.ts +105 -41
  108. package/bundle/src/application/qgrid/slack-user-map.ts +5 -0
  109. package/bundle/src/application/qgrid/token-death.ts +4 -0
  110. package/bundle/src/application/qgrid/token-subscriber.test.ts +21 -19
  111. package/bundle/src/application/qgrid/token-subscriber.ts +186 -53
  112. package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -0
  113. package/bundle/src/application/qgrid/token-window-keepalive.ts +49 -6
  114. package/bundle/src/application/qgrid/tool-emulation-schema.ts +33 -29
  115. package/bundle/src/application/qgrid/tool-emulation.ts +52 -27
  116. package/bundle/src/application/request-log/json-schema-type-text.test.ts +5 -5
  117. package/bundle/src/application/request-log/json-schema-type-text.ts +81 -26
  118. package/bundle/src/application/request-log/request-log.entity.json +8 -0
  119. package/bundle/src/application/request-log/request-log.model.test.ts +111 -4
  120. package/bundle/src/application/request-log/request-log.model.ts +279 -145
  121. package/bundle/src/application/request-log/request-log.types.ts +17 -0
  122. package/bundle/src/application/request-log/zod-code-format.ts +11 -0
  123. package/bundle/src/application/request-log-step/request-log-step.model.ts +1 -0
  124. package/bundle/src/application/request-log-step/request-log-step.types.ts +2 -0
  125. package/bundle/src/application/setting/setting.constant.ts +9 -0
  126. package/bundle/src/application/setting/setting.model.ts +12 -2
  127. package/bundle/src/application/setting/setting.store.ts +5 -1
  128. package/bundle/src/application/setting/setting.types.ts +8 -0
  129. package/bundle/src/application/sonamu.generated.http +135 -0
  130. package/bundle/src/application/sonamu.generated.sso.ts +3 -0
  131. package/bundle/src/application/sonamu.generated.ts +29 -0
  132. package/bundle/src/application/token/token.identity.test.ts +20 -0
  133. package/bundle/src/application/token/token.model.ts +66 -7
  134. package/bundle/src/application/token/token.types.ts +24 -1
  135. package/bundle/src/i18n/en.ts +19 -0
  136. package/bundle/src/i18n/ko.ts +21 -0
  137. package/bundle/src/i18n/sd.generated.ts +24 -2
  138. package/bundle/src/migrations/20260807133553_manual__backfill_request_log_cost.ts +16 -8
  139. package/bundle/src/migrations/20261018215031_alter_request_logs_add1_alter1.ts +15 -0
  140. package/bundle/src/server-bootstrap.ts +4 -2
  141. package/bundle/src/server-error-handler.ts +1 -0
  142. package/bundle/src/sonamu.config.ts +69 -7
  143. package/bundle/src/startup-migrations.ts +27 -9
  144. package/bundle/src/testing/global.ts +20 -0
  145. package/bundle/src/testing/schema-bench.ts +105 -30
  146. package/bundle/src/testing/test-database-name.ts +2 -0
  147. package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +8 -0
  148. package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +41 -6
  149. package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +7 -3
  150. package/bundle/src/utils/providers/anthropic/anthropic-thinking.ts +6 -0
  151. package/bundle/src/utils/providers/anthropic/claude-session.ts +58 -24
  152. package/bundle/src/utils/providers/anthropic/fence-strip.ts +11 -0
  153. package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +110 -45
  154. package/bundle/src/utils/providers/common/credentials.ts +8 -0
  155. package/bundle/src/utils/providers/common/effort.ts +9 -2
  156. package/bundle/src/utils/providers/common/model-cost.test.ts +48 -1
  157. package/bundle/src/utils/providers/common/model-cost.ts +259 -158
  158. package/bundle/src/utils/providers/common/provider-dispatcher.ts +98 -2
  159. package/bundle/src/utils/providers/common/provider-types.ts +46 -3
  160. package/bundle/src/utils/providers/common/schema-validation.test.ts +5 -1
  161. package/bundle/src/utils/providers/common/schema-validation.ts +124 -56
  162. package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +4 -0
  163. package/bundle/src/utils/providers/common/strictifier.ts +185 -59
  164. package/bundle/src/utils/providers/common/thinking.ts +2 -2
  165. package/bundle/src/utils/providers/common/ttft.ts +4 -2
  166. package/bundle/src/utils/providers/grok/__fixtures__/golden-responses-1.0.60.json +164 -0
  167. package/bundle/src/utils/providers/grok/grok-backend-protocol.test.ts +325 -0
  168. package/bundle/src/utils/providers/grok/grok-backend-protocol.ts +721 -0
  169. package/bundle/src/utils/providers/grok/grok-constants.ts +105 -0
  170. package/bundle/src/utils/providers/grok/grok-direct-client.test.ts +179 -0
  171. package/bundle/src/utils/providers/grok/grok-direct-client.ts +313 -0
  172. package/bundle/src/utils/providers/grok/grok-dispatcher.test.ts +1395 -0
  173. package/bundle/src/utils/providers/grok/grok-dispatcher.ts +2105 -0
  174. package/bundle/src/utils/providers/grok/grok-headers.test.ts +149 -0
  175. package/bundle/src/utils/providers/grok/grok-headers.ts +199 -0
  176. package/bundle/src/utils/providers/grok/grok-media-client.test.ts +446 -0
  177. package/bundle/src/utils/providers/grok/grok-media-client.ts +703 -0
  178. package/bundle/src/utils/providers/grok/grok-models.test.ts +96 -0
  179. package/bundle/src/utils/providers/grok/grok-models.ts +161 -0
  180. package/bundle/src/utils/providers/grok/grok-oauth.test.ts +406 -0
  181. package/bundle/src/utils/providers/grok/grok-oauth.ts +771 -0
  182. package/bundle/src/utils/providers/grok/grok-quota.test.ts +142 -0
  183. package/bundle/src/utils/providers/grok/grok-quota.ts +198 -0
  184. package/bundle/src/utils/providers/grok/grok-refresh.test.ts +239 -0
  185. package/bundle/src/utils/providers/grok/grok-refresh.ts +325 -0
  186. package/bundle/src/utils/providers/grok/grok-types.test.ts +252 -0
  187. package/bundle/src/utils/providers/grok/grok-types.ts +374 -0
  188. package/bundle/src/utils/providers/grok/grok-voice-client.test.ts +321 -0
  189. package/bundle/src/utils/providers/grok/grok-voice-client.ts +284 -0
  190. package/bundle/src/utils/providers/grok/index.ts +20 -0
  191. package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +221 -137
  192. package/bundle/src/utils/providers/openai/openai-callback-relay.ts +12 -1
  193. package/bundle/src/utils/providers/openai/openai-constants.ts +2 -0
  194. package/bundle/src/utils/providers/openai/openai-direct-client.ts +111 -21
  195. package/bundle/src/utils/providers/openai/openai-dispatcher.ts +322 -169
  196. package/bundle/src/utils/providers/openai/openai-image-generation.ts +116 -36
  197. package/bundle/src/utils/providers/openai/openai-oauth.ts +48 -20
  198. package/bundle/src/utils/providers/openai/openai-quota.ts +77 -46
  199. package/bundle/src/utils/providers/openai/openai-refresh.ts +42 -18
  200. package/bundle/src/utils/providers/openai/openai-sse.ts +27 -3
  201. package/bundle/src/utils/providers/openai/openai-transport-config.ts +2 -0
  202. package/bundle/src/utils/quiet-hours.ts +12 -1
  203. package/bundle/src/utils/slack-notify.ts +44 -13
  204. package/bundle/src/utils/subset-loaders.ts +7 -7
  205. package/bundle/web-dist/client/assets/clsx-5XM_pHvN.js +1 -0
  206. package/bundle/web-dist/client/assets/index-BA7uQ-6c.js +31 -0
  207. package/bundle/web-dist/client/assets/index-Be2qCZmI.css +1 -0
  208. package/bundle/web-dist/client/assets/logs-C47OJGEI.js +1 -0
  209. package/bundle/web-dist/client/assets/monit-BS9no0dU.js +4 -0
  210. package/bundle/web-dist/client/assets/plus-sdACTfH0.js +1 -0
  211. package/bundle/web-dist/client/assets/refresh-cw-CY8o_1cT.js +1 -0
  212. package/bundle/web-dist/client/assets/request-model-BRtFuZvj.js +1 -0
  213. package/bundle/web-dist/client/assets/routes-kqgkUxE8.js +5 -0
  214. package/bundle/web-dist/client/assets/{services.generated-BsieU37H.js → services.generated-CefiDXEv.js} +5 -5
  215. package/bundle/web-dist/client/assets/settings-DNDvFGSw.js +1 -0
  216. package/bundle/web-dist/client/assets/show-u8Np69q3.js +2 -0
  217. package/bundle/web-dist/client/assets/tokens-QvP410NW.js +1 -0
  218. package/bundle/web-dist/client/assets/total-cost-MnfKU8IN.js +1 -0
  219. package/bundle/web-dist/client/assets/use-oauth-login-flow-Bzdh28Ws.js +1 -0
  220. package/bundle/web-dist/client/assets/use-update-token-mutation-CfJKaxmR.js +1 -0
  221. package/bundle/web-dist/client/assets/vendor-react-CUFFrVN9.js +244 -0
  222. package/bundle/web-dist/client/assets/vendor-tanstack-Bf_jag9G.js +1 -0
  223. package/bundle/web-dist/client/index.html +6 -6
  224. package/bundle/web-dist/server/assets/clsx-D_N5PN_2.js +16 -0
  225. package/bundle/web-dist/server/assets/{lazyRouteComponent-Dtd0NR4r.js → lazyRouteComponent-D8iTIHyn.js} +129 -2
  226. package/bundle/web-dist/server/assets/logs-B3OTd9hy.js +825 -0
  227. package/bundle/web-dist/server/assets/{monit-DvzribSE.js → monit-B2hqorPe.js} +385 -126
  228. package/bundle/web-dist/server/assets/plus-DwG7h9kq.js +28 -0
  229. package/bundle/web-dist/server/assets/{react-dom-B4a6-uuR.js → react-dom-BIYQzBqG.js} +2 -129
  230. package/bundle/web-dist/server/assets/refresh-cw-mMBkwOq0.js +44 -0
  231. package/bundle/web-dist/server/assets/{request-model-CbqfnQOJ.js → request-model-I6OdODSH.js} +15 -6
  232. package/bundle/web-dist/server/assets/{routes-BAPWTizT.js → routes-B95YlUv2.js} +760 -213
  233. package/bundle/web-dist/server/assets/{services.generated-CEbGjsI0.js → services.generated-DQATJ6qf.js} +12821 -12194
  234. package/bundle/web-dist/server/assets/settings-C3gbGk0m.js +830 -0
  235. package/bundle/web-dist/server/assets/{show-CxVZTAdv.js → show-Bxaj3rhH.js} +1355 -419
  236. package/bundle/web-dist/server/assets/tokens-CZjH3cB7.js +1038 -0
  237. package/bundle/web-dist/server/assets/total-cost-fTE9Xy82.js +18 -0
  238. package/bundle/web-dist/server/assets/use-oauth-login-flow-CKDHgc3U.js +2379 -0
  239. package/bundle/web-dist/server/assets/{use-update-token-mutation-CfYmk_4_.js → use-update-token-mutation-BEbgOzaa.js} +1 -1
  240. package/bundle/web-dist/server/entry-server.generated.js +4303 -22810
  241. package/dist/cli.js +9 -9
  242. package/package.json +4 -11
  243. package/postinstall.mjs +12 -0
  244. package/skills/qgrid/SKILL.md +1 -0
  245. package/skills/qgrid/references/cli-env-and-server-boot.md +4 -1
  246. package/skills/qgrid/references/decision-rationale.md +24 -1
  247. package/skills/qgrid/references/grok-runtime.md +89 -0
  248. package/skills/qgrid/references/provider-runtime-differences.md +30 -29
  249. package/skills/qgrid/references/repo-map.md +10 -0
  250. package/skills/qgrid/references/request-log-run-lifecycle.md +1 -1
  251. package/skills/qgrid/references/sonamu-api-web-flow.md +1 -1
  252. package/skills/qgrid/references/token-auth-quota-lifecycle.md +56 -3
  253. package/bundle/dist/node_modules/.pnpm/@jridgewell_sourcemap-codec@1.5.5/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.js +0 -406
  254. package/bundle/dist/node_modules/.pnpm/@vitest_expect@4.1.10/node_modules/@vitest/expect/dist/index.js +0 -1507
  255. package/bundle/dist/node_modules/.pnpm/@vitest_pretty-format@4.1.10/node_modules/@vitest/pretty-format/dist/index.js +0 -887
  256. package/bundle/dist/node_modules/.pnpm/@vitest_runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js +0 -2756
  257. package/bundle/dist/node_modules/.pnpm/@vitest_runner@4.1.10/node_modules/@vitest/runner/dist/index.js +0 -8
  258. package/bundle/dist/node_modules/.pnpm/@vitest_runner@4.1.10/node_modules/@vitest/runner/dist/utils.js +0 -8
  259. package/bundle/dist/node_modules/.pnpm/@vitest_snapshot@4.1.10/node_modules/@vitest/snapshot/dist/index.js +0 -922
  260. package/bundle/dist/node_modules/.pnpm/@vitest_spy@4.1.10/node_modules/@vitest/spy/dist/index.js +0 -386
  261. package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/chunk-pathe.M-eThtNZ.js +0 -121
  262. package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/constants.js +0 -51
  263. package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/diff.js +0 -1317
  264. package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/display.js +0 -561
  265. package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/error.js +0 -40
  266. package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/helpers.js +0 -272
  267. package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/offset.js +0 -27
  268. package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/serialize.js +0 -77
  269. package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/source-map.js +0 -381
  270. package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/timers.js +0 -51
  271. package/bundle/dist/node_modules/.pnpm/chai@6.2.2/node_modules/chai/index.js +0 -2986
  272. package/bundle/dist/node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/branding.js +0 -10
  273. package/bundle/dist/node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/index.js +0 -109
  274. package/bundle/dist/node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/messages.js +0 -10
  275. package/bundle/dist/node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/overloads.js +0 -10
  276. package/bundle/dist/node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/utils.js +0 -10
  277. package/bundle/dist/node_modules/.pnpm/magic-string@0.30.21/node_modules/magic-string/dist/magic-string.es.js +0 -1126
  278. package/bundle/dist/node_modules/.pnpm/pathe@2.0.3/node_modules/pathe/dist/index.js +0 -21
  279. package/bundle/dist/node_modules/.pnpm/pathe@2.0.3/node_modules/pathe/dist/shared/pathe.M-eThtNZ.js +0 -432
  280. package/bundle/dist/node_modules/.pnpm/tinybench@2.9.0/node_modules/tinybench/dist/index.js +0 -517
  281. package/bundle/dist/node_modules/.pnpm/tinyrainbow@3.1.0/node_modules/tinyrainbow/dist/index.js +0 -101
  282. package/bundle/dist/node_modules/.pnpm/vite@8.0.3_@emnapi_core@1.11.2_@emnapi_runtime@1.11.2_@types_node@25.0.7_esbuild@0.27.4_5b35fc1fb85d2af4d105e0d74f940f50/node_modules/vite/dist/node/module-runner.js +0 -1265
  283. package/bundle/dist/node_modules/.pnpm/vitest@4.1.10_@opentelemetry_api@1.9.0_@types_node@25.0.7_@vitest_coverage-v8@4.1.10_vi_a1977b93af7bb69308bcb1dbab39b618/node_modules/vitest/dist/chunks/_commonjsHelpers.D26ty3Ew.js +0 -9
  284. package/bundle/dist/node_modules/.pnpm/vitest@4.1.10_@opentelemetry_api@1.9.0_@types_node@25.0.7_@vitest_coverage-v8@4.1.10_vi_a1977b93af7bb69308bcb1dbab39b618/node_modules/vitest/dist/chunks/benchmark.CX_oY03V.js +0 -41
  285. package/bundle/dist/node_modules/.pnpm/vitest@4.1.10_@opentelemetry_api@1.9.0_@types_node@25.0.7_@vitest_coverage-v8@4.1.10_vi_a1977b93af7bb69308bcb1dbab39b618/node_modules/vitest/dist/chunks/evaluatedModules.Dg1zASAC.js +0 -19
  286. package/bundle/dist/node_modules/.pnpm/vitest@4.1.10_@opentelemetry_api@1.9.0_@types_node@25.0.7_@vitest_coverage-v8@4.1.10_vi_a1977b93af7bb69308bcb1dbab39b618/node_modules/vitest/dist/chunks/index.Chj8NDwU.js +0 -202
  287. package/bundle/dist/node_modules/.pnpm/vitest@4.1.10_@opentelemetry_api@1.9.0_@types_node@25.0.7_@vitest_coverage-v8@4.1.10_vi_a1977b93af7bb69308bcb1dbab39b618/node_modules/vitest/dist/chunks/index.DdgEv5B1.js +0 -46
  288. package/bundle/dist/node_modules/.pnpm/vitest@4.1.10_@opentelemetry_api@1.9.0_@types_node@25.0.7_@vitest_coverage-v8@4.1.10_vi_a1977b93af7bb69308bcb1dbab39b618/node_modules/vitest/dist/chunks/rpc.MzXet3jl.js +0 -119
  289. package/bundle/dist/node_modules/.pnpm/vitest@4.1.10_@opentelemetry_api@1.9.0_@types_node@25.0.7_@vitest_coverage-v8@4.1.10_vi_a1977b93af7bb69308bcb1dbab39b618/node_modules/vitest/dist/chunks/test.DNmyFkvJ.js +0 -3044
  290. package/bundle/dist/node_modules/.pnpm/vitest@4.1.10_@opentelemetry_api@1.9.0_@types_node@25.0.7_@vitest_coverage-v8@4.1.10_vi_a1977b93af7bb69308bcb1dbab39b618/node_modules/vitest/dist/chunks/utils.BX5Fg8C4.js +0 -59
  291. package/bundle/dist/node_modules/.pnpm/vitest@4.1.10_@opentelemetry_api@1.9.0_@types_node@25.0.7_@vitest_coverage-v8@4.1.10_vi_a1977b93af7bb69308bcb1dbab39b618/node_modules/vitest/dist/index.js +0 -28
  292. package/bundle/dist/testing/setup-mocks.js +0 -16
  293. package/bundle/src/testing/setup-mocks.ts +0 -44
  294. package/bundle/web-dist/client/assets/index-Bw2s88Ot.css +0 -1
  295. package/bundle/web-dist/client/assets/index-CrXKu_J8.js +0 -106
  296. package/bundle/web-dist/client/assets/logs-B2SmWJXA.js +0 -1
  297. package/bundle/web-dist/client/assets/monit-D-JwXXoA.js +0 -1
  298. package/bundle/web-dist/client/assets/plus-B62CuqT8.js +0 -1
  299. package/bundle/web-dist/client/assets/refresh-cw-NNcU0Cmq.js +0 -1
  300. package/bundle/web-dist/client/assets/request-model-CojtinqK.js +0 -1
  301. package/bundle/web-dist/client/assets/routes-C5r4gTvY.js +0 -5
  302. package/bundle/web-dist/client/assets/settings-D3tDSAck.js +0 -1
  303. package/bundle/web-dist/client/assets/show-Cah_kUKC.js +0 -2
  304. package/bundle/web-dist/client/assets/tokens-DU2Iyxxd.js +0 -1
  305. package/bundle/web-dist/client/assets/use-oauth-login-flow-D0UaR9_e.js +0 -1
  306. package/bundle/web-dist/client/assets/use-update-token-mutation-DAq0F8p-.js +0 -1
  307. package/bundle/web-dist/client/assets/vendor-react-DH4kUfK2.js +0 -9
  308. package/bundle/web-dist/client/assets/vendor-tanstack-DJRQAbrY.js +0 -1
  309. package/bundle/web-dist/server/assets/logs-l91avYif.js +0 -509
  310. package/bundle/web-dist/server/assets/plus-KZrECewT.js +0 -19
  311. package/bundle/web-dist/server/assets/refresh-cw-BND93JrF.js +0 -23
  312. package/bundle/web-dist/server/assets/settings-BZR5HSAL.js +0 -461
  313. package/bundle/web-dist/server/assets/tokens-D1hb4eKk.js +0 -448
  314. package/bundle/web-dist/server/assets/use-oauth-login-flow-Cvdy0zF8.js +0 -178
  315. package/dist/cli.d.ts +0 -10
  316. /package/bundle/web-dist/client/assets/{cost-DH2jZbrw.js → cost-DB3HbU7T.js} +0 -0
  317. /package/bundle/web-dist/client/assets/{preload-helper-ca-nBW7U.js → preload-helper-DWTEM3RW.js} +0 -0
  318. /package/bundle/web-dist/server/assets/{cost-eEHeTRT5.js → cost-BC9yzyA9.js} +0 -0
@@ -2,16 +2,68 @@ import crypto from "node:crypto";
2
2
 
3
3
  import { getLogger } from "@logtape/logtape";
4
4
  import { type FastifyReply, type FastifyRequest } from "fastify";
5
- import { api, BadRequestException, BaseFrameClass, Sonamu, stream } from "sonamu";
5
+ import {
6
+ api,
7
+ BadRequestException,
8
+ BaseFrameClass,
9
+ InternalServerErrorException,
10
+ NotFoundException,
11
+ ServiceUnavailableException,
12
+ SoException,
13
+ Sonamu,
14
+ stream,
15
+ } from "sonamu";
6
16
  import { getCacheManagerRef } from "sonamu/cache";
17
+ import { z } from "zod";
7
18
 
8
- import { type LocalizedString } from "../../i18n/sd.generated";
19
+ import { type LocalizedString, SD } from "../../i18n/sd.generated";
9
20
  import {
10
21
  getAccessToken,
11
22
  getExpiresAt,
12
23
  getRefreshToken,
13
24
  } from "../../utils/providers/common/credentials";
25
+ import {
26
+ type GrokMediaCost,
27
+ grokImageCost,
28
+ grokReportedCost,
29
+ } from "../../utils/providers/common/model-cost";
30
+ import {
31
+ type MediaDispatcher,
32
+ type MediaTokenSelection,
33
+ type VoiceDispatcher,
34
+ } from "../../utils/providers/common/provider-dispatcher";
35
+ import { isProviderId } from "../../utils/providers/common/provider-types";
14
36
  import { CallerSchemaValidationError } from "../../utils/providers/common/schema-validation";
37
+ import {
38
+ GROK_IMAGE_DEFAULT_MODEL,
39
+ GROK_STT_DEFAULT_MODEL,
40
+ GROK_VIDEO_DEFAULT_MODEL,
41
+ GrokCapabilityUnavailableError,
42
+ GrokCapacityError,
43
+ GrokInvalidRequestError,
44
+ GrokNoWorkersError,
45
+ GrokOnDemandExcludedError,
46
+ GrokRateLimitedError,
47
+ GrokZdrVideoUnavailableError,
48
+ completeGrokOAuth,
49
+ GrokCredentials as StrictGrokCredentials,
50
+ GrokOAuthError,
51
+ type GrokRefreshCoordinator,
52
+ type GrokTokenStateSnapshot,
53
+ pollGrokDeviceCode,
54
+ startGrokDeviceCode,
55
+ startGrokOAuth,
56
+ } from "../../utils/providers/grok";
57
+ import {
58
+ decodeGrokImages,
59
+ GrokContentPolicyError,
60
+ summarizeGrokVideoStatus,
61
+ } from "../../utils/providers/grok/grok-media-client";
62
+ import {
63
+ GROK_TTS_DEFAULT_FORMAT,
64
+ GROK_TTS_DEFAULT_LANGUAGE,
65
+ GROK_TTS_DEFAULT_VOICE,
66
+ } from "../../utils/providers/grok/grok-voice-client";
15
67
  import {
16
68
  OPENAI_CALLBACK_PORTS,
17
69
  openAICallbackRedirectUri,
@@ -22,11 +74,20 @@ import {
22
74
  exchangeOpenAICode,
23
75
  generateOpenAIPKCE,
24
76
  } from "../../utils/providers/openai/openai-oauth";
25
- import { MICRO_USD, RequestLogModel } from "../request-log/request-log.model";
77
+ import {
78
+ type FinishRunParams,
79
+ MICRO_USD,
80
+ RequestLogModel,
81
+ type RequestLogStepFields,
82
+ } from "../request-log/request-log.model";
26
83
  import { type RequestLogListParams } from "../request-log/request-log.types";
27
84
  import { type TokenSubsetA } from "../sonamu.generated";
85
+
86
+ /** Fields token refresh reads; dispatchers pass their pooled view of a token. */
87
+ type RefreshableToken = Pick<TokenSubsetA, "id" | "name" | "provider" | "credentials">;
88
+
28
89
  import { TokenModel, type TokenUpdateFields } from "../token/token.model";
29
- import { TokenCredentials } from "../token/token.types";
90
+ import { GrokCredentials as StoredGrokCredentials, TokenCredentials } from "../token/token.types";
30
91
  import {
31
92
  CONSOLE_CALLBACK_URL,
32
93
  type AnthropicUsageRaw,
@@ -55,10 +116,24 @@ import {
55
116
  type AppendStepInput,
56
117
  type CreateRunInput,
57
118
  type FinishRunInput,
119
+ GenerateImagesInput,
120
+ type GenerateImagesOutput,
121
+ SpeakInput,
122
+ type SpeakOutput,
123
+ TranscribeInput,
124
+ type TranscribeOutput,
125
+ VideoStartInput,
126
+ type VideoStartOutput,
127
+ VideoStatusInput,
128
+ type VideoStatusOutput,
129
+ type GrokDeviceCodeStart,
130
+ type GrokDevicePollResult,
58
131
  type HealthResponse,
59
132
  type OAuthStartResult,
60
133
  type QueryOutput,
61
134
  type QgridRunContext,
135
+ type QgridThreadCoord,
136
+ REQUEST_BODY_LIMIT_BYTES,
62
137
  StreamEvents,
63
138
  type TokenStats,
64
139
  type UsageResponse,
@@ -68,22 +143,38 @@ import { ToolSchemaCompositionError } from "./tool-emulation-schema";
68
143
 
69
144
  const pendingStreams = new Map<string, QueryInput>();
70
145
 
71
- type PendingOAuth = {
72
- codeVerifier: string;
73
- name: string;
74
- redirectUri: string;
75
- provider: "anthropic" | "openai";
76
- };
146
+ const PendingOAuth = z.object({
147
+ codeVerifier: z.string(),
148
+ name: z.string(),
149
+ redirectUri: z.string(),
150
+ provider: z.enum(["anthropic", "openai", "grok"]),
151
+ });
152
+
153
+ type PendingOAuth = z.infer<typeof PendingOAuth>;
154
+
77
155
  const OAUTH_STATE_PREFIX = "oauth:state:";
78
156
  const OAUTH_STATE_TTL = "5m";
79
157
  const OAUTH_STATE_TTL_MS = 5 * 60_000;
80
158
 
159
+ // A bare pasted Grok code carries no state, so it binds to the newest pending Grok login.
160
+ const GROK_LATEST_STATE_KEY = "oauth:grok:latest";
161
+ const GROK_DEVICE_PREFIX = "oauth:grok:device:";
162
+
163
+ const PendingGrokDevice = z.object({
164
+ name: z.string(),
165
+ interval: z.number(),
166
+ expiresAtMs: z.number(),
167
+ });
168
+
169
+ type PendingGrokDevice = z.infer<typeof PendingGrokDevice>;
170
+
81
171
  function unixSecondsToIso(seconds: number | null | undefined): string | null {
82
- return typeof seconds === "number" ? new Date(seconds * 1000).toISOString() : null;
172
+ return seconds === null || seconds === undefined ? null : new Date(seconds * 1000).toISOString();
83
173
  }
84
174
 
85
175
  async function setOAuthState(state: string, data: PendingOAuth): Promise<void> {
86
176
  const cache = getCacheManagerRef();
177
+
87
178
  if (!cache) throw new Error("CacheManager not initialized");
88
179
  await cache.set({
89
180
  key: `${OAUTH_STATE_PREFIX}${state}`,
@@ -94,84 +185,189 @@ async function setOAuthState(state: string, data: PendingOAuth): Promise<void> {
94
185
 
95
186
  async function getOAuthState(state: string): Promise<PendingOAuth | undefined> {
96
187
  const cache = getCacheManagerRef();
188
+
97
189
  if (!cache) throw new Error("CacheManager not initialized");
98
190
  const raw = await cache.get<string>({ key: `${OAUTH_STATE_PREFIX}${state}` });
191
+
99
192
  if (!raw) return undefined;
100
- return JSON.parse(raw) as PendingOAuth;
193
+
194
+ return PendingOAuth.safeParse(JSON.parse(raw)).data;
101
195
  }
102
196
 
103
197
  async function deleteOAuthState(state: string): Promise<void> {
104
198
  const cache = getCacheManagerRef();
199
+
105
200
  if (!cache) return;
106
201
  await cache.delete({ key: `${OAUTH_STATE_PREFIX}${state}` });
107
202
  }
108
203
 
204
+ async function setGrokLatestState(state: string): Promise<void> {
205
+ const cache = getCacheManagerRef();
206
+
207
+ if (!cache) throw new Error("CacheManager not initialized");
208
+ await cache.set({ key: GROK_LATEST_STATE_KEY, value: state, ttl: OAUTH_STATE_TTL });
209
+ }
210
+
211
+ async function getGrokLatestState(): Promise<string | undefined> {
212
+ const cache = getCacheManagerRef();
213
+
214
+ if (!cache) throw new Error("CacheManager not initialized");
215
+
216
+ return (await cache.get<string>({ key: GROK_LATEST_STATE_KEY })) ?? undefined;
217
+ }
218
+
219
+ async function setGrokDeviceState(deviceCode: string, data: PendingGrokDevice): Promise<void> {
220
+ const cache = getCacheManagerRef();
221
+
222
+ if (!cache) throw new Error("CacheManager not initialized");
223
+ const ttlSec = Math.max(1, Math.ceil((data.expiresAtMs - Date.now()) / 1000));
224
+ await cache.set({
225
+ key: `${GROK_DEVICE_PREFIX}${deviceCode}`,
226
+ value: JSON.stringify(data),
227
+ ttl: `${ttlSec}s`,
228
+ });
229
+ }
230
+
231
+ async function getGrokDeviceState(deviceCode: string): Promise<PendingGrokDevice | undefined> {
232
+ const cache = getCacheManagerRef();
233
+
234
+ if (!cache) throw new Error("CacheManager not initialized");
235
+ const raw = await cache.get<string>({ key: `${GROK_DEVICE_PREFIX}${deviceCode}` });
236
+
237
+ if (!raw) return undefined;
238
+ const data = PendingGrokDevice.safeParse(JSON.parse(raw)).data;
239
+
240
+ return data && data.expiresAtMs > Date.now() ? data : undefined;
241
+ }
242
+
243
+ async function deleteGrokDeviceState(deviceCode: string): Promise<void> {
244
+ const cache = getCacheManagerRef();
245
+
246
+ if (!cache) return;
247
+ await cache.delete({ key: `${GROK_DEVICE_PREFIX}${deviceCode}` });
248
+ }
249
+
250
+ function grokOAuthBadRequest(cause: unknown): never {
251
+ if (cause instanceof GrokOAuthError) {
252
+ throw new BadRequestException(SD("qgrid.grokLoginFailed")(cause.message));
253
+ }
254
+
255
+ throw cause;
256
+ }
257
+
258
+ function grokTokenSnapshot(tokenId: number): GrokTokenStateSnapshot | undefined {
259
+ // Token-state reporting is optional on the routing port; the concrete GrokDispatcher has it.
260
+ return QgridDispatcher.grokDispatcher?.tokenStates?.().find((state) => state.id === tokenId);
261
+ }
262
+
263
+ /** Stores refreshed Grok credentials; the token subscriber pushes the row to the dispatcher. */
264
+ export async function persistGrokCredentials(
265
+ tokenId: number,
266
+ next: StrictGrokCredentials,
267
+ ): Promise<void> {
268
+ const credentials = StrictGrokCredentials.parse(next);
269
+ const row = await TokenModel.findById("A", tokenId);
270
+ await TokenModel.save([
271
+ {
272
+ id: row.id,
273
+ provider: row.provider,
274
+ credentials,
275
+ name: row.name,
276
+ reauth_required: false,
277
+ },
278
+ ]);
279
+ }
280
+
109
281
  const trustedLoopbackBase = () => `http://localhost:${process.env.PORT ?? "44900"}`;
110
282
 
111
283
  function isRemoteOAuthRequest(): boolean {
112
284
  try {
113
285
  const { headers } = Sonamu.getContext();
114
- const origin = typeof headers.origin === "string" ? headers.origin : undefined;
286
+ const origin = headers.origin;
115
287
  const forwarded = headers["x-forwarded-host"];
116
288
  const rawHost = Array.isArray(forwarded) ? forwarded[0] : (forwarded ?? headers.host);
289
+
117
290
  const host = origin
118
291
  ? new URL(origin).hostname
119
292
  : new URL(`http://${rawHost ?? "localhost"}`).hostname;
293
+
120
294
  return !new Set(["localhost", "127.0.0.1", "::1", "[::1]"]).has(host);
121
295
  } catch {}
296
+
122
297
  return false;
123
298
  }
124
299
 
125
300
  // Anthropic's remote/manual flow uses its fixed console callback. Headers may select between two
126
301
  // allowlisted URIs, but never contribute authority or path bytes to either URI.
127
- function resolveOAuthRedirect(): { redirectUri: string; mode: "redirect" | "code" } {
302
+ interface OAuthRedirect {
303
+ redirectUri: string;
304
+ mode: "redirect" | "code";
305
+ }
306
+
307
+ function resolveOAuthRedirect(): OAuthRedirect {
128
308
  if (isRemoteOAuthRequest()) {
129
309
  return { redirectUri: CONSOLE_CALLBACK_URL, mode: "code" };
130
310
  }
311
+
131
312
  return { redirectUri: `${trustedLoopbackBase()}/callback`, mode: "redirect" };
132
313
  }
133
314
 
134
315
  function parsePastedOAuthResult(input: string): { code: string; state: string } | null {
135
316
  const trimmed = input.trim();
317
+
136
318
  try {
137
319
  const callback = new URL(trimmed);
138
320
  const code = callback.searchParams.get("code")?.trim() ?? "";
139
321
  const state = callback.searchParams.get("state")?.trim() ?? "";
322
+
140
323
  if (code && state) return { code, state };
141
324
  } catch {}
142
325
 
143
326
  const separatorAt = trimmed.indexOf("#");
144
327
  const code = separatorAt > 0 ? trimmed.slice(0, separatorAt).trim() : "";
145
328
  const state = separatorAt > 0 ? trimmed.slice(separatorAt + 1).trim() : "";
329
+
146
330
  return code && state ? { code, state } : null;
147
331
  }
148
332
 
149
333
  function rejectImageGenerationStream(args: QueryInput): void {
150
334
  if (!args.imageGeneration && args.model !== `openai/${CODEX_IMAGE_GENERATION_MODEL}`) return;
151
335
  throw new BadRequestException(
152
- "qgrid: imageGeneration is not supported with streaming; use query or generateImage with qgrid.image() instead." as LocalizedString,
336
+ SD("error.detail")(
337
+ "qgrid: imageGeneration is not supported with streaming; use query or generateImage with qgrid.image() instead.",
338
+ ),
153
339
  );
154
340
  }
155
341
 
156
342
  function rejectInvalidImageQuery(args: QueryInput): void {
157
343
  const standalone = args.model === `openai/${CODEX_IMAGE_GENERATION_MODEL}`;
344
+
158
345
  if (!standalone && args.imageGeneration !== "auto") return;
346
+
159
347
  if (standalone && args.imageGeneration !== true) {
160
348
  throw new BadRequestException(
161
- "qgrid: the image model requires imageGeneration: true; use qgrid.image() with generateImage." as LocalizedString,
349
+ SD("error.detail")(
350
+ "qgrid: the image model requires imageGeneration: true; use qgrid.image() with generateImage.",
351
+ ),
162
352
  );
163
353
  }
354
+
164
355
  if (args.tools?.length || args.jsonSchema !== undefined) {
165
356
  throw new BadRequestException(
166
- "qgrid: automatic and standalone image generation do not support caller tools or structured output." as LocalizedString,
357
+ SD("error.detail")(
358
+ "qgrid: automatic and standalone image generation do not support caller tools or structured output.",
359
+ ),
167
360
  );
168
361
  }
362
+
169
363
  if (
170
364
  args.imageGenerationOptions?.outputFormat &&
171
365
  args.imageGenerationOptions.outputFormat !== "png"
172
366
  ) {
173
367
  throw new BadRequestException(
174
- "qgrid: automatic and standalone image generation only support PNG output." as LocalizedString,
368
+ SD("error.detail")(
369
+ "qgrid: automatic and standalone image generation only support PNG output.",
370
+ ),
175
371
  );
176
372
  }
177
373
  }
@@ -188,15 +384,17 @@ function rejectInvalidCallerSchemas(args: QueryInput): void {
188
384
  error instanceof CallerSchemaValidationError ||
189
385
  error instanceof ToolSchemaCompositionError
190
386
  ) {
191
- throw new BadRequestException(error.message as LocalizedString);
387
+ throw new BadRequestException(SD("error.detail")(error.message));
192
388
  }
389
+
193
390
  // 잘못된 keyword 형태(anyOf 를 객체로 보내는 등)는 파이프라인에서 untyped 오류로 나오므로,
194
391
  // 타입 좁히기 대신 Error 전체를 caller-fault 400 으로 매핑한다(계약 테스트로 고정됨).
195
392
  if (error instanceof Error) {
196
393
  throw new BadRequestException(
197
- `qgrid: caller schema cannot be normalized: ${error.message}` as LocalizedString,
394
+ SD("error.detail")(`qgrid: caller schema cannot be normalized: ${error.message}`),
198
395
  );
199
396
  }
397
+
200
398
  throw error;
201
399
  }
202
400
  }
@@ -206,26 +404,32 @@ async function resolveTokenName(args: QueryInput): Promise<InternalQueryInput> {
206
404
 
207
405
  const tokenProvider = args.tokenName.split("/", 1)[0];
208
406
  const modelProvider = args.model?.split("/", 1)[0];
209
- if (tokenProvider !== "anthropic" && tokenProvider !== "openai") {
210
- throw new BadRequestException(`Invalid token name: ${args.tokenName}` as LocalizedString);
407
+
408
+ if (!isProviderId(tokenProvider)) {
409
+ throw new BadRequestException(SD("error.detail")(`Invalid token name: ${args.tokenName}`));
211
410
  }
411
+
212
412
  if (modelProvider !== tokenProvider) {
213
413
  throw new BadRequestException(
214
- "Token provider does not match model provider" as LocalizedString,
414
+ SD("error.detail")("Token provider does not match model provider"),
215
415
  );
216
416
  }
217
417
 
218
418
  const token = await TokenModel.findActiveByProviderAndName("A", tokenProvider, args.tokenName);
419
+
219
420
  if (!token) {
220
- throw new BadRequestException(`Active token not found: ${args.tokenName}` as LocalizedString);
421
+ throw new BadRequestException(SD("error.detail")(`Active token not found: ${args.tokenName}`));
221
422
  }
423
+
222
424
  return { ...args, preferredTokenId: token.id, requirePreferredToken: true };
223
425
  }
224
426
 
225
- function createHttpDisconnectHandle(): {
427
+ interface HttpDisconnectHandle {
226
428
  signal: AbortSignal;
227
429
  dispose: () => void;
228
- } {
430
+ }
431
+
432
+ function createHttpDisconnectHandle(): HttpDisconnectHandle {
229
433
  const abortController = new AbortController();
230
434
  let requestRaw: FastifyRequest["raw"] | undefined;
231
435
  let responseRaw: FastifyReply["raw"] | undefined;
@@ -233,12 +437,14 @@ function createHttpDisconnectHandle(): {
233
437
  try {
234
438
  const ctx = Sonamu.getContext();
235
439
  requestRaw = ctx.request?.raw;
440
+
236
441
  if (ctx.transport === "http") responseRaw = ctx.reply.raw;
237
442
  } catch {
238
443
  // Direct frame calls (for example unit tests) do not have an HTTP context.
239
444
  }
240
445
 
241
446
  const abort = () => abortController.abort();
447
+
242
448
  const abortOnIncompleteResponseClose = () => {
243
449
  if (!responseRaw?.writableEnded) abort();
244
450
  };
@@ -265,6 +471,524 @@ const oauthLogger = getLogger(["qgrid", "oauth"]);
265
471
  // per-token refresh dedup — 회전 겹침으로 인한 사망 오판을 막는다.
266
472
  const inflightAnthropicRefresh = new Map<number, Promise<string>>();
267
473
 
474
+ // --- Grok media and voice ---
475
+
476
+ const mediaLogger = getLogger(["qgrid", "media"]);
477
+
478
+ type MediaKind = "image" | "video" | "stt" | "tts";
479
+
480
+ const MEDIA_TOOL_NAMES: Record<MediaKind, string> = {
481
+ image: "image_generation",
482
+ video: "video_generation",
483
+ stt: "transcription",
484
+ tts: "speech",
485
+ };
486
+
487
+ function grokNotReady(): Error {
488
+ return QgridDispatcher.startupState.grok === "failed"
489
+ ? new InternalServerErrorException(SD("qgrid.dispatcherFailed")("Grok"))
490
+ : new ServiceUnavailableException(SD("qgrid.dispatcherStarting")("Grok"));
491
+ }
492
+
493
+ function grokMedia(): MediaDispatcher {
494
+ const dispatcher = QgridDispatcher.grokDispatcher;
495
+ const generateImages = dispatcher?.generateImages;
496
+ const editImages = dispatcher?.editImages;
497
+ const startVideo = dispatcher?.startVideo;
498
+ const getVideo = dispatcher?.getVideo;
499
+
500
+ if (!dispatcher || !generateImages || !editImages || !startVideo || !getVideo) {
501
+ throw grokNotReady();
502
+ }
503
+
504
+ return {
505
+ generateImages: generateImages.bind(dispatcher),
506
+ editImages: editImages.bind(dispatcher),
507
+ startVideo: startVideo.bind(dispatcher),
508
+ getVideo: getVideo.bind(dispatcher),
509
+ };
510
+ }
511
+
512
+ function grokVoice(): VoiceDispatcher {
513
+ const dispatcher = QgridDispatcher.grokDispatcher;
514
+ const transcribe = dispatcher?.transcribe;
515
+ const speak = dispatcher?.speak;
516
+
517
+ if (!dispatcher || !transcribe || !speak) throw grokNotReady();
518
+
519
+ return { transcribe: transcribe.bind(dispatcher), speak: speak.bind(dispatcher) };
520
+ }
521
+
522
+ class TooManyRequestsException extends SoException {
523
+ constructor(message: LocalizedString) {
524
+ super(429, message);
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Every Grok token is cooling down. A 429 rather than a 503: the server error handler pins 503's
530
+ * Retry-After to its startup interval, while this delay comes from the upstream cooldown.
531
+ */
532
+ function grokRateLimited(error: GrokRateLimitedError): TooManyRequestsException {
533
+ let reply: FastifyReply | undefined;
534
+
535
+ try {
536
+ const ctx = Sonamu.getContext();
537
+
538
+ if (ctx.transport === "http") reply = ctx.reply;
539
+ } catch {
540
+ // Direct frame calls (for example unit tests) have no HTTP context and no header to set.
541
+ }
542
+
543
+ // An SSE stream has already sent its headers; the error event then carries the delay alone.
544
+ if (reply && !reply.raw.headersSent) reply.header("Retry-After", String(error.retryAfterSec));
545
+
546
+ return new TooManyRequestsException(SD("qgrid.grokRateLimited")(error.retryAfterSec));
547
+ }
548
+
549
+ /**
550
+ * Caller-fault Grok errors become 400/429/503 at the chat and media boundaries; upstream and
551
+ * provider failures propagate unchanged (500 by the server error handler). "No eligible token for
552
+ * this request class" (no workers, in-flight cap, on-demand D6, ZDR video) is a 503, not a server
553
+ * fault; every token cooling down is a 429 with the soonest retry delay.
554
+ */
555
+ function throwGrokCallerError(cause: unknown): never {
556
+ if (cause instanceof GrokInvalidRequestError) {
557
+ throw new BadRequestException(SD("qgrid.grokInvalidRequest")(cause.message));
558
+ }
559
+
560
+ if (cause instanceof GrokContentPolicyError) {
561
+ throw new BadRequestException(SD("qgrid.grokContentPolicy")(cause.message));
562
+ }
563
+
564
+ if (cause instanceof GrokCapabilityUnavailableError) {
565
+ throw new ServiceUnavailableException(SD("qgrid.grokCapabilityUnavailable")(cause.message));
566
+ }
567
+
568
+ if (cause instanceof GrokOnDemandExcludedError) {
569
+ throw new ServiceUnavailableException(
570
+ SD("qgrid.grokOnDemandExcluded")(cause.tokenNames.join(", ")),
571
+ );
572
+ }
573
+
574
+ if (cause instanceof GrokZdrVideoUnavailableError) {
575
+ throw new ServiceUnavailableException(
576
+ SD("qgrid.grokZdrVideoUnavailable")(cause.tokenNames.join(", ")),
577
+ );
578
+ }
579
+
580
+ if (cause instanceof GrokCapacityError) {
581
+ throw new ServiceUnavailableException(SD("qgrid.grokCapacity"));
582
+ }
583
+
584
+ if (cause instanceof GrokRateLimitedError) throw grokRateLimited(cause);
585
+
586
+ if (cause instanceof GrokNoWorkersError) {
587
+ throw new ServiceUnavailableException(SD("qgrid.grokNoWorkers")(cause.message));
588
+ }
589
+
590
+ throw cause;
591
+ }
592
+
593
+ class PayloadTooLargeException extends SoException {
594
+ constructor(message: LocalizedString) {
595
+ super(413, message);
596
+ }
597
+ }
598
+
599
+ const MiB = 1024 * 1024;
600
+
601
+ /**
602
+ * Fastify rejects a body over `bodyLimit` before routing with FST_ERR_CTP_BODY_TOO_LARGE, which is
603
+ * not a Sonamu exception and would otherwise answer 500. The server error handler applies this
604
+ * first so the caller gets a Sonamu 413.
605
+ */
606
+ export function mapRequestBodyTooLarge(error: Error): Error {
607
+ if ("code" in error && error.code === "FST_ERR_CTP_BODY_TOO_LARGE") {
608
+ return new PayloadTooLargeException(
609
+ SD("qgrid.requestBodyTooLarge")(REQUEST_BODY_LIMIT_BYTES / MiB),
610
+ );
611
+ }
612
+
613
+ return error;
614
+ }
615
+
616
+ // Sonamu's generated validator does not carry zod refinements (size, https, base64), so media
617
+ // endpoints re-validate with the full schema.
618
+ function parseMediaInput<S extends z.ZodType>(schema: S, value: z.input<S>): z.infer<S> {
619
+ const parsed = schema.safeParse(value);
620
+
621
+ if (!parsed.success) {
622
+ const detail = parsed.error.issues
623
+ .map((issue) => `${issue.path.join(".") || "input"}: ${issue.message}`)
624
+ .join("; ");
625
+
626
+ throw new BadRequestException(SD("qgrid.invalidInput")(detail));
627
+ }
628
+
629
+ return parsed.data;
630
+ }
631
+
632
+ async function resolveGrokTokenSelection(
633
+ tokenName: string | undefined,
634
+ ): Promise<MediaTokenSelection> {
635
+ if (!tokenName) return {};
636
+
637
+ if (!tokenName.startsWith("grok/")) {
638
+ throw new BadRequestException(
639
+ SD("error.detail")("Token provider does not match model provider"),
640
+ );
641
+ }
642
+
643
+ const token = await TokenModel.findActiveByProviderAndName("A", "grok", tokenName);
644
+
645
+ if (!token) {
646
+ throw new BadRequestException(SD("error.detail")(`Active token not found: ${tokenName}`));
647
+ }
648
+
649
+ return { preferredTokenId: token.id, requirePreferredToken: true };
650
+ }
651
+
652
+ interface RoutedModelId {
653
+ id: string;
654
+ route: string;
655
+ }
656
+
657
+ function routeModelId(model: string | undefined, fallback: string): RoutedModelId {
658
+ const id = model ? model.slice("grok/".length) : fallback;
659
+
660
+ return { id, route: `grok/${id}` };
661
+ }
662
+
663
+ type GeneratedImage = GenerateImagesOutput["images"][number];
664
+
665
+ /** Adds the provider thread coordinate to the lifecycle run context without dropping either. */
666
+ function mergeRunContext(
667
+ lifecycle: QgridRunContext | undefined,
668
+ threadCoord: QgridThreadCoord | undefined,
669
+ ): QgridRunContext | undefined {
670
+ if (!lifecycle && !threadCoord) return undefined;
671
+ const runContext: QgridRunContext = { ...lifecycle };
672
+
673
+ if (threadCoord) runContext.threadCoord = threadCoord;
674
+
675
+ return runContext;
676
+ }
677
+
678
+ function microUsd(usd: number | undefined): number | undefined {
679
+ return usd === undefined ? undefined : Math.round(usd * MICRO_USD);
680
+ }
681
+
682
+ /** Reference-image log metadata: a URL, or a data URL's media type and size (plus bytes). */
683
+ type ImageRefLog = { url: string } | { mediaType: string; data?: string; byteSize: number };
684
+
685
+ /** Reference-image log metadata. Data URLs keep their bytes only when `withData` (images). */
686
+ function describeImageRefs(refs: string[], withData: boolean): ImageRefLog[] {
687
+ return refs.map((ref) => {
688
+ const match = /^data:([^;,]+);base64,(.*)$/.exec(ref);
689
+
690
+ if (!match) return { url: ref };
691
+ const [, mediaType = "", data = ""] = match;
692
+ const byteSize = Buffer.byteLength(data, "base64");
693
+
694
+ return withData ? { mediaType, data, byteSize } : { mediaType, byteSize };
695
+ });
696
+ }
697
+
698
+ function imageTagForLog(b64: string, mimeType: string, alt: string | undefined): string {
699
+ const safeAlt = (alt ?? "generated image").replace(/[&"<>]/g, (ch) => `&#${ch.charCodeAt(0)};`);
700
+
701
+ return `<img src="data:${mimeType};base64,${b64}" alt="${safeAlt}" />`;
702
+ }
703
+
704
+ type MediaLogStart = { kind: MediaKind; model: string; prompt: string; projectName?: string };
705
+
706
+ // Creating the running row is a precondition of a logged provider call, as for query.
707
+ async function startMediaRun(
708
+ loggerEnabled: boolean | undefined,
709
+ start: MediaLogStart,
710
+ ): Promise<number | undefined> {
711
+ if (loggerEnabled === false) return undefined;
712
+
713
+ return RequestLogModel.createRun({
714
+ user_prompt: start.prompt,
715
+ requested_model_name: start.model,
716
+ project_name: start.projectName,
717
+ is_image_generation: start.kind === "image",
718
+ });
719
+ }
720
+
721
+ /** JSON-serializable log arguments; `undefined` members are dropped by JSON.stringify. */
722
+ type MediaLogArg = string | number | boolean | null | undefined | MediaLogArg[] | MediaLogArgs;
723
+
724
+ type MediaLogArgs = { [key: string]: MediaLogArg };
725
+
726
+ /** Image step arguments; cost and reference images are recorded only when known. */
727
+ type ImageStepArgs = {
728
+ kind: "image";
729
+ prompt: string;
730
+ model: string;
731
+ requestedOptions: MediaLogArgs;
732
+ imageCount: number;
733
+ costSource?: string;
734
+ costInUsdTicks?: number;
735
+ inputImages?: ImageRefLog[];
736
+ };
737
+
738
+ /** Video step arguments; reference images are recorded only when supplied. */
739
+ type VideoStepArgs = {
740
+ kind: "video";
741
+ prompt: string;
742
+ model: string;
743
+ requestedOptions: MediaLogArgs;
744
+ inputImages?: ImageRefLog[];
745
+ };
746
+
747
+ type MediaLogSteps = Array<{ toolCallId: string; args: MediaLogArgs; result?: string }>;
748
+
749
+ type MediaLogSuccess = {
750
+ kind: MediaKind;
751
+ model: string;
752
+ tokenName: string;
753
+ durationMs: number;
754
+ response: string;
755
+ steps: MediaLogSteps;
756
+ cost?: GrokMediaCost;
757
+ };
758
+
759
+ function mediaToolSteps(
760
+ kind: MediaKind,
761
+ steps: MediaLogSteps,
762
+ durationMs: number,
763
+ ): RequestLogStepFields[] {
764
+ return steps.map((step, index) => {
765
+ const fields: RequestLogStepFields = {
766
+ step_index: 0,
767
+ type: "tool_call",
768
+ tool_call_index: index,
769
+ tool_call_id: step.toolCallId,
770
+ tool_name: MEDIA_TOOL_NAMES[kind],
771
+ tool_args: JSON.stringify(step.args),
772
+ };
773
+
774
+ if (step.result !== undefined) fields.tool_result = step.result;
775
+ fields.tool_duration_ms = durationMs;
776
+
777
+ return fields;
778
+ });
779
+ }
780
+
781
+ // Image cost follows the image-generation columns (driver cost 0); video and voice cost is the
782
+ // row's own cost. An unknown cost stays NULL rather than a guessed 0.
783
+ type MediaCostFields = Pick<FinishRunParams, "cost_usd" | "cost_source"> &
784
+ Partial<Pick<FinishRunParams, "is_image_generation" | "image_cost_usd" | "image_cost_method">>;
785
+
786
+ function mediaCostFields(
787
+ kind: MediaKind,
788
+ model: string,
789
+ cost: GrokMediaCost | undefined,
790
+ ): MediaCostFields {
791
+ if (kind === "image") {
792
+ return {
793
+ cost_usd: 0,
794
+ cost_source: cost?.costSource ?? null,
795
+ is_image_generation: true,
796
+ image_cost_usd: microUsd(cost?.costUsd) ?? null,
797
+ image_cost_method: cost
798
+ ? `${cost.costSource === "provider" ? "reported" : "catalog"}:${model}:ticks=${cost.ticks}`
799
+ : null,
800
+ };
801
+ }
802
+
803
+ return { cost_usd: microUsd(cost?.costUsd) ?? null, cost_source: cost?.costSource ?? null };
804
+ }
805
+
806
+ function mediaFinishFields(success: Omit<MediaLogSuccess, "steps">): FinishRunParams {
807
+ return {
808
+ status: "succeeded",
809
+ response: success.response,
810
+ token_name: success.tokenName,
811
+ requested_model_name: success.model,
812
+ model_name: success.model,
813
+ input_tokens: 0,
814
+ output_tokens: 0,
815
+ cache_read_tokens: 0,
816
+ cache_creation_tokens: 0,
817
+ duration_ms: success.durationMs,
818
+ ...mediaCostFields(success.kind, success.model, success.cost),
819
+ };
820
+ }
821
+
822
+ // Logging after a successful provider call is best-effort and never replaces the result.
823
+ async function finishMediaRun(
824
+ requestLogId: number | undefined,
825
+ success: MediaLogSuccess,
826
+ ): Promise<void> {
827
+ if (requestLogId === undefined) return;
828
+
829
+ try {
830
+ for (const step of mediaToolSteps(success.kind, success.steps, success.durationMs)) {
831
+ await RequestLogModel.appendStep(requestLogId, step);
832
+ }
833
+
834
+ await RequestLogModel.finishRun(requestLogId, mediaFinishFields(success));
835
+ } catch (e) {
836
+ mediaLogger.error(
837
+ `${success.kind} request log failed: ${e instanceof Error ? e.message : String(e)}`,
838
+ );
839
+ }
840
+ }
841
+
842
+ async function failMediaRun(
843
+ requestLogId: number | undefined,
844
+ cause: unknown,
845
+ aborted: boolean,
846
+ ): Promise<void> {
847
+ if (requestLogId === undefined) return;
848
+
849
+ try {
850
+ await RequestLogModel.finishRun(requestLogId, {
851
+ status: aborted ? "aborted" : "error",
852
+ error_message: aborted
853
+ ? "client disconnected"
854
+ : cause instanceof Error
855
+ ? cause.message
856
+ : String(cause),
857
+ });
858
+ } catch (e) {
859
+ mediaLogger.error(`media request log failed: ${e instanceof Error ? e.message : String(e)}`);
860
+ }
861
+ }
862
+
863
+ /**
864
+ * One provider call against an open media run. A failure closes the run as error (or aborted on
865
+ * client disconnect) and surfaces as the caller-facing Grok error; success leaves the run open.
866
+ */
867
+ async function dispatchMediaCall<T>(
868
+ requestLogId: number | undefined,
869
+ call: (signal: AbortSignal) => Promise<T>,
870
+ ): Promise<T> {
871
+ const disconnect = createHttpDisconnectHandle();
872
+ let result: T;
873
+
874
+ try {
875
+ result = await call(disconnect.signal);
876
+ } catch (e) {
877
+ await failMediaRun(requestLogId, e, disconnect.signal.aborted);
878
+ throwGrokCallerError(e);
879
+ } finally {
880
+ disconnect.dispose();
881
+ }
882
+
883
+ return result;
884
+ }
885
+
886
+ type MediaRunLog = Omit<MediaLogSuccess, "kind" | "model" | "durationMs">;
887
+
888
+ /**
889
+ * A one-shot media endpoint: open the run, make the provider call, then log the result. `dispatch`
890
+ * returns the endpoint output and what the run records about it.
891
+ */
892
+ async function runMediaEndpoint<T>(
893
+ start: MediaLogStart & { logger: boolean | undefined },
894
+ dispatch: (
895
+ signal: AbortSignal,
896
+ requestLogId: number | undefined,
897
+ ) => Promise<{ output: T; log: MediaRunLog }>,
898
+ ): Promise<T> {
899
+ const requestLogId = await startMediaRun(start.logger, start);
900
+ const startedAt = Date.now();
901
+
902
+ const { output, log } = await dispatchMediaCall(requestLogId, (signal) =>
903
+ dispatch(signal, requestLogId),
904
+ );
905
+
906
+ await finishMediaRun(requestLogId, {
907
+ ...log,
908
+ kind: start.kind,
909
+ model: start.model,
910
+ durationMs: Date.now() - startedAt,
911
+ });
912
+
913
+ return output;
914
+ }
915
+
916
+ // Video jobs (plan 5.7): an unguessable handle maps to the token that started the job. Polls go
917
+ // only to that token. The owner is the caller identity request logs already carry (projectName);
918
+ // qgrid has no authenticated caller id, so the 128-bit handle is the actual capability.
919
+ const VIDEO_JOB_TTL_MS = 24 * 60 * 60_000;
920
+ const VIDEO_JOB_MAX = 10_000;
921
+
922
+ type VideoJob = {
923
+ tokenId: number;
924
+ tokenName: string;
925
+ requestId: string;
926
+ owner: string | null;
927
+ model: string;
928
+ createdAt: number;
929
+ requestLogId?: number;
930
+ final?: VideoStatusOutput;
931
+ };
932
+
933
+ const videoJobs = new Map<string, VideoJob>();
934
+
935
+ function pruneVideoJobs(now: number): void {
936
+ for (const [handle, job] of videoJobs) {
937
+ if (now - job.createdAt < VIDEO_JOB_TTL_MS && videoJobs.size <= VIDEO_JOB_MAX) break;
938
+ videoJobs.delete(handle);
939
+ }
940
+ }
941
+
942
+ function videoToolCallId(job: Pick<VideoJob, "requestId">): string {
943
+ return `video_generation:${job.requestId}`;
944
+ }
945
+
946
+ // Resolves the pending video step and finishes the run. Best-effort: a run already expired by
947
+ // stale cleanup only loses its late result.
948
+ async function finishVideoRun(
949
+ job: VideoJob,
950
+ final: VideoStatusOutput,
951
+ cost: GrokMediaCost | undefined,
952
+ ): Promise<void> {
953
+ if (job.requestLogId === undefined) return;
954
+
955
+ try {
956
+ await RequestLogModel.continueToolRun(job.requestLogId, [
957
+ {
958
+ toolCallId: videoToolCallId(job),
959
+ output: JSON.stringify({
960
+ status: final.status,
961
+ url: final.url,
962
+ durationSec: final.durationSec,
963
+ costInUsdTicks: final.costInUsdTicks,
964
+ error: final.error,
965
+ }),
966
+ isError: final.status === "failed",
967
+ },
968
+ ]);
969
+
970
+ const fields = mediaFinishFields({
971
+ kind: "video",
972
+ model: job.model,
973
+ tokenName: job.tokenName,
974
+ durationMs: Date.now() - job.createdAt,
975
+ response: final.url ?? "",
976
+ cost,
977
+ });
978
+
979
+ await RequestLogModel.finishRun(
980
+ job.requestLogId,
981
+ final.status === "done"
982
+ ? fields
983
+ : { ...fields, status: "error", error_message: final.error ?? "video generation failed" },
984
+ );
985
+ } catch (e) {
986
+ mediaLogger.error(`video request log failed: ${e instanceof Error ? e.message : String(e)}`);
987
+ }
988
+ }
989
+
990
+ const GROK_TTS_LOG_MODEL = "grok/tts";
991
+
268
992
  class QgridFrameClass extends BaseFrameClass {
269
993
  constructor() {
270
994
  super("Qgrid");
@@ -276,52 +1000,68 @@ class QgridFrameClass extends BaseFrameClass {
276
1000
  rejectInvalidCallerSchemas(args);
277
1001
  const resolvedArgs = await resolveTokenName(args);
278
1002
  const disconnect = createHttpDisconnectHandle();
1003
+
279
1004
  try {
280
1005
  if (resolvedArgs.logger === false) {
281
- return await QgridDispatcher.query(resolvedArgs, disconnect.signal);
1006
+ try {
1007
+ return await QgridDispatcher.query(resolvedArgs, disconnect.signal);
1008
+ } catch (e) {
1009
+ throwGrokCallerError(e);
1010
+ }
282
1011
  }
283
1012
 
284
1013
  const { requestLogId, stepIndex } = await beforeQuery(resolvedArgs);
285
1014
  let result: QueryOutput;
1015
+
286
1016
  try {
287
1017
  result = await QgridDispatcher.query(resolvedArgs, disconnect.signal);
288
1018
  } catch (e) {
289
1019
  const completedDriver = completedDriverFromImageFailure(e, resolvedArgs.serviceTier);
1020
+
290
1021
  const completedStep = completedDriver
291
1022
  ? ([{ stepIndex, result: completedDriver }] as const)
292
1023
  : ([] as const);
1024
+
293
1025
  if (disconnect.signal.aborted) {
294
1026
  await finishRunAborted(requestLogId, resolvedArgs, ...completedStep);
295
1027
  } else {
296
1028
  await finishRunWithError(
297
1029
  requestLogId,
298
- (e as Error).message,
1030
+ e instanceof Error ? e.message : String(e),
299
1031
  resolvedArgs,
300
1032
  ...completedStep,
301
1033
  );
302
1034
  }
303
- throw e;
1035
+
1036
+ throwGrokCallerError(e);
304
1037
  }
305
1038
 
306
1039
  try {
307
1040
  const lifecycle = await afterQuery(requestLogId, stepIndex, resolvedArgs, result);
1041
+
308
1042
  if (disconnect.signal.aborted) {
309
1043
  await finishRunAborted(requestLogId, resolvedArgs);
310
1044
  }
1045
+
311
1046
  // tool-call requestLogId와 provider threadCoord는 서로 독립적으로 유지한다.
312
1047
  const threadCoord = result.runContext?.threadCoord;
313
- const runContext =
314
- lifecycle.runContext || threadCoord
315
- ? { ...lifecycle.runContext, ...(threadCoord ? { threadCoord } : {}) }
316
- : undefined;
1048
+
1049
+ const runContext = mergeRunContext(lifecycle.runContext, threadCoord);
1050
+
317
1051
  return { ...result, requestLogId, runContext };
318
1052
  } catch (e) {
319
- logger.error(`query afterQuery failed: ${(e as Error).message}`);
1053
+ logger.error(`query afterQuery failed: ${e instanceof Error ? e.message : String(e)}`);
1054
+
320
1055
  if (disconnect.signal.aborted) {
321
1056
  await finishRunAborted(requestLogId, resolvedArgs);
322
1057
  } else {
323
- await finishRunWithError(requestLogId, (e as Error).message, resolvedArgs);
1058
+ await finishRunWithError(
1059
+ requestLogId,
1060
+ e instanceof Error ? e.message : String(e),
1061
+ resolvedArgs,
1062
+ );
324
1063
  }
1064
+
325
1065
  // provider 응답은 성공했으므로 로깅 장애가 생성 결과를 덮어쓰지 않는다.
326
1066
  return { ...result, requestLogId };
327
1067
  }
@@ -339,6 +1079,7 @@ class QgridFrameClass extends BaseFrameClass {
339
1079
  pendingStreams.set(streamId, resolvedArgs);
340
1080
  const expiry = setTimeout(() => pendingStreams.delete(streamId), 30_000);
341
1081
  expiry.unref?.();
1082
+
342
1083
  return { streamId };
343
1084
  }
344
1085
 
@@ -346,6 +1087,7 @@ class QgridFrameClass extends BaseFrameClass {
346
1087
  async queryStream(streamId: string): Promise<void> {
347
1088
  const args = pendingStreams.get(streamId);
348
1089
  pendingStreams.delete(streamId);
1090
+
349
1091
  if (!args) throw new Error("invalid or expired streamId");
350
1092
  rejectImageGenerationStream(args);
351
1093
 
@@ -362,6 +1104,7 @@ class QgridFrameClass extends BaseFrameClass {
362
1104
  });
363
1105
 
364
1106
  let runInfo: { requestLogId: number; stepIndex: number } | undefined;
1107
+
365
1108
  if (args.logger !== false) {
366
1109
  // provider 실행 전 running row 생성에 실패하면 logged 요청으로 진행하지 않는다.
367
1110
  runInfo = await beforeQuery(args);
@@ -371,6 +1114,7 @@ class QgridFrameClass extends BaseFrameClass {
371
1114
  if (clientClosed || sse.closed) {
372
1115
  if (runInfo) await finishRunAborted(runInfo.requestLogId, args);
373
1116
  await sse.end();
1117
+
374
1118
  return;
375
1119
  }
376
1120
 
@@ -391,13 +1135,14 @@ class QgridFrameClass extends BaseFrameClass {
391
1135
  abortController.signal,
392
1136
  );
393
1137
  } catch (e) {
394
- streamError = e as Error;
1138
+ streamError = e instanceof Error ? e : new Error(String(e));
395
1139
  }
396
1140
 
397
1141
  // provider 결과가 없는 close는 즉시 aborted로 마감한다.
398
1142
  if (clientClosed && !streamResult) {
399
1143
  if (runInfo) await finishRunAborted(runInfo.requestLogId, args);
400
1144
  await sse.end();
1145
+
401
1146
  return;
402
1147
  }
403
1148
 
@@ -405,8 +1150,10 @@ class QgridFrameClass extends BaseFrameClass {
405
1150
  if (streamResult) {
406
1151
  // dispatcher 가 실은 thread 재사용 좌표는 logger 설정과 무관하게 회송한다.
407
1152
  const threadCoord = streamResult.runContext?.threadCoord;
1153
+
408
1154
  let runContext: QgridRunContext | undefined =
409
- threadCoord !== undefined ? { threadCoord } : undefined;
1155
+ threadCoord === undefined ? undefined : { threadCoord };
1156
+
410
1157
  if (runInfo) {
411
1158
  try {
412
1159
  // close 후 도착한 결과도 step/usage는 남기되, 아래에서 최종 status를 aborted로 덮어쓴다.
@@ -416,37 +1163,43 @@ class QgridFrameClass extends BaseFrameClass {
416
1163
  args,
417
1164
  streamResult,
418
1165
  );
419
- runContext =
420
- lifecycle.runContext || threadCoord
421
- ? { ...lifecycle.runContext, ...(threadCoord ? { threadCoord } : {}) }
422
- : undefined;
1166
+
1167
+ runContext = mergeRunContext(lifecycle.runContext, threadCoord);
423
1168
 
424
1169
  // afterQuery가 진행되는 사이 client가 닫힌 경우 완료 상태를 aborted로 되돌린다.
425
1170
  if (clientClosed) {
426
1171
  await finishRunAborted(runInfo.requestLogId, args);
427
1172
  await sse.end();
1173
+
428
1174
  return;
429
1175
  }
430
1176
  } catch (e) {
431
- logger.error(`stream afterQuery failed: ${(e as Error).message}`);
432
- await finishRunWithError(runInfo.requestLogId, (e as Error).message, args);
1177
+ logger.error(`stream afterQuery failed: ${e instanceof Error ? e.message : String(e)}`);
1178
+ await finishRunWithError(
1179
+ runInfo.requestLogId,
1180
+ e instanceof Error ? e.message : String(e),
1181
+ args,
1182
+ );
433
1183
 
434
1184
  if (clientClosed) {
435
1185
  await finishRunAborted(runInfo.requestLogId, args);
436
1186
  await sse.end();
1187
+
437
1188
  return;
438
1189
  }
439
1190
  }
440
1191
  }
1192
+
441
1193
  if (!clientClosed && !sse.closed) {
442
- sse.publish("done", {
443
- ...streamResult,
444
- ...(runInfo ? { requestLogId: runInfo.requestLogId } : {}),
445
- runContext,
446
- });
1194
+ const done: QueryOutput = { ...streamResult };
1195
+
1196
+ if (runInfo) done.requestLogId = runInfo.requestLogId;
1197
+ done.runContext = runContext;
1198
+ sse.publish("done", done);
447
1199
  }
448
1200
  } else if (streamError) {
449
1201
  if (runInfo) await finishRunWithError(runInfo.requestLogId, streamError.message, args);
1202
+
450
1203
  if (!clientClosed && !sse.closed) sse.publish("error", { message: streamError.message });
451
1204
  } else if ((clientClosed || sse.closed) && runInfo) {
452
1205
  await finishRunAborted(runInfo.requestLogId, args);
@@ -467,6 +1220,7 @@ class QgridFrameClass extends BaseFrameClass {
467
1220
  is_structured: input.isStructured,
468
1221
  json_schema: input.jsonSchema,
469
1222
  });
1223
+
470
1224
  return { requestLogId };
471
1225
  }
472
1226
 
@@ -499,6 +1253,7 @@ class QgridFrameClass extends BaseFrameClass {
499
1253
  tool_duration_ms: input.toolDurationMs,
500
1254
  error: input.error,
501
1255
  });
1256
+
502
1257
  return { stepId };
503
1258
  }
504
1259
 
@@ -524,6 +1279,7 @@ class QgridFrameClass extends BaseFrameClass {
524
1279
  error_message: input.errorMessage,
525
1280
  response_json_ok: input.responseJsonOk,
526
1281
  });
1282
+
527
1283
  return { ok: true };
528
1284
  }
529
1285
 
@@ -537,7 +1293,7 @@ class QgridFrameClass extends BaseFrameClass {
537
1293
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
538
1294
  async totalCost(
539
1295
  params?: RequestLogListParams,
540
- ): Promise<{ usd: number; unknownImageCostCount: number }> {
1296
+ ): Promise<{ usd: number; unknownImageCostCount: number; unknownCostCount: number }> {
541
1297
  return RequestLogModel.totalCost(params);
542
1298
  }
543
1299
 
@@ -564,6 +1320,7 @@ class QgridFrameClass extends BaseFrameClass {
564
1320
  name,
565
1321
  },
566
1322
  ]);
1323
+
567
1324
  return { added: true };
568
1325
  }
569
1326
 
@@ -581,31 +1338,40 @@ class QgridFrameClass extends BaseFrameClass {
581
1338
  (!Number.isInteger(quotaThreshold) || quotaThreshold < 1 || quotaThreshold > 100)
582
1339
  ) {
583
1340
  throw new BadRequestException(
584
- "quotaThreshold must be an integer between 1 and 100, or null" as LocalizedString,
1341
+ SD("error.detail")("quotaThreshold must be an integer between 1 and 100, or null"),
585
1342
  );
586
1343
  }
1344
+
587
1345
  if (weight !== undefined && (!Number.isInteger(weight) || weight < 1 || weight > 100)) {
588
1346
  throw new BadRequestException(
589
- "weight must be an integer between 1 and 100" as LocalizedString,
1347
+ SD("error.detail")("weight must be an integer between 1 and 100"),
590
1348
  );
591
1349
  }
592
1350
 
593
1351
  const patch: TokenUpdateFields = {};
1352
+
594
1353
  if (name !== undefined) patch.name = name;
1354
+
595
1355
  if (quotaThreshold !== undefined) patch.quota_threshold = quotaThreshold;
1356
+
596
1357
  if (weight !== undefined) patch.weight = weight;
1358
+
597
1359
  if (keepaliveEnabled !== undefined) patch.keepalive_enabled = keepaliveEnabled;
1360
+
598
1361
  if (Object.keys(patch).length === 0) return { updated: false };
599
1362
 
600
1363
  const updated = await TokenModel.updateFields(id, patch);
1364
+
601
1365
  return { updated: updated > 0 };
602
1366
  }
603
1367
 
604
1368
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
605
1369
  async removeToken(id: number): Promise<{ removed: boolean }> {
606
1370
  const entry = await TokenModel.findOne("A", { id });
1371
+
607
1372
  if (!entry) return { removed: false };
608
1373
  await TokenModel.del([entry.id]);
1374
+
609
1375
  return { removed: true };
610
1376
  }
611
1377
 
@@ -615,10 +1381,13 @@ class QgridFrameClass extends BaseFrameClass {
615
1381
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
616
1382
  async toggleToken(id: number): Promise<{ active: boolean }> {
617
1383
  const result = await TokenModel.toggleActive(id);
1384
+
618
1385
  if (!result) return { active: false };
1386
+
619
1387
  if (result.reauthRequired) {
620
- throw new BadRequestException("Re-login required" as LocalizedString);
1388
+ throw new BadRequestException(SD("error.detail")("Re-login required"));
621
1389
  }
1390
+
622
1391
  return { active: result.active };
623
1392
  }
624
1393
 
@@ -638,23 +1407,43 @@ class QgridFrameClass extends BaseFrameClass {
638
1407
  @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
639
1408
  async oauthComplete(pastedCode: string): Promise<{ added: boolean; name: string }> {
640
1409
  const result = parsePastedOAuthResult(pastedCode);
1410
+
641
1411
  if (!result) {
1412
+ // Grok accepts a bare authorization code, which carries no state (grok-build login.rs).
1413
+ const grokState = await getGrokLatestState();
1414
+ const grokPending = grokState ? await getOAuthState(grokState) : undefined;
1415
+
1416
+ if (grokState && grokPending?.provider === "grok" && pastedCode.trim()) {
1417
+ await deleteOAuthState(grokState);
1418
+ await this.completeGrokLogin(pastedCode, grokState, grokPending);
1419
+
1420
+ return { added: true, name: grokPending.name };
1421
+ }
1422
+
642
1423
  throw new BadRequestException(
643
- "expected the callback URL or code shown after login, in code#state form" as LocalizedString,
1424
+ SD("error.detail")(
1425
+ "expected the callback URL or code shown after login, in code#state form",
1426
+ ),
644
1427
  );
645
1428
  }
1429
+
646
1430
  const { code, state } = result;
647
1431
 
648
1432
  const pending = await getOAuthState(state);
1433
+
649
1434
  if (!pending) {
650
1435
  throw new BadRequestException(
651
- "login session not found or expired — start the login again" as LocalizedString,
1436
+ SD("error.detail")("login session not found or expired — start the login again"),
652
1437
  );
653
1438
  }
1439
+
654
1440
  await deleteOAuthState(state);
655
1441
 
656
1442
  if (pending.provider === "openai") {
657
1443
  await this.completeOpenAILogin(code, pending);
1444
+ } else if (pending.provider === "grok") {
1445
+ // completeGrokOAuth re-checks the pasted URL's state against the stored one.
1446
+ await this.completeGrokLogin(pastedCode, state, pending);
658
1447
  } else {
659
1448
  await this.completeAnthropicLogin(code, state, pending);
660
1449
  }
@@ -696,26 +1485,67 @@ class QgridFrameClass extends BaseFrameClass {
696
1485
  notifyTokenAdded(pending.name, "openai");
697
1486
  }
698
1487
 
1488
+ async completeGrokLogin(codeOrUrl: string, state: string, pending: PendingOAuth): Promise<void> {
1489
+ let credentials: StrictGrokCredentials;
1490
+
1491
+ try {
1492
+ credentials = await completeGrokOAuth({
1493
+ codeOrUrl,
1494
+ verifier: pending.codeVerifier,
1495
+ redirectUri: pending.redirectUri,
1496
+ state,
1497
+ });
1498
+ } catch (error) {
1499
+ grokOAuthBadRequest(error);
1500
+ }
1501
+
1502
+ await this.saveGrokToken(credentials, pending.name);
1503
+ }
1504
+
1505
+ // Registration rejects anything that is not an xAI OAuth at+jwt (API keys, opaque tokens).
1506
+ async saveGrokToken(candidate: StrictGrokCredentials, name: string): Promise<void> {
1507
+ const parsed = StrictGrokCredentials.safeParse(candidate);
1508
+
1509
+ if (!parsed.success) {
1510
+ throw new BadRequestException(SD("qgrid.grokNotOAuthToken"));
1511
+ }
1512
+
1513
+ await TokenModel.replaceByAccount("grok", parsed.data.userId, {
1514
+ provider: "grok",
1515
+ credentials: parsed.data,
1516
+ name,
1517
+ });
1518
+ logger.info(`Grok token saved for ${name}`);
1519
+ notifyTokenAdded(name, "grok");
1520
+ }
1521
+
699
1522
  async handleOAuthCallback(code: string, state: string, reply: FastifyReply): Promise<void> {
700
1523
  const pending = await getOAuthState(state);
1524
+
701
1525
  if (!pending) {
702
1526
  logger.warn("oauth callback: invalid_state");
1527
+
703
1528
  return reply.redirect("/?oauth=error&reason=invalid_state");
704
1529
  }
1530
+
705
1531
  await deleteOAuthState(state);
706
1532
 
707
1533
  try {
708
1534
  if (pending.provider === "openai") {
709
1535
  await this.completeOpenAILogin(code, pending);
1536
+ } else if (pending.provider === "grok") {
1537
+ await this.completeGrokLogin(code, state, pending);
710
1538
  } else {
711
1539
  await this.completeAnthropicLogin(code, state, pending);
712
1540
  }
1541
+
713
1542
  return reply.redirect(`/?oauth=success&name=${encodeURIComponent(pending.name)}`);
714
1543
  } catch {
715
1544
  logger.warn("oauth callback failed", {
716
1545
  provider: pending.provider,
717
1546
  reason: "exchange_failed",
718
1547
  });
1548
+
719
1549
  return reply.redirect("/?oauth=error&reason=exchange_failed");
720
1550
  }
721
1551
  }
@@ -725,6 +1555,7 @@ class QgridFrameClass extends BaseFrameClass {
725
1555
  const { codeVerifier, codeChallenge, state } = generateOpenAIPKCE();
726
1556
  const mode = isRemoteOAuthRequest() ? "code" : "redirect";
727
1557
  let redirectUri: string;
1558
+
728
1559
  if (mode === "code") {
729
1560
  // OpenAI accepts only the Codex CLI's registered loopback URI. On a remote dashboard the
730
1561
  // browser cannot reach the server's loopback, so the user pastes the resulting callback URL.
@@ -734,23 +1565,492 @@ class QgridFrameClass extends BaseFrameClass {
734
1565
  redirectUri = await startOpenAICallbackRelay(trustedLoopbackBase(), OAUTH_STATE_TTL_MS);
735
1566
  } catch {
736
1567
  throw new BadRequestException(
737
- `OpenAI login needs local port ${OPENAI_CALLBACK_PORTS.join(" or ")} — close any running \`codex login\` and retry` as LocalizedString,
1568
+ SD("error.detail")(
1569
+ `OpenAI login needs local port ${OPENAI_CALLBACK_PORTS.join(" or ")} — close any running \`codex login\` and retry`,
1570
+ ),
738
1571
  );
739
1572
  }
740
1573
  }
1574
+
741
1575
  const authUrl = buildOpenAIAuthUrl(codeChallenge, state, redirectUri);
742
1576
  await setOAuthState(state, { codeVerifier, name, redirectUri, provider: "openai" });
1577
+
743
1578
  return { authUrl, mode };
744
1579
  }
745
1580
 
1581
+ // Grok's registered redirect is a random 127.0.0.1 loopback port that qgrid does not serve, so
1582
+ // the user always pastes the resulting callback URL (or the bare code) into oauthComplete.
1583
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
1584
+ async oauthStartGrok(name: string): Promise<OAuthStartResult> {
1585
+ const state = crypto.randomBytes(32).toString("base64url");
1586
+ let started;
1587
+
1588
+ try {
1589
+ started = await startGrokOAuth({ state });
1590
+ } catch (error) {
1591
+ grokOAuthBadRequest(error);
1592
+ }
1593
+
1594
+ await setOAuthState(state, {
1595
+ codeVerifier: started.verifier,
1596
+ name,
1597
+ redirectUri: started.redirectUri,
1598
+ provider: "grok",
1599
+ });
1600
+ await setGrokLatestState(state);
1601
+
1602
+ return { authUrl: started.authorizeUrl, mode: "code" };
1603
+ }
1604
+
1605
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
1606
+ async oauthStartGrokDevice(name: string): Promise<GrokDeviceCodeStart> {
1607
+ let device;
1608
+
1609
+ try {
1610
+ device = await startGrokDeviceCode();
1611
+ } catch (error) {
1612
+ grokOAuthBadRequest(error);
1613
+ }
1614
+
1615
+ await setGrokDeviceState(device.deviceCode, {
1616
+ name,
1617
+ interval: device.interval,
1618
+ expiresAtMs: Date.now() + device.expiresIn * 1000,
1619
+ });
1620
+
1621
+ return device;
1622
+ }
1623
+
1624
+ // One poll per call; the web waits intervalSec between calls.
1625
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
1626
+ async oauthPollGrokDevice(deviceCode: string): Promise<GrokDevicePollResult> {
1627
+ const pending = await getGrokDeviceState(deviceCode);
1628
+
1629
+ if (!pending) {
1630
+ throw new BadRequestException(
1631
+ SD("error.detail")("login session not found or expired — start the login again"),
1632
+ );
1633
+ }
1634
+
1635
+ let result;
1636
+
1637
+ try {
1638
+ result = await pollGrokDeviceCode({ deviceCode, interval: pending.interval });
1639
+ } catch (error) {
1640
+ if (error instanceof GrokOAuthError && !error.options.retryable) {
1641
+ await deleteGrokDeviceState(deviceCode);
1642
+ }
1643
+
1644
+ grokOAuthBadRequest(error);
1645
+ }
1646
+
1647
+ if (result.status === "pending") {
1648
+ const slowDown = result.interval > pending.interval;
1649
+
1650
+ if (slowDown) await setGrokDeviceState(deviceCode, { ...pending, interval: result.interval });
1651
+
1652
+ return { status: slowDown ? "slow_down" : "pending", intervalSec: result.interval };
1653
+ }
1654
+
1655
+ await deleteGrokDeviceState(deviceCode);
1656
+ await this.saveGrokToken(result.credentials, pending.name);
1657
+
1658
+ return { status: "added", name: pending.name };
1659
+ }
1660
+
1661
+ // Grok image generation (grok/* only; OpenAI images stay on query/qgrid.image()). With
1662
+ // referenceImages the request goes to /images/edits.
1663
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
1664
+ async generateImages(params: GenerateImagesInput): Promise<GenerateImagesOutput> {
1665
+ const input = parseMediaInput(GenerateImagesInput, params);
1666
+ const media = grokMedia();
1667
+ const selection = await resolveGrokTokenSelection(input.tokenName);
1668
+ const { id, route } = routeModelId(input.model, GROK_IMAGE_DEFAULT_MODEL);
1669
+ const refs = input.referenceImages ?? [];
1670
+
1671
+ const start = {
1672
+ kind: "image",
1673
+ model: route,
1674
+ prompt: input.prompt,
1675
+ projectName: input.projectName,
1676
+ logger: input.logger,
1677
+ } as const;
1678
+
1679
+ return runMediaEndpoint(start, async (signal, requestLogId) => {
1680
+ const request = {
1681
+ model: id,
1682
+ prompt: input.prompt,
1683
+ n: input.n,
1684
+ aspectRatio: input.aspectRatio,
1685
+ resolution: input.resolution,
1686
+ quality: input.quality,
1687
+ outputFormat: input.outputFormat,
1688
+ signal,
1689
+ ...selection,
1690
+ };
1691
+
1692
+ const dispatched = refs.length
1693
+ ? await media.editImages({ ...request, images: refs })
1694
+ : await media.generateImages(request);
1695
+
1696
+ const decoded = decodeGrokImages(dispatched.result);
1697
+ const cost = grokImageCost(id, decoded.images.length, decoded.costInUsdTicks);
1698
+
1699
+ const output: GenerateImagesOutput = {
1700
+ model: route,
1701
+ tokenName: dispatched.tokenName,
1702
+ images: decoded.images.map((image) => {
1703
+ const generated: GeneratedImage = {
1704
+ b64: Buffer.from(image.bytes).toString("base64"),
1705
+ mimeType: image.mediaType,
1706
+ };
1707
+
1708
+ if (image.revisedPrompt) generated.revisedPrompt = image.revisedPrompt;
1709
+
1710
+ return generated;
1711
+ }),
1712
+ usage: { imageCount: decoded.images.length },
1713
+ };
1714
+
1715
+ if (decoded.costInUsdTicks !== undefined) {
1716
+ output.usage.costInUsdTicks = decoded.costInUsdTicks;
1717
+ }
1718
+
1719
+ if (cost) {
1720
+ output.costUsd = cost.costUsd;
1721
+ output.costSource = cost.costSource;
1722
+ }
1723
+
1724
+ if (requestLogId !== undefined) output.requestLogId = requestLogId;
1725
+
1726
+ const log: MediaRunLog = {
1727
+ tokenName: output.tokenName,
1728
+ cost,
1729
+ response: output.images
1730
+ .map((image) => imageTagForLog(image.b64, image.mimeType, image.revisedPrompt))
1731
+ .join("\n"),
1732
+ steps: output.images.map((image, index) => {
1733
+ const args: ImageStepArgs = {
1734
+ kind: "image",
1735
+ prompt: input.prompt,
1736
+ model: route,
1737
+ requestedOptions: {
1738
+ n: input.n,
1739
+ aspectRatio: input.aspectRatio,
1740
+ resolution: input.resolution,
1741
+ quality: input.quality,
1742
+ outputFormat: input.outputFormat,
1743
+ },
1744
+ imageCount: output.usage.imageCount,
1745
+ };
1746
+
1747
+ if (cost) {
1748
+ args.costSource = cost.costSource;
1749
+ args.costInUsdTicks = cost.ticks;
1750
+ }
1751
+
1752
+ // Reference images are stored once, on the first output step.
1753
+ if (index === 0 && refs.length) args.inputImages = describeImageRefs(refs, true);
1754
+
1755
+ return {
1756
+ toolCallId: `image_generation:0:${index}`,
1757
+ args,
1758
+ result: imageTagForLog(image.b64, image.mimeType, image.revisedPrompt),
1759
+ };
1760
+ }),
1761
+ };
1762
+
1763
+ return { output, log };
1764
+ });
1765
+ }
1766
+
1767
+ // Starts a Grok video job. Poll videoStatus with the returned handle and the same projectName.
1768
+ // The run stays open after a successful start (a pending tool step), so only the provider call
1769
+ // goes through dispatchMediaCall; videoStatus finishes the run on a terminal status.
1770
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
1771
+ async videoStart(params: VideoStartInput): Promise<VideoStartOutput> {
1772
+ const input = parseMediaInput(VideoStartInput, params);
1773
+ const media = grokMedia();
1774
+ const selection = await resolveGrokTokenSelection(input.tokenName);
1775
+ const { id, route } = routeModelId(input.model, GROK_VIDEO_DEFAULT_MODEL);
1776
+
1777
+ const requestLogId = await startMediaRun(input.logger, {
1778
+ kind: "video",
1779
+ model: route,
1780
+ prompt: input.prompt,
1781
+ projectName: input.projectName,
1782
+ });
1783
+
1784
+ const startedAt = Date.now();
1785
+
1786
+ const started = await dispatchMediaCall(requestLogId, (signal) =>
1787
+ media.startVideo({
1788
+ model: id,
1789
+ prompt: input.prompt,
1790
+ duration: input.duration,
1791
+ aspectRatio: input.aspectRatio,
1792
+ resolution: input.resolution,
1793
+ image: input.image,
1794
+ lastFrame: input.lastFrame,
1795
+ referenceImages: input.referenceImages,
1796
+ mode: input.mode,
1797
+ signal,
1798
+ ...selection,
1799
+ }),
1800
+ );
1801
+
1802
+ const now = Date.now();
1803
+ pruneVideoJobs(now);
1804
+ const jobHandle = crypto.randomBytes(16).toString("base64url");
1805
+
1806
+ const job: VideoJob = {
1807
+ tokenId: started.tokenId,
1808
+ tokenName: started.tokenName,
1809
+ requestId: started.result.request_id,
1810
+ owner: input.projectName ?? null,
1811
+ model: route,
1812
+ createdAt: now,
1813
+ };
1814
+
1815
+ if (requestLogId !== undefined) job.requestLogId = requestLogId;
1816
+
1817
+ videoJobs.set(jobHandle, job);
1818
+
1819
+ if (requestLogId !== undefined) {
1820
+ // The pending tool step keeps the run open; an abandoned job is expired by stale cleanup.
1821
+ const refs = [
1822
+ ...(input.image ? [input.image] : []),
1823
+ ...(input.lastFrame ? [input.lastFrame] : []),
1824
+ ...(input.referenceImages ?? []),
1825
+ ];
1826
+
1827
+ const args: VideoStepArgs = {
1828
+ kind: "video",
1829
+ prompt: input.prompt,
1830
+ model: route,
1831
+ requestedOptions: {
1832
+ duration: input.duration,
1833
+ aspectRatio: input.aspectRatio,
1834
+ resolution: input.resolution,
1835
+ mode: input.mode,
1836
+ },
1837
+ };
1838
+
1839
+ if (refs.length) args.inputImages = describeImageRefs(refs, false);
1840
+
1841
+ const [step] = mediaToolSteps(
1842
+ "video",
1843
+ [{ toolCallId: videoToolCallId(job), args }],
1844
+ now - startedAt,
1845
+ );
1846
+
1847
+ try {
1848
+ await RequestLogModel.appendStep(requestLogId, step!);
1849
+ } catch (e) {
1850
+ mediaLogger.error(
1851
+ `video request log failed: ${e instanceof Error ? e.message : String(e)}`,
1852
+ );
1853
+ }
1854
+ }
1855
+
1856
+ const output: VideoStartOutput = { jobHandle, model: route };
1857
+
1858
+ if (requestLogId !== undefined) output.requestLogId = requestLogId;
1859
+
1860
+ return output;
1861
+ }
1862
+
1863
+ // Polls the token that started the job. A terminal status is cached so the run is logged once.
1864
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
1865
+ async videoStatus(params: VideoStatusInput): Promise<VideoStatusOutput> {
1866
+ const input = parseMediaInput(VideoStatusInput, params);
1867
+ pruneVideoJobs(Date.now());
1868
+ const job = videoJobs.get(input.jobHandle);
1869
+
1870
+ // A foreign owner gets the same 404 as an unknown handle, so handles cannot be probed.
1871
+ if (!job || job.owner !== (input.projectName ?? null)) {
1872
+ throw new NotFoundException(SD("qgrid.videoJobNotFound"));
1873
+ }
1874
+
1875
+ if (job.final) return job.final;
1876
+
1877
+ const media = grokMedia();
1878
+ const disconnect = createHttpDisconnectHandle();
1879
+ let summary: ReturnType<typeof summarizeGrokVideoStatus>;
1880
+ let durationSec: number | undefined;
1881
+
1882
+ try {
1883
+ const polled = await media.getVideo(job.tokenId, job.requestId, disconnect.signal);
1884
+ summary = summarizeGrokVideoStatus(polled.result);
1885
+ durationSec = polled.result.video?.duration ?? undefined;
1886
+ } catch (e) {
1887
+ if (!(e instanceof GrokContentPolicyError)) throwGrokCallerError(e);
1888
+ summary = { status: "failed", mimeType: "video/mp4", error: e.message };
1889
+ } finally {
1890
+ disconnect.dispose();
1891
+ }
1892
+
1893
+ // An overlapping poll may have finished the job while this one awaited upstream.
1894
+ if (job.final) return job.final;
1895
+
1896
+ const status: VideoStatusOutput = { status: summary.status, mimeType: "video/mp4" };
1897
+
1898
+ if (job.requestLogId !== undefined) status.requestLogId = job.requestLogId;
1899
+
1900
+ if (summary.status === "pending") return status;
1901
+
1902
+ const cost = grokReportedCost(summary.usageTicks);
1903
+ const final = status;
1904
+
1905
+ if (summary.url) final.url = summary.url;
1906
+
1907
+ if (durationSec !== undefined) final.durationSec = durationSec;
1908
+
1909
+ if (cost) {
1910
+ final.costInUsdTicks = cost.ticks;
1911
+ final.costUsd = cost.costUsd;
1912
+ }
1913
+
1914
+ if (summary.error) final.error = summary.error;
1915
+
1916
+ // Set before finishVideoRun's await: overlapping polls return this result instead of logging again.
1917
+ job.final = final;
1918
+ await finishVideoRun(job, final, cost);
1919
+
1920
+ return final;
1921
+ }
1922
+
1923
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
1924
+ async transcribe(params: TranscribeInput): Promise<TranscribeOutput> {
1925
+ const input = parseMediaInput(TranscribeInput, params);
1926
+ const voice = grokVoice();
1927
+ const selection = await resolveGrokTokenSelection(input.tokenName);
1928
+ const { id, route } = routeModelId(input.model, GROK_STT_DEFAULT_MODEL);
1929
+ const audio = new Uint8Array(Buffer.from(input.audio, "base64"));
1930
+
1931
+ const start = {
1932
+ kind: "stt",
1933
+ model: route,
1934
+ prompt: `[audio ${input.mediaType} ${audio.byteLength} bytes]`,
1935
+ projectName: input.projectName,
1936
+ logger: input.logger,
1937
+ } as const;
1938
+
1939
+ return runMediaEndpoint(start, async (signal, requestLogId) => {
1940
+ const dispatched = await voice.transcribe({
1941
+ audio,
1942
+ mediaType: input.mediaType,
1943
+ model: id,
1944
+ language: input.language,
1945
+ signal,
1946
+ ...selection,
1947
+ });
1948
+
1949
+ const { text, language, duration } = dispatched.result;
1950
+
1951
+ const output: TranscribeOutput = { text, model: route, tokenName: dispatched.tokenName };
1952
+
1953
+ if (language) output.language = language;
1954
+
1955
+ if (duration !== undefined) output.durationSec = duration;
1956
+
1957
+ if (requestLogId !== undefined) output.requestLogId = requestLogId;
1958
+
1959
+ const log: MediaRunLog = {
1960
+ tokenName: output.tokenName,
1961
+ response: output.text,
1962
+ steps: [
1963
+ {
1964
+ toolCallId: "transcription:0",
1965
+ args: {
1966
+ kind: "stt",
1967
+ model: route,
1968
+ mediaType: input.mediaType,
1969
+ audioBytes: audio.byteLength,
1970
+ language: input.language,
1971
+ durationSec: output.durationSec,
1972
+ },
1973
+ result: JSON.stringify({
1974
+ text: output.text,
1975
+ language: output.language,
1976
+ durationSec: output.durationSec,
1977
+ }),
1978
+ },
1979
+ ],
1980
+ };
1981
+
1982
+ return { output, log };
1983
+ });
1984
+ }
1985
+
1986
+ // /tts takes no model; `model` only labels the request log.
1987
+ @api({ httpMethod: "POST", clients: ["axios", "tanstack-mutation"] })
1988
+ async speak(params: SpeakInput): Promise<SpeakOutput> {
1989
+ const input = parseMediaInput(SpeakInput, params);
1990
+ const voice = grokVoice();
1991
+ const selection = await resolveGrokTokenSelection(input.tokenName);
1992
+ const route = input.model ?? GROK_TTS_LOG_MODEL;
1993
+
1994
+ const request = {
1995
+ text: input.text,
1996
+ voice: input.voice ?? GROK_TTS_DEFAULT_VOICE,
1997
+ language: input.language ?? GROK_TTS_DEFAULT_LANGUAGE,
1998
+ format: input.format ?? GROK_TTS_DEFAULT_FORMAT,
1999
+ };
2000
+
2001
+ const start = {
2002
+ kind: "tts",
2003
+ model: route,
2004
+ prompt: input.text,
2005
+ projectName: input.projectName,
2006
+ logger: input.logger,
2007
+ } as const;
2008
+
2009
+ return runMediaEndpoint(start, async (signal, requestLogId) => {
2010
+ const dispatched = await voice.speak({ ...request, signal, ...selection });
2011
+ const audioBytes = dispatched.result.audio.byteLength;
2012
+
2013
+ const output: SpeakOutput = {
2014
+ audio: Buffer.from(dispatched.result.audio).toString("base64"),
2015
+ mimeType: dispatched.result.mediaType,
2016
+ tokenName: dispatched.tokenName,
2017
+ };
2018
+
2019
+ if (requestLogId !== undefined) output.requestLogId = requestLogId;
2020
+
2021
+ const log: MediaRunLog = {
2022
+ tokenName: output.tokenName,
2023
+ response: `[audio ${output.mimeType} ${audioBytes} bytes]`,
2024
+ steps: [
2025
+ {
2026
+ toolCallId: "speech:0",
2027
+ args: {
2028
+ kind: "tts",
2029
+ model: route,
2030
+ voice: request.voice,
2031
+ language: request.language,
2032
+ format: request.format,
2033
+ characters: input.text.length,
2034
+ },
2035
+ result: JSON.stringify({ mimeType: output.mimeType, audioBytes }),
2036
+ },
2037
+ ],
2038
+ };
2039
+
2040
+ return { output, log };
2041
+ });
2042
+ }
2043
+
746
2044
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
747
2045
  async usage(tokenId?: number): Promise<UsageResponse> {
748
2046
  const { rows: allTokens } = await TokenModel.findMany("A");
2047
+
749
2048
  const entry = tokenId
750
2049
  ? allTokens.find((e) => e.id === tokenId)
751
2050
  : allTokens.findLast((e) => e.active && e.provider === "anthropic");
752
2051
 
753
2052
  if (!entry) return { error: "NOT_FOUND" };
2053
+
754
2054
  if (entry.reauth_required) return { error: "re-login required" };
755
2055
 
756
2056
  if (entry.provider === "openai") {
@@ -761,6 +2061,7 @@ class QgridFrameClass extends BaseFrameClass {
761
2061
  try {
762
2062
  const raw = await QgridDispatcher.openaiDispatcher?.getRateLimitsByTokenId(entry.id);
763
2063
  const rl = raw?.data.rateLimits;
2064
+
764
2065
  return {
765
2066
  provider: "openai",
766
2067
  fiveHour: rl?.primary
@@ -779,10 +2080,30 @@ class QgridFrameClass extends BaseFrameClass {
779
2080
  : null,
780
2081
  };
781
2082
  } catch (e) {
782
- return { error: `OpenAI usage failed: ${(e as Error).message}` };
2083
+ return { error: `OpenAI usage failed: ${e instanceof Error ? e.message : String(e)}` };
783
2084
  }
784
2085
  }
785
2086
 
2087
+ if (entry.provider === "grok") {
2088
+ // Cached billing from the dispatcher's prefetch; usage never calls xAI itself.
2089
+ const snapshot = entry.active ? grokTokenSnapshot(entry.id) : undefined;
2090
+ const billing = snapshot?.lastBilling;
2091
+ const storedTier = StoredGrokCredentials.safeParse(entry.credentials).data?.subscriptionTier;
2092
+
2093
+ return {
2094
+ provider: "grok",
2095
+ fiveHour: null,
2096
+ sevenDay: billing
2097
+ ? { utilization: billing.utilizationPct, resetsAt: billing.periodEnd }
2098
+ : null,
2099
+ periodType: billing?.periodType ?? null,
2100
+ tier: snapshot?.lastTier ?? storedTier ?? null,
2101
+ onDemandExcluded: snapshot?.onDemandExcluded ?? false,
2102
+ zdrVideoExcluded: snapshot?.zdrVideoExcluded ?? false,
2103
+ keepaliveApplicable: false,
2104
+ };
2105
+ }
2106
+
786
2107
  if (entry.provider !== "anthropic") {
787
2108
  return { error: `usage API not supported for provider: ${entry.provider}` };
788
2109
  }
@@ -794,35 +2115,49 @@ class QgridFrameClass extends BaseFrameClass {
794
2115
  try {
795
2116
  accessToken = await this.refreshToken(entry);
796
2117
  } catch (e) {
797
- oauthLogger.warn(`refresh failed for ${entry.name}: ${(e as Error).message}`);
2118
+ oauthLogger.warn(
2119
+ `refresh failed for ${entry.name}: ${e instanceof Error ? e.message : String(e)}`,
2120
+ );
2121
+
798
2122
  return { error: "Token refresh failed; usage is unavailable" };
799
2123
  }
800
2124
  }
801
2125
 
802
2126
  const raw = await fetchUsage(accessToken);
2127
+
803
2128
  if (raw.error && getRefreshToken(entry.credentials)) {
804
2129
  try {
805
2130
  accessToken = await this.refreshToken(entry);
806
2131
  const retried = await fetchUsage(accessToken);
2132
+
807
2133
  if (retried.error) return { error: retried.error };
2134
+
808
2135
  return convertAnthropicUsage(retried);
809
2136
  } catch (e) {
810
- oauthLogger.warn(`refresh failed for ${entry.name}: ${(e as Error).message}`);
2137
+ oauthLogger.warn(
2138
+ `refresh failed for ${entry.name}: ${e instanceof Error ? e.message : String(e)}`,
2139
+ );
2140
+
811
2141
  return { error: "Token refresh failed; usage is unavailable" };
812
2142
  }
813
2143
  }
2144
+
814
2145
  if (raw.error) return { error: raw.error };
2146
+
815
2147
  return convertAnthropicUsage(raw);
816
2148
  }
817
2149
 
818
2150
  // 동시 refresh 가 회전을 겹치면 뒤늦은 호출이 이미 폐기된 refresh token 을 쓰게 되어
819
2151
  // 멀쩡한 토큰이 사망으로 오판된다. openai-refresh.ts 와 같은 per-token dedup 을 둔다.
820
- async refreshToken(token: TokenSubsetA): Promise<string> {
2152
+ async refreshToken(token: RefreshableToken): Promise<string> {
2153
+ if (token.provider === "grok") return this.refreshGrokToken(token);
821
2154
  const inflight = inflightAnthropicRefresh.get(token.id);
2155
+
822
2156
  if (inflight) return inflight;
823
2157
 
824
2158
  const promise = this.doRefreshToken(token);
825
2159
  inflightAnthropicRefresh.set(token.id, promise);
2160
+
826
2161
  try {
827
2162
  return await promise;
828
2163
  } finally {
@@ -830,18 +2165,35 @@ class QgridFrameClass extends BaseFrameClass {
830
2165
  }
831
2166
  }
832
2167
 
833
- async doRefreshToken(token: TokenSubsetA): Promise<string> {
2168
+ // Set by bootstrap; the same coordinator the GrokDispatcher uses, so refreshes are single-flight.
2169
+ grokRefresher: GrokRefreshCoordinator | null = null;
2170
+
2171
+ async refreshGrokToken(token: RefreshableToken): Promise<string> {
2172
+ if (!this.grokRefresher) throw new Error("Grok refresher not initialized");
2173
+ const current = StrictGrokCredentials.parse(token.credentials);
2174
+
2175
+ const next = await this.grokRefresher.refresh(token.id, current, (creds) =>
2176
+ persistGrokCredentials(token.id, creds),
2177
+ );
2178
+
2179
+ return next.accessToken;
2180
+ }
2181
+
2182
+ async doRefreshToken(token: RefreshableToken): Promise<string> {
834
2183
  const creds = token.credentials;
835
2184
  const rt = getRefreshToken(creds);
2185
+
836
2186
  if (!rt) throw new Error("No refresh token");
837
2187
 
838
2188
  let refreshed;
2189
+
839
2190
  try {
840
2191
  refreshed = await refreshAccessToken(rt);
841
2192
  } catch (e) {
842
2193
  if (e instanceof RefreshFailedError && e.isAuthDead()) {
843
2194
  await deactivateAuthDeadToken(token, `anthropic:${e.status}`);
844
2195
  }
2196
+
845
2197
  throw e;
846
2198
  }
847
2199
 
@@ -851,6 +2203,7 @@ class QgridFrameClass extends BaseFrameClass {
851
2203
  refreshToken: refreshed.refreshToken,
852
2204
  expiresAt: refreshed.expiresAt,
853
2205
  });
2206
+
854
2207
  await TokenModel.save([
855
2208
  {
856
2209
  id: token.id,
@@ -860,12 +2213,14 @@ class QgridFrameClass extends BaseFrameClass {
860
2213
  reauth_required: false,
861
2214
  },
862
2215
  ]);
2216
+
863
2217
  return refreshed.accessToken;
864
2218
  }
865
2219
 
866
2220
  @api({ httpMethod: "GET", clients: ["axios", "tanstack-query"] })
867
2221
  async health(): Promise<HealthResponse> {
868
2222
  const providers = { ...QgridDispatcher.startupState };
2223
+
869
2224
  return {
870
2225
  status: "ok",
871
2226
  activeTokens: QgridDispatcher.tokens.size,