@cartanova/qgrid-cli 2.10.10 → 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.
- package/bundle/dist/_virtual/_rolldown/runtime.js +1 -10
- package/bundle/dist/application/monit/log-buffer.js +12 -6
- package/bundle/dist/application/monit/monit.frame.js +11 -3
- package/bundle/dist/application/monit/monit.types.js +1 -1
- package/bundle/dist/application/qgrid/conv-routing.js +9 -6
- package/bundle/dist/application/qgrid/expired-token-reminder.js +2 -2
- package/bundle/dist/application/qgrid/oauth.js +31 -4
- package/bundle/dist/application/qgrid/qgrid-image-generation.js +1 -1
- package/bundle/dist/application/qgrid/qgrid-response-format.js +21 -19
- package/bundle/dist/application/qgrid/qgrid-run-lifecycle.js +40 -33
- package/bundle/dist/application/qgrid/qgrid.dispatcher.js +128 -93
- package/bundle/dist/application/qgrid/qgrid.frame.js +930 -49
- package/bundle/dist/application/qgrid/qgrid.types.js +212 -5
- package/bundle/dist/application/qgrid/schema-prompt.js +75 -48
- package/bundle/dist/application/qgrid/slack-user-map.js +1 -1
- package/bundle/dist/application/qgrid/token-death.js +1 -1
- package/bundle/dist/application/qgrid/token-subscriber.js +83 -30
- package/bundle/dist/application/qgrid/token-trigger-setup.js +1 -1
- package/bundle/dist/application/qgrid/token-window-keepalive.js +8 -6
- package/bundle/dist/application/qgrid/tool-emulation-schema.js +13 -13
- package/bundle/dist/application/qgrid/tool-emulation.js +19 -15
- package/bundle/dist/application/request-log/json-schema-type-text.js +29 -12
- package/bundle/dist/application/request-log/request-log.model.js +83 -70
- package/bundle/dist/application/request-log/request-log.types.js +1 -1
- package/bundle/dist/application/request-log/zod-code-format.js +1 -1
- package/bundle/dist/application/request-log-step/request-log-step.model.js +1 -1
- package/bundle/dist/application/request-log-step/request-log-step.types.js +1 -1
- package/bundle/dist/application/setting/setting.constant.js +1 -1
- package/bundle/dist/application/setting/setting.model.js +2 -2
- package/bundle/dist/application/setting/setting.store.js +2 -2
- package/bundle/dist/application/setting/setting.types.js +1 -1
- package/bundle/dist/application/sonamu.generated.js +32 -16
- package/bundle/dist/application/sonamu.generated.sso.js +1 -1
- package/bundle/dist/application/token/token.model.js +26 -9
- package/bundle/dist/application/token/token.types.js +20 -3
- package/bundle/dist/i18n/en.js +15 -1
- package/bundle/dist/i18n/ko.js +15 -1
- package/bundle/dist/i18n/sd.generated.js +1 -1
- package/bundle/dist/migrations/20260807133553_manual__backfill_request_log_cost.js +8 -6
- package/bundle/dist/server-bootstrap.js +4 -2
- package/bundle/dist/server-error-handler.js +1 -1
- package/bundle/dist/sonamu.config.js +37 -8
- package/bundle/dist/startup-migrations.js +17 -7
- package/bundle/dist/testing/global.js +1 -1
- package/bundle/dist/testing/schema-bench.js +0 -0
- package/bundle/dist/testing/test-database-name.js +1 -1
- package/bundle/dist/utils/providers/anthropic/anthropic-constants.js +1 -1
- package/bundle/dist/utils/providers/anthropic/anthropic-dispatcher.js +11 -5
- package/bundle/dist/utils/providers/anthropic/anthropic-quota.js +4 -4
- package/bundle/dist/utils/providers/anthropic/anthropic-thinking.js +1 -1
- package/bundle/dist/utils/providers/anthropic/claude-session.js +20 -19
- package/bundle/dist/utils/providers/anthropic/fence-strip.js +1 -1
- package/bundle/dist/utils/providers/anthropic/stream-json-adapter.js +49 -23
- package/bundle/dist/utils/providers/common/credentials.js +5 -1
- package/bundle/dist/utils/providers/common/effort.js +3 -3
- package/bundle/dist/utils/providers/common/model-cost.js +69 -14
- package/bundle/dist/utils/providers/common/provider-types.js +29 -1
- package/bundle/dist/utils/providers/common/schema-validation.js +78 -40
- package/bundle/dist/utils/providers/common/smooth-weighted-round-robin.js +1 -1
- package/bundle/dist/utils/providers/common/strictifier.js +62 -43
- package/bundle/dist/utils/providers/common/thinking.js +3 -2
- package/bundle/dist/utils/providers/common/ttft.js +1 -1
- package/bundle/dist/utils/providers/grok/grok-backend-protocol.js +427 -0
- package/bundle/dist/utils/providers/grok/grok-constants.js +55 -0
- package/bundle/dist/utils/providers/grok/grok-direct-client.js +196 -0
- package/bundle/dist/utils/providers/grok/grok-dispatcher.js +1313 -0
- package/bundle/dist/utils/providers/grok/grok-headers.js +118 -0
- package/bundle/dist/utils/providers/grok/grok-media-client.js +420 -0
- package/bundle/dist/utils/providers/grok/grok-models.js +114 -0
- package/bundle/dist/utils/providers/grok/grok-oauth.js +455 -0
- package/bundle/dist/utils/providers/grok/grok-quota.js +119 -0
- package/bundle/dist/utils/providers/grok/grok-refresh.js +192 -0
- package/bundle/dist/utils/providers/grok/grok-types.js +186 -0
- package/bundle/dist/utils/providers/grok/grok-voice-client.js +199 -0
- package/bundle/dist/utils/providers/grok/index.js +11 -0
- package/bundle/dist/utils/providers/openai/openai-backend-protocol.js +111 -83
- package/bundle/dist/utils/providers/openai/openai-callback-relay.js +3 -2
- package/bundle/dist/utils/providers/openai/openai-constants.js +1 -1
- package/bundle/dist/utils/providers/openai/openai-direct-client.js +23 -7
- package/bundle/dist/utils/providers/openai/openai-dispatcher.js +143 -106
- package/bundle/dist/utils/providers/openai/openai-image-generation.js +48 -33
- package/bundle/dist/utils/providers/openai/openai-oauth.js +28 -13
- package/bundle/dist/utils/providers/openai/openai-quota.js +38 -27
- package/bundle/dist/utils/providers/openai/openai-refresh.js +17 -9
- package/bundle/dist/utils/providers/openai/openai-sse.js +1 -1
- package/bundle/dist/utils/providers/openai/openai-transport-config.js +1 -1
- package/bundle/dist/utils/quiet-hours.js +1 -1
- package/bundle/dist/utils/slack-notify.js +14 -8
- package/bundle/dist/utils/subset-loaders.js +4 -5
- package/bundle/src/application/monit/log-buffer.ts +23 -9
- package/bundle/src/application/monit/monit.frame.ts +22 -5
- package/bundle/src/application/monit/monit.types.ts +6 -0
- package/bundle/src/application/qgrid/conv-routing.test.ts +76 -4
- package/bundle/src/application/qgrid/conv-routing.ts +35 -9
- package/bundle/src/application/qgrid/expired-token-reminder.ts +9 -1
- package/bundle/src/application/qgrid/oauth.ts +51 -15
- package/bundle/src/application/qgrid/qgrid-image-generation.ts +9 -0
- package/bundle/src/application/qgrid/qgrid-response-format.ts +50 -25
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.test.ts +19 -0
- package/bundle/src/application/qgrid/qgrid-run-lifecycle.ts +84 -40
- package/bundle/src/application/qgrid/qgrid.dispatcher.test.ts +97 -0
- package/bundle/src/application/qgrid/qgrid.dispatcher.ts +274 -126
- package/bundle/src/application/qgrid/qgrid.frame.test.ts +1013 -3
- package/bundle/src/application/qgrid/qgrid.frame.ts +1415 -60
- package/bundle/src/application/qgrid/qgrid.types.ts +335 -0
- package/bundle/src/application/qgrid/schema-prompt.ts +105 -41
- package/bundle/src/application/qgrid/slack-user-map.ts +5 -0
- package/bundle/src/application/qgrid/token-death.ts +4 -0
- package/bundle/src/application/qgrid/token-subscriber.test.ts +21 -19
- package/bundle/src/application/qgrid/token-subscriber.ts +186 -53
- package/bundle/src/application/qgrid/token-trigger-setup.ts +2 -0
- package/bundle/src/application/qgrid/token-window-keepalive.ts +49 -6
- package/bundle/src/application/qgrid/tool-emulation-schema.ts +33 -29
- package/bundle/src/application/qgrid/tool-emulation.ts +52 -27
- package/bundle/src/application/request-log/json-schema-type-text.test.ts +5 -5
- package/bundle/src/application/request-log/json-schema-type-text.ts +81 -26
- package/bundle/src/application/request-log/request-log.model.test.ts +111 -4
- package/bundle/src/application/request-log/request-log.model.ts +277 -146
- package/bundle/src/application/request-log/request-log.types.ts +6 -0
- package/bundle/src/application/request-log/zod-code-format.ts +11 -0
- package/bundle/src/application/request-log-step/request-log-step.model.ts +1 -0
- package/bundle/src/application/request-log-step/request-log-step.types.ts +2 -0
- package/bundle/src/application/setting/setting.constant.ts +9 -0
- package/bundle/src/application/setting/setting.model.ts +12 -2
- package/bundle/src/application/setting/setting.store.ts +5 -1
- package/bundle/src/application/setting/setting.types.ts +8 -0
- package/bundle/src/application/sonamu.generated.http +133 -0
- package/bundle/src/application/sonamu.generated.sso.ts +2 -0
- package/bundle/src/application/sonamu.generated.ts +15 -0
- package/bundle/src/application/token/token.identity.test.ts +20 -0
- package/bundle/src/application/token/token.model.ts +66 -7
- package/bundle/src/application/token/token.types.ts +24 -1
- package/bundle/src/i18n/en.ts +19 -0
- package/bundle/src/i18n/ko.ts +21 -0
- package/bundle/src/i18n/sd.generated.ts +23 -2
- package/bundle/src/migrations/20260807133553_manual__backfill_request_log_cost.ts +16 -8
- package/bundle/src/server-bootstrap.ts +4 -2
- package/bundle/src/server-error-handler.ts +1 -0
- package/bundle/src/sonamu.config.ts +69 -7
- package/bundle/src/startup-migrations.ts +27 -9
- package/bundle/src/testing/global.ts +20 -0
- package/bundle/src/testing/schema-bench.ts +105 -30
- package/bundle/src/testing/test-database-name.ts +2 -0
- package/bundle/src/utils/providers/anthropic/anthropic-constants.ts +8 -0
- package/bundle/src/utils/providers/anthropic/anthropic-dispatcher.ts +41 -6
- package/bundle/src/utils/providers/anthropic/anthropic-quota.ts +7 -3
- package/bundle/src/utils/providers/anthropic/anthropic-thinking.ts +6 -0
- package/bundle/src/utils/providers/anthropic/claude-session.ts +58 -24
- package/bundle/src/utils/providers/anthropic/fence-strip.ts +11 -0
- package/bundle/src/utils/providers/anthropic/stream-json-adapter.ts +110 -45
- package/bundle/src/utils/providers/common/credentials.ts +8 -0
- package/bundle/src/utils/providers/common/effort.ts +9 -2
- package/bundle/src/utils/providers/common/model-cost.test.ts +48 -1
- package/bundle/src/utils/providers/common/model-cost.ts +259 -158
- package/bundle/src/utils/providers/common/provider-dispatcher.ts +98 -2
- package/bundle/src/utils/providers/common/provider-types.ts +46 -3
- package/bundle/src/utils/providers/common/schema-validation.test.ts +5 -1
- package/bundle/src/utils/providers/common/schema-validation.ts +124 -56
- package/bundle/src/utils/providers/common/smooth-weighted-round-robin.ts +4 -0
- package/bundle/src/utils/providers/common/strictifier.ts +185 -59
- package/bundle/src/utils/providers/common/thinking.ts +2 -2
- package/bundle/src/utils/providers/common/ttft.ts +4 -2
- package/bundle/src/utils/providers/grok/__fixtures__/golden-responses-1.0.60.json +164 -0
- package/bundle/src/utils/providers/grok/grok-backend-protocol.test.ts +325 -0
- package/bundle/src/utils/providers/grok/grok-backend-protocol.ts +721 -0
- package/bundle/src/utils/providers/grok/grok-constants.ts +105 -0
- package/bundle/src/utils/providers/grok/grok-direct-client.test.ts +179 -0
- package/bundle/src/utils/providers/grok/grok-direct-client.ts +313 -0
- package/bundle/src/utils/providers/grok/grok-dispatcher.test.ts +1395 -0
- package/bundle/src/utils/providers/grok/grok-dispatcher.ts +2105 -0
- package/bundle/src/utils/providers/grok/grok-headers.test.ts +149 -0
- package/bundle/src/utils/providers/grok/grok-headers.ts +199 -0
- package/bundle/src/utils/providers/grok/grok-media-client.test.ts +446 -0
- package/bundle/src/utils/providers/grok/grok-media-client.ts +703 -0
- package/bundle/src/utils/providers/grok/grok-models.test.ts +96 -0
- package/bundle/src/utils/providers/grok/grok-models.ts +161 -0
- package/bundle/src/utils/providers/grok/grok-oauth.test.ts +406 -0
- package/bundle/src/utils/providers/grok/grok-oauth.ts +771 -0
- package/bundle/src/utils/providers/grok/grok-quota.test.ts +142 -0
- package/bundle/src/utils/providers/grok/grok-quota.ts +198 -0
- package/bundle/src/utils/providers/grok/grok-refresh.test.ts +239 -0
- package/bundle/src/utils/providers/grok/grok-refresh.ts +325 -0
- package/bundle/src/utils/providers/grok/grok-types.test.ts +252 -0
- package/bundle/src/utils/providers/grok/grok-types.ts +374 -0
- package/bundle/src/utils/providers/grok/grok-voice-client.test.ts +321 -0
- package/bundle/src/utils/providers/grok/grok-voice-client.ts +284 -0
- package/bundle/src/utils/providers/grok/index.ts +20 -0
- package/bundle/src/utils/providers/openai/openai-backend-protocol.ts +221 -137
- package/bundle/src/utils/providers/openai/openai-callback-relay.ts +12 -1
- package/bundle/src/utils/providers/openai/openai-constants.ts +2 -0
- package/bundle/src/utils/providers/openai/openai-direct-client.ts +111 -21
- package/bundle/src/utils/providers/openai/openai-dispatcher.ts +322 -169
- package/bundle/src/utils/providers/openai/openai-image-generation.ts +116 -36
- package/bundle/src/utils/providers/openai/openai-oauth.ts +48 -20
- package/bundle/src/utils/providers/openai/openai-quota.ts +77 -46
- package/bundle/src/utils/providers/openai/openai-refresh.ts +42 -18
- package/bundle/src/utils/providers/openai/openai-sse.ts +27 -3
- package/bundle/src/utils/providers/openai/openai-transport-config.ts +2 -0
- package/bundle/src/utils/quiet-hours.ts +12 -1
- package/bundle/src/utils/slack-notify.ts +44 -13
- package/bundle/src/utils/subset-loaders.ts +7 -7
- package/bundle/web-dist/client/assets/clsx-5XM_pHvN.js +1 -0
- package/bundle/web-dist/client/assets/index-BA7uQ-6c.js +31 -0
- package/bundle/web-dist/client/assets/index-Be2qCZmI.css +1 -0
- package/bundle/web-dist/client/assets/logs-C47OJGEI.js +1 -0
- package/bundle/web-dist/client/assets/monit-BS9no0dU.js +4 -0
- package/bundle/web-dist/client/assets/plus-sdACTfH0.js +1 -0
- package/bundle/web-dist/client/assets/refresh-cw-CY8o_1cT.js +1 -0
- package/bundle/web-dist/client/assets/request-model-BRtFuZvj.js +1 -0
- package/bundle/web-dist/client/assets/routes-kqgkUxE8.js +5 -0
- package/bundle/web-dist/client/assets/{services.generated-NP-ValHd.js → services.generated-CefiDXEv.js} +5 -5
- package/bundle/web-dist/client/assets/settings-DNDvFGSw.js +1 -0
- package/bundle/web-dist/client/assets/show-u8Np69q3.js +2 -0
- package/bundle/web-dist/client/assets/tokens-QvP410NW.js +1 -0
- package/bundle/web-dist/client/assets/total-cost-MnfKU8IN.js +1 -0
- package/bundle/web-dist/client/assets/use-oauth-login-flow-Bzdh28Ws.js +1 -0
- package/bundle/web-dist/client/assets/use-update-token-mutation-CfJKaxmR.js +1 -0
- package/bundle/web-dist/client/assets/vendor-react-CUFFrVN9.js +244 -0
- package/bundle/web-dist/client/assets/vendor-tanstack-Bf_jag9G.js +1 -0
- package/bundle/web-dist/client/index.html +6 -6
- package/bundle/web-dist/server/assets/clsx-D_N5PN_2.js +16 -0
- package/bundle/web-dist/server/assets/{lazyRouteComponent-CgBdabgd.js → lazyRouteComponent-D8iTIHyn.js} +129 -2
- package/bundle/web-dist/server/assets/logs-B3OTd9hy.js +825 -0
- package/bundle/web-dist/server/assets/{monit-ZPt66W5M.js → monit-B2hqorPe.js} +385 -126
- package/bundle/web-dist/server/assets/plus-DwG7h9kq.js +28 -0
- package/bundle/web-dist/server/assets/{react-dom-Ds75e4Jl.js → react-dom-BIYQzBqG.js} +2 -129
- package/bundle/web-dist/server/assets/refresh-cw-mMBkwOq0.js +44 -0
- package/bundle/web-dist/server/assets/{request-model-DjGldb-h.js → request-model-I6OdODSH.js} +15 -6
- package/bundle/web-dist/server/assets/{routes-Ct-k7nBA.js → routes-B95YlUv2.js} +760 -213
- package/bundle/web-dist/server/assets/{services.generated-BA4AcAf5.js → services.generated-DQATJ6qf.js} +12820 -12194
- package/bundle/web-dist/server/assets/settings-C3gbGk0m.js +830 -0
- package/bundle/web-dist/server/assets/{show-LSFIzvO2.js → show-Bxaj3rhH.js} +1350 -418
- package/bundle/web-dist/server/assets/tokens-CZjH3cB7.js +1038 -0
- package/bundle/web-dist/server/assets/total-cost-fTE9Xy82.js +18 -0
- package/bundle/web-dist/server/assets/use-oauth-login-flow-CKDHgc3U.js +2379 -0
- package/bundle/web-dist/server/assets/{use-update-token-mutation-pkArIzCP.js → use-update-token-mutation-BEbgOzaa.js} +1 -1
- package/bundle/web-dist/server/entry-server.generated.js +4295 -22810
- package/dist/cli.js +9 -9
- package/package.json +4 -11
- package/postinstall.mjs +12 -0
- package/skills/qgrid/SKILL.md +1 -0
- package/skills/qgrid/references/cli-env-and-server-boot.md +4 -1
- package/skills/qgrid/references/decision-rationale.md +23 -0
- package/skills/qgrid/references/grok-runtime.md +89 -0
- package/skills/qgrid/references/provider-runtime-differences.md +30 -29
- package/skills/qgrid/references/repo-map.md +10 -0
- package/skills/qgrid/references/token-auth-quota-lifecycle.md +56 -3
- package/bundle/dist/node_modules/.pnpm/@jridgewell_sourcemap-codec@1.5.5/node_modules/@jridgewell/sourcemap-codec/dist/sourcemap-codec.js +0 -406
- package/bundle/dist/node_modules/.pnpm/@vitest_expect@4.1.10/node_modules/@vitest/expect/dist/index.js +0 -1507
- package/bundle/dist/node_modules/.pnpm/@vitest_pretty-format@4.1.10/node_modules/@vitest/pretty-format/dist/index.js +0 -887
- package/bundle/dist/node_modules/.pnpm/@vitest_runner@4.1.10/node_modules/@vitest/runner/dist/chunk-artifact.js +0 -2756
- package/bundle/dist/node_modules/.pnpm/@vitest_runner@4.1.10/node_modules/@vitest/runner/dist/index.js +0 -8
- package/bundle/dist/node_modules/.pnpm/@vitest_runner@4.1.10/node_modules/@vitest/runner/dist/utils.js +0 -8
- package/bundle/dist/node_modules/.pnpm/@vitest_snapshot@4.1.10/node_modules/@vitest/snapshot/dist/index.js +0 -922
- package/bundle/dist/node_modules/.pnpm/@vitest_spy@4.1.10/node_modules/@vitest/spy/dist/index.js +0 -386
- package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/chunk-pathe.M-eThtNZ.js +0 -121
- package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/constants.js +0 -51
- package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/diff.js +0 -1317
- package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/display.js +0 -561
- package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/error.js +0 -40
- package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/helpers.js +0 -272
- package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/offset.js +0 -27
- package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/serialize.js +0 -77
- package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/source-map.js +0 -381
- package/bundle/dist/node_modules/.pnpm/@vitest_utils@4.1.10/node_modules/@vitest/utils/dist/timers.js +0 -51
- package/bundle/dist/node_modules/.pnpm/chai@6.2.2/node_modules/chai/index.js +0 -2986
- package/bundle/dist/node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/branding.js +0 -10
- package/bundle/dist/node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/index.js +0 -109
- package/bundle/dist/node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/messages.js +0 -10
- package/bundle/dist/node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/overloads.js +0 -10
- package/bundle/dist/node_modules/.pnpm/expect-type@1.3.0/node_modules/expect-type/dist/utils.js +0 -10
- package/bundle/dist/node_modules/.pnpm/magic-string@0.30.21/node_modules/magic-string/dist/magic-string.es.js +0 -1126
- package/bundle/dist/node_modules/.pnpm/pathe@2.0.3/node_modules/pathe/dist/index.js +0 -21
- package/bundle/dist/node_modules/.pnpm/pathe@2.0.3/node_modules/pathe/dist/shared/pathe.M-eThtNZ.js +0 -432
- package/bundle/dist/node_modules/.pnpm/tinybench@2.9.0/node_modules/tinybench/dist/index.js +0 -517
- package/bundle/dist/node_modules/.pnpm/tinyrainbow@3.1.0/node_modules/tinyrainbow/dist/index.js +0 -101
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/bundle/dist/testing/setup-mocks.js +0 -16
- package/bundle/src/testing/setup-mocks.ts +0 -44
- package/bundle/web-dist/client/assets/index-Bw2s88Ot.css +0 -1
- package/bundle/web-dist/client/assets/index-DZwzUfI-.js +0 -106
- package/bundle/web-dist/client/assets/logs-Cog79DU5.js +0 -1
- package/bundle/web-dist/client/assets/monit-2auznBys.js +0 -1
- package/bundle/web-dist/client/assets/plus-B62CuqT8.js +0 -1
- package/bundle/web-dist/client/assets/refresh-cw-NNcU0Cmq.js +0 -1
- package/bundle/web-dist/client/assets/request-model-CojtinqK.js +0 -1
- package/bundle/web-dist/client/assets/routes-B0lTeLK-.js +0 -5
- package/bundle/web-dist/client/assets/settings-D8ZxZGSJ.js +0 -1
- package/bundle/web-dist/client/assets/show-CFSERlnM.js +0 -2
- package/bundle/web-dist/client/assets/tokens-CZXUyI7O.js +0 -1
- package/bundle/web-dist/client/assets/use-oauth-login-flow-hboOs3wG.js +0 -1
- package/bundle/web-dist/client/assets/use-update-token-mutation-BQHS0byG.js +0 -1
- package/bundle/web-dist/client/assets/vendor-react-DH4kUfK2.js +0 -9
- package/bundle/web-dist/client/assets/vendor-tanstack-DJRQAbrY.js +0 -1
- package/bundle/web-dist/server/assets/logs-BMyR98rU.js +0 -509
- package/bundle/web-dist/server/assets/plus-BZ9I0VgC.js +0 -19
- package/bundle/web-dist/server/assets/refresh-cw-CztCFis-.js +0 -23
- package/bundle/web-dist/server/assets/settings-CBUZ47Xx.js +0 -461
- package/bundle/web-dist/server/assets/tokens-DxrBW512.js +0 -448
- package/bundle/web-dist/server/assets/use-oauth-login-flow-Gn8flMK5.js +0 -178
- package/dist/cli.d.ts +0 -10
- /package/bundle/web-dist/client/assets/{cost-DH2jZbrw.js → cost-DB3HbU7T.js} +0 -0
- /package/bundle/web-dist/client/assets/{preload-helper-ca-nBW7U.js → preload-helper-DWTEM3RW.js} +0 -0
- /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 {
|
|
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 {
|
|
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
|
-
|
|
72
|
-
codeVerifier: string
|
|
73
|
-
name: string
|
|
74
|
-
redirectUri: string
|
|
75
|
-
provider: "anthropic"
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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}`
|
|
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
|
-
|
|
210
|
-
|
|
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"
|
|
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}`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1030
|
+
e instanceof Error ? e.message : String(e),
|
|
299
1031
|
resolvedArgs,
|
|
300
1032
|
...completedStep,
|
|
301
1033
|
);
|
|
302
1034
|
}
|
|
303
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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: ${
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
420
|
-
|
|
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: ${
|
|
432
|
-
await finishRunWithError(
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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"
|
|
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"
|
|
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"
|
|
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
|
-
"
|
|
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"
|
|
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
|
-
|
|
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: ${
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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,
|