create-windy 0.3.2 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (343) hide show
  1. package/README.md +27 -9
  2. package/dist/cli.js +825 -117
  3. package/package.json +1 -1
  4. package/template/.agents/skills/windy-business-module/SKILL.md +16 -3
  5. package/template/.windy-template.json +2 -2
  6. package/template/AGENTS.md +4 -0
  7. package/template/README.md +18 -11
  8. package/template/_gitignore +1 -0
  9. package/template/apps/agent-server/README.md +2 -0
  10. package/template/apps/agent-server/pyproject.toml +8 -0
  11. package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +87 -10
  12. package/template/apps/agent-server/src/southwind_agent_server/adk_model.py +18 -3
  13. package/template/apps/agent-server/src/southwind_agent_server/adk_tools.py +78 -11
  14. package/template/apps/agent-server/src/southwind_agent_server/app.py +120 -115
  15. package/template/apps/agent-server/src/southwind_agent_server/artifact_input.py +153 -0
  16. package/template/apps/agent-server/src/southwind_agent_server/config.py +29 -0
  17. package/template/apps/agent-server/src/southwind_agent_server/contracts.py +81 -1
  18. package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +8 -0
  19. package/template/apps/agent-server/src/southwind_agent_server/engine.py +28 -0
  20. package/template/apps/agent-server/src/southwind_agent_server/fingerprint.py +41 -0
  21. package/template/apps/agent-server/src/southwind_agent_server/http_helpers.py +62 -0
  22. package/template/apps/agent-server/src/southwind_agent_server/lease.py +15 -0
  23. package/template/apps/agent-server/src/southwind_agent_server/multimodal.py +76 -0
  24. package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +112 -20
  25. package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +14 -1
  26. package/template/apps/agent-server/src/southwind_agent_server/postgres_store.py +329 -0
  27. package/template/apps/agent-server/src/southwind_agent_server/postgres_store_backend.py +332 -0
  28. package/template/apps/agent-server/src/southwind_agent_server/provider_governance.py +64 -3
  29. package/template/apps/agent-server/src/southwind_agent_server/runtime_factory.py +68 -0
  30. package/template/apps/agent-server/src/southwind_agent_server/service.py +218 -56
  31. package/template/apps/agent-server/src/southwind_agent_server/signal_events.py +36 -0
  32. package/template/apps/agent-server/src/southwind_agent_server/sqlite_catalog.py +107 -0
  33. package/template/apps/agent-server/src/southwind_agent_server/sqlite_schema.py +65 -0
  34. package/template/apps/agent-server/src/southwind_agent_server/sqlite_serialization.py +16 -0
  35. package/template/apps/agent-server/src/southwind_agent_server/sqlite_store.py +89 -57
  36. package/template/apps/agent-server/src/southwind_agent_server/store.py +105 -2
  37. package/template/apps/agent-server/src/southwind_agent_server/store_cipher.py +40 -0
  38. package/template/apps/agent-server/src/southwind_agent_server/telemetry.py +89 -0
  39. package/template/apps/agent-server/src/southwind_agent_server/tool_host.py +72 -0
  40. package/template/apps/agent-server/tests/test_adk_adapter.py +6 -2
  41. package/template/apps/agent-server/tests/test_api.py +24 -1
  42. package/template/apps/agent-server/tests/test_artifact_input.py +110 -0
  43. package/template/apps/agent-server/tests/test_config.py +4 -0
  44. package/template/apps/agent-server/tests/test_fingerprint.py +10 -0
  45. package/template/apps/agent-server/tests/test_multimodal.py +51 -0
  46. package/template/apps/agent-server/tests/test_openai_provider.py +6 -2
  47. package/template/apps/agent-server/tests/test_postgres_store.py +131 -0
  48. package/template/apps/agent-server/tests/test_provider_governance.py +61 -0
  49. package/template/apps/agent-server/tests/test_sqlite_store.py +95 -10
  50. package/template/apps/agent-server/tests/test_telemetry.py +25 -0
  51. package/template/apps/agent-server/tests/test_tool_approval.py +28 -0
  52. package/template/apps/agent-server/uv.lock +165 -0
  53. package/template/apps/document-server/Dockerfile +19 -0
  54. package/template/apps/document-server/pyproject.toml +43 -0
  55. package/template/apps/document-server/src/southwind_document_server/__init__.py +1 -0
  56. package/template/apps/document-server/src/southwind_document_server/adapter.py +337 -0
  57. package/template/apps/document-server/src/southwind_document_server/app.py +151 -0
  58. package/template/apps/document-server/src/southwind_document_server/config.py +94 -0
  59. package/template/apps/document-server/src/southwind_document_server/contracts.py +103 -0
  60. package/template/apps/document-server/src/southwind_document_server/service.py +149 -0
  61. package/template/apps/document-server/src/southwind_document_server/store.py +185 -0
  62. package/template/apps/document-server/tests/__init__.py +1 -0
  63. package/template/apps/document-server/tests/test_config.py +42 -0
  64. package/template/apps/document-server/tests/test_service.py +140 -0
  65. package/template/apps/document-server/uv.lock +430 -0
  66. package/template/apps/embedding-server/Dockerfile +17 -0
  67. package/template/apps/embedding-server/pyproject.toml +44 -0
  68. package/template/apps/embedding-server/src/southwind_embedding_server/__init__.py +1 -0
  69. package/template/apps/embedding-server/src/southwind_embedding_server/adapter.py +254 -0
  70. package/template/apps/embedding-server/src/southwind_embedding_server/app.py +119 -0
  71. package/template/apps/embedding-server/src/southwind_embedding_server/config.py +120 -0
  72. package/template/apps/embedding-server/src/southwind_embedding_server/contracts.py +106 -0
  73. package/template/apps/embedding-server/src/southwind_embedding_server/error.py +6 -0
  74. package/template/apps/embedding-server/src/southwind_embedding_server/image_input.py +87 -0
  75. package/template/apps/embedding-server/tests/__init__.py +1 -0
  76. package/template/apps/embedding-server/tests/test_adapter.py +92 -0
  77. package/template/apps/embedding-server/tests/test_config.py +38 -0
  78. package/template/apps/embedding-server/uv.lock +449 -0
  79. package/template/apps/server/src/agent/background-operation-port.ts +37 -0
  80. package/template/apps/server/src/agent/background-operation.test.ts +231 -0
  81. package/template/apps/server/src/agent/background-operation.ts +252 -0
  82. package/template/apps/server/src/agent/drizzle-run-binding-repository.ts +114 -0
  83. package/template/apps/server/src/agent/execution-grants.ts +7 -1
  84. package/template/apps/server/src/agent/fake-runtime.test-support.ts +43 -0
  85. package/template/apps/server/src/agent/idempotency-key.test.ts +28 -0
  86. package/template/apps/server/src/agent/idempotency-key.ts +11 -0
  87. package/template/apps/server/src/agent/remote-runtime.test.ts +115 -1
  88. package/template/apps/server/src/agent/remote-runtime.ts +173 -5
  89. package/template/apps/server/src/agent/run-authorization.test-support.ts +9 -0
  90. package/template/apps/server/src/agent/run-authorization.test.ts +126 -0
  91. package/template/apps/server/src/agent/run-authorization.ts +180 -0
  92. package/template/apps/server/src/agent/run-binding-repository.ts +93 -0
  93. package/template/apps/server/src/agent/run-contracts.ts +88 -1
  94. package/template/apps/server/src/agent/run-definition-support.ts +58 -0
  95. package/template/apps/server/src/agent/run-errors.ts +25 -0
  96. package/template/apps/server/src/agent/run-facade.test.ts +57 -46
  97. package/template/apps/server/src/agent/run-facade.ts +185 -65
  98. package/template/apps/server/src/agent/run-release.ts +78 -0
  99. package/template/apps/server/src/agent/run-route-input.ts +169 -0
  100. package/template/apps/server/src/agent/run-routes.ts +63 -76
  101. package/template/apps/server/src/agent/runtime-bootstrap.ts +169 -13
  102. package/template/apps/server/src/agent/sandbox-client.ts +200 -0
  103. package/template/apps/server/src/agent/sandbox-protocol.ts +76 -0
  104. package/template/apps/server/src/agent/sandbox-registry.ts +205 -0
  105. package/template/apps/server/src/agent/sandbox-runtime.test.ts +137 -0
  106. package/template/apps/server/src/agent/sandbox-worker-server.ts +195 -0
  107. package/template/apps/server/src/agent/sandbox-worker.ts +12 -0
  108. package/template/apps/server/src/agent/tool-approval.test.ts +75 -0
  109. package/template/apps/server/src/agent/tool-approval.ts +115 -0
  110. package/template/apps/server/src/agent/tool-audit.ts +62 -0
  111. package/template/apps/server/src/agent/tool-host.test.ts +39 -11
  112. package/template/apps/server/src/agent/tool-host.ts +184 -17
  113. package/template/apps/server/src/agent/tool-registry.ts +52 -47
  114. package/template/apps/server/src/agent/vision-facade.test.ts +256 -0
  115. package/template/apps/server/src/agent/vision-input.ts +120 -0
  116. package/template/apps/server/src/ai-artifact/codec.test.ts +44 -0
  117. package/template/apps/server/src/ai-artifact/codec.ts +137 -0
  118. package/template/apps/server/src/ai-artifact/routes.ts +37 -0
  119. package/template/apps/server/src/ai-artifact/runtime.ts +205 -0
  120. package/template/apps/server/src/application-services.ts +62 -2
  121. package/template/apps/server/src/auth/reauthentication.test.ts +40 -0
  122. package/template/apps/server/src/auth/reauthentication.ts +19 -0
  123. package/template/apps/server/src/auth/runtime.ts +9 -1
  124. package/template/apps/server/src/data-access/context.ts +6 -4
  125. package/template/apps/server/src/data-access/scope-snapshot.ts +27 -0
  126. package/template/apps/server/src/data-governance/export/approval-port.ts +6 -3
  127. package/template/apps/server/src/data-governance/export/authorization-port.test.ts +23 -4
  128. package/template/apps/server/src/data-governance/export/authorization-port.ts +89 -25
  129. package/template/apps/server/src/data-governance/export/drizzle-repository.integration.test.ts +2 -0
  130. package/template/apps/server/src/data-governance/export/drizzle-repository.ts +1 -0
  131. package/template/apps/server/src/data-governance/export/job-runtime.test.ts +15 -4
  132. package/template/apps/server/src/data-governance/export/job-runtime.ts +68 -15
  133. package/template/apps/server/src/data-governance/export/ndjson-stream.ts +25 -6
  134. package/template/apps/server/src/data-governance/export/postgres-recovery.integration.test.ts +4 -2
  135. package/template/apps/server/src/data-governance/export/renderer-job-runtime.test.ts +235 -0
  136. package/template/apps/server/src/data-governance/export/renderer-runtime.ts +293 -0
  137. package/template/apps/server/src/data-governance/export/renderer-selection.test.ts +67 -0
  138. package/template/apps/server/src/data-governance/export/repository.test.ts +2 -0
  139. package/template/apps/server/src/data-governance/export/request-fingerprint.test.ts +13 -0
  140. package/template/apps/server/src/data-governance/export/request-fingerprint.ts +2 -0
  141. package/template/apps/server/src/data-governance/export/routes.ts +11 -1
  142. package/template/apps/server/src/data-governance/export/scope-snapshot.ts +6 -18
  143. package/template/apps/server/src/data-governance/export/service-support.ts +188 -0
  144. package/template/apps/server/src/data-governance/export/service.ts +90 -113
  145. package/template/apps/server/src/data-governance/export/source.ts +101 -0
  146. package/template/apps/server/src/data-governance/export/types.ts +27 -0
  147. package/template/apps/server/src/document/contracts.ts +76 -0
  148. package/template/apps/server/src/document/facade.ts +339 -0
  149. package/template/apps/server/src/document/remote-runtime.test.ts +96 -0
  150. package/template/apps/server/src/document/remote-runtime.ts +223 -0
  151. package/template/apps/server/src/document/routes.ts +193 -0
  152. package/template/apps/server/src/embedding/contracts.ts +83 -0
  153. package/template/apps/server/src/embedding/error.ts +10 -0
  154. package/template/apps/server/src/embedding/facade.ts +217 -0
  155. package/template/apps/server/src/embedding/input.ts +172 -0
  156. package/template/apps/server/src/embedding/remote-runtime.test.ts +66 -0
  157. package/template/apps/server/src/embedding/remote-runtime.ts +218 -0
  158. package/template/apps/server/src/embedding/routes.ts +152 -0
  159. package/template/apps/server/src/index.ts +40 -43
  160. package/template/apps/server/src/jobs/artifact-service.test.ts +14 -2
  161. package/template/apps/server/src/jobs/artifact-service.ts +10 -0
  162. package/template/apps/server/src/jobs/drizzle-execution-repository.ts +17 -18
  163. package/template/apps/server/src/jobs/drizzle-execution.integration.test.ts +19 -1
  164. package/template/apps/server/src/jobs/drizzle-mappers.ts +4 -1
  165. package/template/apps/server/src/jobs/drizzle-recovery-repository.ts +49 -2
  166. package/template/apps/server/src/jobs/drizzle-repository.ts +1 -0
  167. package/template/apps/server/src/jobs/execution-repository.ts +17 -0
  168. package/template/apps/server/src/jobs/execution.test.ts +34 -0
  169. package/template/apps/server/src/jobs/in-memory-control.ts +135 -0
  170. package/template/apps/server/src/jobs/in-memory-execution.ts +34 -65
  171. package/template/apps/server/src/jobs/legacy-service-runtime.ts +5 -2
  172. package/template/apps/server/src/jobs/repository.ts +3 -0
  173. package/template/apps/server/src/jobs/worker.ts +80 -41
  174. package/template/apps/server/src/module-host/ai-operation-port.ts +170 -0
  175. package/template/apps/server/src/module-host/durable-context.test.ts +312 -0
  176. package/template/apps/server/src/module-host/durable-context.ts +124 -0
  177. package/template/apps/server/src/module-host/durable-handler-registration.ts +72 -0
  178. package/template/apps/server/src/module-host/governed-export-route-support.ts +171 -0
  179. package/template/apps/server/src/module-host/governed-export-routes.test.ts +327 -0
  180. package/template/apps/server/src/module-host/governed-export-routes.ts +238 -0
  181. package/template/apps/server/src/module-host/governed-renderer-registration.test.ts +247 -0
  182. package/template/apps/server/src/module-host/governed-resource-routes.ts +331 -0
  183. package/template/apps/server/src/module-host/governed-resources.test.ts +344 -0
  184. package/template/apps/server/src/module-host/governed-resources.ts +203 -0
  185. package/template/apps/server/src/module-host/http-runtime.ts +95 -0
  186. package/template/apps/server/src/module-host/initialize-contracts.ts +14 -0
  187. package/template/apps/server/src/module-host/initialize.test.ts +49 -1
  188. package/template/apps/server/src/module-host/initialize.ts +39 -16
  189. package/template/apps/server/src/module-host/job-port.test.ts +262 -0
  190. package/template/apps/server/src/module-host/job-port.ts +334 -0
  191. package/template/apps/server/src/module-host/request-context.test.ts +88 -2
  192. package/template/apps/server/src/module-host/request-context.ts +56 -22
  193. package/template/apps/server/src/module-host/route-installer.test.ts +10 -0
  194. package/template/apps/server/src/module-host/route-installer.ts +36 -1
  195. package/template/apps/server/src/module-host/sandbox-tool-registration.ts +42 -0
  196. package/template/apps/server/src/module-host/tool-handlers.ts +67 -31
  197. package/template/apps/server/src/module-host.ts +1 -0
  198. package/template/apps/server/src/module-runtime-validation.ts +7 -3
  199. package/template/apps/server/src/speech/contracts.ts +76 -0
  200. package/template/apps/server/src/speech/facade.ts +316 -0
  201. package/template/apps/server/src/speech/remote-runtime.test.ts +88 -0
  202. package/template/apps/server/src/speech/remote-runtime.ts +227 -0
  203. package/template/apps/server/src/speech/routes.ts +180 -0
  204. package/template/apps/server/src/system/actor.ts +39 -0
  205. package/template/apps/server/src/system/route-helpers.ts +1 -0
  206. package/template/apps/server/src/workflow/service.ts +2 -1
  207. package/template/apps/speech-server/Dockerfile +22 -0
  208. package/template/apps/speech-server/pyproject.toml +43 -0
  209. package/template/apps/speech-server/src/southwind_speech_server/__init__.py +1 -0
  210. package/template/apps/speech-server/src/southwind_speech_server/adapter.py +249 -0
  211. package/template/apps/speech-server/src/southwind_speech_server/app.py +183 -0
  212. package/template/apps/speech-server/src/southwind_speech_server/config.py +100 -0
  213. package/template/apps/speech-server/src/southwind_speech_server/contracts.py +103 -0
  214. package/template/apps/speech-server/src/southwind_speech_server/errors.py +6 -0
  215. package/template/apps/speech-server/src/southwind_speech_server/normalizer.py +108 -0
  216. package/template/apps/speech-server/src/southwind_speech_server/output_validation.py +128 -0
  217. package/template/apps/speech-server/src/southwind_speech_server/service.py +162 -0
  218. package/template/apps/speech-server/src/southwind_speech_server/store.py +209 -0
  219. package/template/apps/speech-server/tests/__init__.py +1 -0
  220. package/template/apps/speech-server/tests/test_config.py +33 -0
  221. package/template/apps/speech-server/tests/test_service.py +154 -0
  222. package/template/apps/speech-server/uv.lock +430 -0
  223. package/template/apps/web/src/app/business-routes.ts +22 -0
  224. package/template/apps/web/src/components/common/AccessSnapshotBoundary.vue +112 -0
  225. package/template/apps/web/src/components/common/AccessSnapshotBoundary.webtest.ts +130 -0
  226. package/template/apps/web/src/components/common/AccessSnapshotNotice.vue +117 -0
  227. package/template/apps/web/src/components/common/FormDialog.vue +6 -0
  228. package/template/apps/web/src/components/common/ModalLayout.webtest.ts +13 -0
  229. package/template/apps/web/src/composables/access-snapshot-change.ts +131 -0
  230. package/template/apps/web/src/composables/access-snapshot-change.webtest.ts +103 -0
  231. package/template/apps/web/src/composables/useAccessSnapshot.ts +48 -2
  232. package/template/apps/web/src/composables/useAccessSnapshot.webtest.ts +54 -0
  233. package/template/apps/web/src/composables/useAccessSnapshotMonitor.ts +42 -0
  234. package/template/apps/web/src/layout/AppShell.vue +28 -25
  235. package/template/apps/web/src/layout/AppShell.webtest.ts +1 -0
  236. package/template/apps/web/src/router/business-routes.ts +68 -0
  237. package/template/apps/web/src/router/business-routes.webtest.ts +85 -0
  238. package/template/apps/web/src/router/index.ts +7 -0
  239. package/template/apps/web/src/router/shared-scaffold-neutrality.webtest.ts +12 -0
  240. package/template/apps/web/src/services/access-api.ts +5 -1
  241. package/template/apps/web/src/services/access-api.webtest.ts +35 -0
  242. package/template/docker-compose.yml +127 -0
  243. package/template/docs/architecture/ai-egress.md +12 -9
  244. package/template/docs/architecture/ai-runtime.md +23 -11
  245. package/template/docs/architecture/durable-jobs.md +25 -3
  246. package/template/docs/architecture/foundation-infrastructure.md +163 -0
  247. package/template/docs/architecture/module-host.md +115 -0
  248. package/template/docs/architecture/server-module-interface.md +39 -0
  249. package/template/docs/architecture/web-access-snapshot-resilience.md +60 -0
  250. package/template/docs/development/business-web-routes.md +66 -0
  251. package/template/docs/development/custom-login-page.md +3 -3
  252. package/template/docs/development/module-lifecycle.md +71 -0
  253. package/template/docs/operations/observability.md +5 -1
  254. package/template/docs/platform/agent-runtime.md +21 -11
  255. package/template/docs/platform/agent-tool-sandbox.md +58 -0
  256. package/template/docs/platform/agent-tools.md +5 -0
  257. package/template/docs/platform/document-ai-runtime.md +204 -0
  258. package/template/docs/platform/governed-export-renderers.md +132 -0
  259. package/template/docs/platform/navigation-registry.md +5 -0
  260. package/template/docs/platform/organization-membership.md +2 -1
  261. package/template/docs/platform/speech-ai-runtime.md +156 -0
  262. package/template/docs/platform/system-crud-api.md +7 -1
  263. package/template/docs/platform/vision-ai-runtime.md +97 -0
  264. package/template/docs/platform/web-system-crud.md +6 -0
  265. package/template/package.json +16 -2
  266. package/template/packages/ai-client/index.ts +4 -0
  267. package/template/packages/ai-client/package.json +5 -2
  268. package/template/packages/ai-client/src/agent/client/artifact.ts +26 -0
  269. package/template/packages/ai-client/src/agent/client/attach.test.ts +124 -0
  270. package/template/packages/ai-client/src/agent/client/client.ts +119 -7
  271. package/template/packages/ai-client/src/agent/client/run.ts +14 -0
  272. package/template/packages/ai-client/src/agent/client/types.ts +18 -0
  273. package/template/packages/ai-client/src/agent/client/vision.test.ts +53 -0
  274. package/template/packages/ai-client/src/agent/contracts/contracts.test.ts +1 -0
  275. package/template/packages/ai-client/src/agent/contracts/events.ts +33 -0
  276. package/template/packages/ai-client/src/agent/contracts/runs.ts +71 -0
  277. package/template/packages/ai-client/src/agent/index.ts +1 -0
  278. package/template/packages/ai-client/src/artifact-ref.ts +8 -0
  279. package/template/packages/ai-client/src/document/client.test.ts +114 -0
  280. package/template/packages/ai-client/src/document/client.ts +134 -0
  281. package/template/packages/ai-client/src/document/contracts.ts +152 -0
  282. package/template/packages/ai-client/src/document/error.ts +43 -0
  283. package/template/packages/ai-client/src/document/index.ts +3 -0
  284. package/template/packages/ai-client/src/document/run.ts +104 -0
  285. package/template/packages/ai-client/src/document/validation.ts +115 -0
  286. package/template/packages/ai-client/src/embedding/client.test.ts +77 -0
  287. package/template/packages/ai-client/src/embedding/client.ts +96 -0
  288. package/template/packages/ai-client/src/embedding/contracts.ts +46 -0
  289. package/template/packages/ai-client/src/embedding/error.ts +61 -0
  290. package/template/packages/ai-client/src/embedding/index.ts +3 -0
  291. package/template/packages/ai-client/src/embedding/validation.ts +117 -0
  292. package/template/packages/ai-client/src/speech/client.test.ts +135 -0
  293. package/template/packages/ai-client/src/speech/client.ts +104 -0
  294. package/template/packages/ai-client/src/speech/contracts.ts +129 -0
  295. package/template/packages/ai-client/src/speech/error.ts +43 -0
  296. package/template/packages/ai-client/src/speech/index.ts +3 -0
  297. package/template/packages/ai-client/src/speech/run.ts +133 -0
  298. package/template/packages/ai-client/src/speech/validation.ts +132 -0
  299. package/template/packages/config/package.json +2 -2
  300. package/template/packages/database/drizzle/0039_early_ezekiel_stane.sql +1 -0
  301. package/template/packages/database/drizzle/0040_colossal_baron_strucker.sql +63 -0
  302. package/template/packages/database/drizzle/0041_yummy_prowler.sql +21 -0
  303. package/template/packages/database/drizzle/0042_puzzling_rumiko_fujikawa.sql +27 -0
  304. package/template/packages/database/drizzle/meta/0039_snapshot.json +7755 -0
  305. package/template/packages/database/drizzle/meta/0040_snapshot.json +8150 -0
  306. package/template/packages/database/drizzle/meta/0041_snapshot.json +8323 -0
  307. package/template/packages/database/drizzle/meta/0042_snapshot.json +8541 -0
  308. package/template/packages/database/drizzle/meta/_journal.json +28 -0
  309. package/template/packages/database/package.json +2 -2
  310. package/template/packages/database/src/agent-runtime-schema.test.ts +54 -0
  311. package/template/packages/database/src/schema/agent-runtime.ts +149 -0
  312. package/template/packages/database/src/schema/common.ts +2 -14
  313. package/template/packages/database/src/schema/durable-jobs.ts +5 -0
  314. package/template/packages/database/src/schema/governed-exports.ts +17 -5
  315. package/template/packages/database/src/schema/index.ts +3 -0
  316. package/template/packages/database/src/schema-durable-recovery.test.ts +31 -0
  317. package/template/packages/database/src/schema-governed-exports.test.ts +21 -1
  318. package/template/packages/database/src/schema.test.ts +6 -23
  319. package/template/packages/jobs/package.json +3 -2
  320. package/template/packages/jobs/src/handlers.test.ts +13 -0
  321. package/template/packages/jobs/src/handlers.ts +7 -1
  322. package/template/packages/jobs/src/types.ts +4 -0
  323. package/template/packages/modules/package.json +2 -2
  324. package/template/packages/modules/src/ai-operation-composition.test-support.ts +165 -0
  325. package/template/packages/modules/src/ai-operation-composition.test.ts +91 -127
  326. package/template/packages/modules/src/ai-operation-composition.ts +59 -1
  327. package/template/packages/modules/src/manifest.ts +25 -1
  328. package/template/packages/modules/src/tool-composition.ts +18 -0
  329. package/template/packages/server-sdk/index.ts +50 -0
  330. package/template/packages/server-sdk/package.json +10 -4
  331. package/template/packages/server-sdk/src/ai-artifact-port.ts +34 -0
  332. package/template/packages/server-sdk/src/ai-operation-port.ts +65 -0
  333. package/template/packages/server-sdk/src/ai-operation-registration.ts +42 -1
  334. package/template/packages/server-sdk/src/background-ai-port.ts +43 -0
  335. package/template/packages/server-sdk/src/data-governance-registration.ts +116 -0
  336. package/template/packages/server-sdk/src/durable-handler.ts +29 -0
  337. package/template/packages/server-sdk/src/jobs-port.ts +69 -0
  338. package/template/packages/server-sdk/src/module-host.ts +16 -2
  339. package/template/packages/server-sdk/src/request-context.ts +6 -2
  340. package/template/packages/server-sdk/src/sandbox-tool.ts +21 -0
  341. package/template/packages/server-sdk/src/tool-registration.ts +2 -0
  342. package/template/packages/shared/package.json +1 -1
  343. package/template/packages/shared/src/rbac.ts +30 -0
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+
10
+ class NormalizationError(Exception):
11
+ def __init__(self, code: str, message: str) -> None:
12
+ super().__init__(message)
13
+ self.code = code
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class NormalizedAudio:
18
+ path: Path
19
+ duration_ms: int
20
+
21
+
22
+ class AudioNormalizer:
23
+ @property
24
+ def available(self) -> bool:
25
+ return bool(shutil.which("ffmpeg") and shutil.which("ffprobe"))
26
+
27
+ async def normalize(
28
+ self,
29
+ source: Path,
30
+ target: Path,
31
+ max_duration_seconds: int,
32
+ timeout_seconds: int,
33
+ ) -> NormalizedAudio:
34
+ if not self.available:
35
+ raise NormalizationError("CAPABILITY_UNAVAILABLE", "音频归一化工具不可用")
36
+ duration_ms = await self._duration(source, timeout_seconds)
37
+ if duration_ms > max_duration_seconds * 1000:
38
+ raise NormalizationError("BUDGET_EXCEEDED", "音频时长超过 Operation 配额")
39
+ await run_process(
40
+ [
41
+ "ffmpeg",
42
+ "-nostdin",
43
+ "-v",
44
+ "error",
45
+ "-i",
46
+ str(source),
47
+ "-vn",
48
+ "-ac",
49
+ "1",
50
+ "-ar",
51
+ "16000",
52
+ "-c:a",
53
+ "pcm_s16le",
54
+ "-y",
55
+ str(target),
56
+ ],
57
+ timeout_seconds,
58
+ )
59
+ if not await asyncio.to_thread(valid_output, target):
60
+ raise NormalizationError("INVALID_INPUT", "音频归一化输出无效")
61
+ return NormalizedAudio(target, duration_ms)
62
+
63
+ async def _duration(self, source: Path, timeout_seconds: int) -> int:
64
+ output = await run_process(
65
+ [
66
+ "ffprobe",
67
+ "-v",
68
+ "error",
69
+ "-show_entries",
70
+ "format=duration",
71
+ "-of",
72
+ "json",
73
+ str(source),
74
+ ],
75
+ timeout_seconds,
76
+ )
77
+ try:
78
+ value = json.loads(output)
79
+ seconds = float(value["format"]["duration"])
80
+ except (KeyError, TypeError, ValueError, json.JSONDecodeError):
81
+ raise NormalizationError("INVALID_INPUT", "无法读取音频时长") from None
82
+ if seconds <= 0:
83
+ raise NormalizationError("INVALID_INPUT", "音频时长无效")
84
+ return round(seconds * 1000)
85
+
86
+
87
+ async def run_process(arguments: list[str], timeout_seconds: int) -> str:
88
+ process = await asyncio.create_subprocess_exec(
89
+ *arguments,
90
+ stdout=asyncio.subprocess.PIPE,
91
+ stderr=asyncio.subprocess.PIPE,
92
+ )
93
+ try:
94
+ stdout, _stderr = await asyncio.wait_for(
95
+ process.communicate(),
96
+ timeout=max(1, timeout_seconds),
97
+ )
98
+ except TimeoutError:
99
+ process.kill()
100
+ await process.wait()
101
+ raise NormalizationError("BUDGET_EXCEEDED", "音频归一化超时") from None
102
+ if process.returncode != 0:
103
+ raise NormalizationError("INVALID_INPUT", "音频格式不受支持或内容损坏")
104
+ return stdout.decode("utf-8", errors="strict")
105
+
106
+
107
+ def valid_output(target: Path) -> bool:
108
+ return target.is_file() and target.stat().st_size > 44
@@ -0,0 +1,128 @@
1
+ from __future__ import annotations
2
+
3
+ from .contracts import JsonValue
4
+ from .errors import SafeSpeechError
5
+
6
+
7
+ def canonicalize_output(
8
+ value: JsonValue,
9
+ duration_ms: int,
10
+ max_units: int,
11
+ ) -> JsonValue:
12
+ if not isinstance(value, dict):
13
+ raise invalid_output("Speech 输出缺少分段")
14
+ segments = value.get("segments")
15
+ if not isinstance(segments, list):
16
+ raise invalid_output("Speech 输出缺少分段")
17
+ if len(segments) > max_units:
18
+ raise invalid_output("Speech 输出分段超过上限")
19
+ canonical: list[JsonValue] = []
20
+ previous_end = 0
21
+ for index, segment in enumerate(segments, 1):
22
+ if not isinstance(segment, dict):
23
+ raise invalid_output("Speech 分段结构无效")
24
+ start = integer(segment.get("startMs"))
25
+ end = integer(segment.get("endMs"))
26
+ text = segment.get("text")
27
+ if (
28
+ start is None
29
+ or end is None
30
+ or start < previous_end
31
+ or end <= start
32
+ or end > duration_ms + 1000
33
+ or not isinstance(text, str)
34
+ or not text.strip()
35
+ ):
36
+ raise invalid_output("Speech 分段时间戳或文本无效")
37
+ item: dict[str, JsonValue] = {
38
+ "id": f"segment-{index:06d}",
39
+ "text": text,
40
+ "startMs": start,
41
+ "endMs": end,
42
+ }
43
+ confidence = probability(segment.get("confidence"))
44
+ if confidence is not None:
45
+ item["confidence"] = confidence
46
+ language = segment.get("language")
47
+ if language is not None:
48
+ if not isinstance(language, str) or not language.strip():
49
+ raise invalid_output("Speech 分段语言无效")
50
+ item["language"] = language
51
+ words = segment.get("words")
52
+ if words is not None:
53
+ item["words"] = canonicalize_words(words, start, end)
54
+ canonical.append(item)
55
+ previous_end = end
56
+ metadata = value.get("metadata", {})
57
+ if not isinstance(metadata, dict) or any(
58
+ not isinstance(item, str | int | float | bool) for item in metadata.values()
59
+ ):
60
+ raise invalid_output("Speech 元数据无效")
61
+ result: dict[str, JsonValue] = {
62
+ "durationMs": duration_ms,
63
+ "segments": canonical,
64
+ "metadata": {**metadata, "normalizedFormat": "pcm_s16le/16000/mono"},
65
+ }
66
+ language = value.get("language")
67
+ if language is not None:
68
+ if not isinstance(language, str) or not language.strip():
69
+ raise invalid_output("Speech 语言无效")
70
+ result["language"] = language
71
+ return result
72
+
73
+
74
+ def canonicalize_words(
75
+ value: JsonValue, segment_start: int, segment_end: int
76
+ ) -> JsonValue:
77
+ if not isinstance(value, list):
78
+ raise invalid_output("Speech 词级时间戳无效")
79
+ result: list[JsonValue] = []
80
+ previous_end = segment_start
81
+ for word in value:
82
+ if not isinstance(word, dict):
83
+ raise invalid_output("Speech 词级时间戳无效")
84
+ start = integer(word.get("startMs"))
85
+ end = integer(word.get("endMs"))
86
+ text = word.get("text")
87
+ if (
88
+ start is None
89
+ or end is None
90
+ or start < previous_end
91
+ or end <= start
92
+ or end > segment_end
93
+ or not isinstance(text, str)
94
+ or not text
95
+ ):
96
+ raise invalid_output("Speech 词级时间戳无效")
97
+ item: dict[str, JsonValue] = {
98
+ "text": text,
99
+ "startMs": start,
100
+ "endMs": end,
101
+ }
102
+ confidence = probability(word.get("confidence"))
103
+ if confidence is not None:
104
+ item["confidence"] = confidence
105
+ result.append(item)
106
+ previous_end = end
107
+ return result
108
+
109
+
110
+ def integer(value: object) -> int | None:
111
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
112
+
113
+
114
+ def probability(value: object) -> float | int | None:
115
+ if value is None:
116
+ return None
117
+ if (
118
+ not isinstance(value, int | float)
119
+ or isinstance(value, bool)
120
+ or value < 0
121
+ or value > 1
122
+ ):
123
+ raise invalid_output("Speech 置信度无效")
124
+ return value
125
+
126
+
127
+ def invalid_output(message: str) -> SafeSpeechError:
128
+ return SafeSpeechError("OUTPUT_VALIDATION_FAILED", message, False)
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import uuid
7
+
8
+ from .adapter import SpeechAdapter
9
+ from .contracts import ErrorResponse, StartSpeechRunRequest
10
+ from .errors import SafeSpeechError
11
+ from .store import RunRecord, SpeechRunStore
12
+
13
+
14
+ class SpeechRunService:
15
+ def __init__(self, adapter: SpeechAdapter, store: SpeechRunStore) -> None:
16
+ self.adapter = adapter
17
+ self.store = store
18
+ self.tasks: dict[str, asyncio.Task[None]] = {}
19
+
20
+ async def start(
21
+ self, request: StartSpeechRunRequest, idempotency_key: str
22
+ ) -> RunRecord:
23
+ validate_options(request)
24
+ fingerprint = hashlib.sha256(
25
+ json.dumps(
26
+ {
27
+ "operationKey": request.operation_key,
28
+ "sourceSha256": request.source.sha256,
29
+ "options": request.options,
30
+ "capabilities": sorted(request.required_capabilities),
31
+ },
32
+ ensure_ascii=False,
33
+ separators=(",", ":"),
34
+ sort_keys=True,
35
+ ).encode()
36
+ ).hexdigest()
37
+ record, created = self.store.create(
38
+ f"speech_{uuid.uuid4().hex}",
39
+ request.operation_key,
40
+ idempotency_key,
41
+ fingerprint,
42
+ )
43
+ if created:
44
+ task = asyncio.create_task(self._execute(record.run_id, request))
45
+ self.tasks[record.run_id] = task
46
+ task.add_done_callback(lambda _task: self.tasks.pop(record.run_id, None))
47
+ return record
48
+
49
+ async def cancel(self, run_id: str) -> RunRecord:
50
+ record = self.require(run_id)
51
+ if not record.terminal:
52
+ self.store.append(run_id, "run.cancelled")
53
+ task = self.tasks.get(run_id)
54
+ if task:
55
+ task.cancel()
56
+ return self.require(run_id)
57
+
58
+ def require(self, run_id: str) -> RunRecord:
59
+ record = self.store.get(run_id)
60
+ if not record:
61
+ raise KeyError(run_id)
62
+ return record
63
+
64
+ async def _execute(self, run_id: str, request: StartSpeechRunRequest) -> None:
65
+ self.store.append(run_id, "run.started")
66
+ try:
67
+ async with asyncio.timeout(request.limits.max_duration_seconds):
68
+ output = await self.adapter.execute(request)
69
+ if self.require(run_id).status == "cancelled":
70
+ return
71
+ segments = output.get("segments") if isinstance(output, dict) else None
72
+ if not isinstance(segments, list):
73
+ raise SafeSpeechError(
74
+ "OUTPUT_VALIDATION_FAILED",
75
+ "Speech 输出缺少稳定分段",
76
+ False,
77
+ )
78
+ total = len(segments)
79
+ for index, segment in enumerate(segments, 1):
80
+ if not isinstance(segment, dict):
81
+ raise SafeSpeechError(
82
+ "OUTPUT_VALIDATION_FAILED",
83
+ "Speech 分段无效",
84
+ False,
85
+ )
86
+ end_ms = segment.get("endMs")
87
+ if not isinstance(end_ms, int):
88
+ raise SafeSpeechError(
89
+ "OUTPUT_VALIDATION_FAILED",
90
+ "Speech 分段结束时间无效",
91
+ False,
92
+ )
93
+ self.store.append(
94
+ run_id,
95
+ "speech.segment.completed",
96
+ {
97
+ "completedSegments": index,
98
+ "totalSegments": total,
99
+ "endMs": end_ms,
100
+ },
101
+ )
102
+ self.store.complete(run_id, output)
103
+ except TimeoutError:
104
+ self._fail(run_id, "RUN_TIMEOUT", "Speech 任务超时", True)
105
+ except SafeSpeechError as error:
106
+ self._fail(
107
+ run_id,
108
+ error.code,
109
+ error.safe_message,
110
+ error.retryable,
111
+ )
112
+ except Exception:
113
+ self._fail(run_id, "RUNTIME_UNAVAILABLE", "Speech Runtime 执行失败", True)
114
+
115
+ def _fail(self, run_id: str, code: str, message: str, retryable: bool) -> None:
116
+ self.store.append(
117
+ run_id,
118
+ "run.failed",
119
+ {
120
+ "error": ErrorResponse(
121
+ code=code,
122
+ message=message,
123
+ request_id=f"speech_{uuid.uuid4().hex}",
124
+ retryable=retryable,
125
+ ).model_dump(by_alias=True)
126
+ },
127
+ )
128
+
129
+
130
+ def validate_options(request: StartSpeechRunRequest) -> None:
131
+ options = request.options
132
+ if not isinstance(options, dict):
133
+ raise SafeSpeechError("INVALID_INPUT", "Speech options 必须是对象", False)
134
+ allowed = {"language", "vad", "timestamps"}
135
+ if any(key not in allowed for key in options):
136
+ raise SafeSpeechError("INVALID_INPUT", "Speech options 含未知字段", False)
137
+ language = options.get("language")
138
+ if language is not None and (
139
+ not isinstance(language, str) or not language.strip() or len(language) > 35
140
+ ):
141
+ raise SafeSpeechError("INVALID_INPUT", "Speech language 无效", False)
142
+ timestamps = options.get("timestamps")
143
+ if timestamps is not None and timestamps not in {"segment", "word"}:
144
+ raise SafeSpeechError("INVALID_INPUT", "Speech timestamps 无效", False)
145
+ vad = options.get("vad")
146
+ if vad is None:
147
+ return
148
+ if not isinstance(vad, dict) or any(
149
+ key not in {"enabled", "minSpeechMs", "maxSilenceMs"} for key in vad
150
+ ):
151
+ raise SafeSpeechError("INVALID_INPUT", "Speech VAD 配置无效", False)
152
+ if not isinstance(vad.get("enabled"), bool):
153
+ raise SafeSpeechError("INVALID_INPUT", "Speech VAD enabled 无效", False)
154
+ for key in ("minSpeechMs", "maxSilenceMs"):
155
+ value = vad.get(key)
156
+ if value is not None and (
157
+ not isinstance(value, int)
158
+ or isinstance(value, bool)
159
+ or value < 0
160
+ or value > 60_000
161
+ ):
162
+ raise SafeSpeechError("INVALID_INPUT", f"Speech VAD {key} 无效", False)
@@ -0,0 +1,209 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import sqlite3
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import cast
10
+
11
+ from .contracts import JsonValue, RunStatus
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class RunRecord:
16
+ run_id: str
17
+ operation_key: str
18
+ idempotency_key: str
19
+ fingerprint: str
20
+ status: RunStatus
21
+ output: JsonValue = None
22
+
23
+ @property
24
+ def terminal(self) -> bool:
25
+ return self.status in {"completed", "failed", "cancelled"}
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ResultArtifact:
30
+ body: bytes
31
+ media_type: str
32
+ byte_size: int
33
+ sha256: str
34
+
35
+
36
+ class RunConflictError(Exception):
37
+ pass
38
+
39
+
40
+ class SpeechRunStore:
41
+ def __init__(self, path: str) -> None:
42
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
43
+ self.database = sqlite3.connect(path, check_same_thread=False)
44
+ self.database.row_factory = sqlite3.Row
45
+ self._initialize()
46
+ self._recover()
47
+
48
+ def create(
49
+ self,
50
+ run_id: str,
51
+ operation_key: str,
52
+ idempotency_key: str,
53
+ fingerprint: str,
54
+ ) -> tuple[RunRecord, bool]:
55
+ existing = self.database.execute(
56
+ "select run_id from speech_runs where idempotency_key = ?",
57
+ (idempotency_key,),
58
+ ).fetchone()
59
+ if existing:
60
+ record = self.get(str(existing["run_id"]))
61
+ if not record or record.fingerprint != fingerprint:
62
+ raise RunConflictError
63
+ return record, False
64
+ now = time.time()
65
+ self.database.execute(
66
+ """
67
+ insert into speech_runs values (?, ?, ?, ?, 'accepted', 'null', ?, ?)
68
+ """,
69
+ (run_id, operation_key, idempotency_key, fingerprint, now, now),
70
+ )
71
+ self.database.commit()
72
+ return self.get(run_id) or _missing(), True
73
+
74
+ def get(self, run_id: str) -> RunRecord | None:
75
+ row = self.database.execute(
76
+ "select * from speech_runs where run_id = ?", (run_id,)
77
+ ).fetchone()
78
+ if not row:
79
+ return None
80
+ return RunRecord(
81
+ run_id=str(row["run_id"]),
82
+ operation_key=str(row["operation_key"]),
83
+ idempotency_key=str(row["idempotency_key"]),
84
+ fingerprint=str(row["fingerprint"]),
85
+ status=cast(RunStatus, str(row["status"])),
86
+ output=json.loads(str(row["output_json"])),
87
+ )
88
+
89
+ def append(
90
+ self, run_id: str, event_type: str, fields: dict[str, object] | None = None
91
+ ) -> None:
92
+ record = self.get(run_id)
93
+ if not record or record.terminal:
94
+ return
95
+ sequence = (
96
+ int(
97
+ self.database.execute(
98
+ "select count(*) from speech_events where run_id = ?", (run_id,)
99
+ ).fetchone()[0]
100
+ )
101
+ + 1
102
+ )
103
+ event = {
104
+ "type": event_type,
105
+ "runId": run_id,
106
+ "sequence": sequence,
107
+ **(fields or {}),
108
+ }
109
+ status = {
110
+ "run.started": "running",
111
+ "run.completed": "completed",
112
+ "run.failed": "failed",
113
+ "run.cancelled": "cancelled",
114
+ }.get(event_type, record.status)
115
+ self.database.execute(
116
+ "insert into speech_events values (?, ?, ?)",
117
+ (run_id, sequence, encode(event)),
118
+ )
119
+ self.database.execute(
120
+ "update speech_runs set status = ?, updated_at = ? where run_id = ?",
121
+ (status, time.time(), run_id),
122
+ )
123
+ self.database.commit()
124
+
125
+ def complete(self, run_id: str, output: JsonValue) -> None:
126
+ self.database.execute(
127
+ "update speech_runs set output_json = ? where run_id = ?",
128
+ (encode(output), run_id),
129
+ )
130
+ self.database.commit()
131
+ self.append(run_id, "run.completed")
132
+
133
+ def artifact(self, run_id: str) -> ResultArtifact:
134
+ record = self.get(run_id)
135
+ if not record or record.status != "completed":
136
+ raise KeyError(run_id)
137
+ body = encode(record.output).encode()
138
+ return ResultArtifact(
139
+ body=body,
140
+ media_type="application/json",
141
+ byte_size=len(body),
142
+ sha256=hashlib.sha256(body).hexdigest(),
143
+ )
144
+
145
+ def events_after(self, run_id: str, after: int) -> list[dict[str, object]]:
146
+ return [
147
+ json.loads(str(row["event_json"]))
148
+ for row in self.database.execute(
149
+ """
150
+ select event_json from speech_events
151
+ where run_id = ? and sequence > ? order by sequence
152
+ """,
153
+ (run_id, after),
154
+ )
155
+ ]
156
+
157
+ def _initialize(self) -> None:
158
+ self.database.executescript(
159
+ """
160
+ create table if not exists speech_runs (
161
+ run_id text primary key,
162
+ operation_key text not null,
163
+ idempotency_key text not null unique,
164
+ fingerprint text not null,
165
+ status text not null,
166
+ output_json text not null,
167
+ created_at real not null,
168
+ updated_at real not null
169
+ );
170
+ create table if not exists speech_events (
171
+ run_id text not null references speech_runs(run_id) on delete cascade,
172
+ sequence integer not null,
173
+ event_json text not null,
174
+ primary key (run_id, sequence)
175
+ );
176
+ """
177
+ )
178
+ self.database.commit()
179
+
180
+ def _recover(self) -> None:
181
+ rows = self.database.execute(
182
+ "select run_id from speech_runs where status in ('accepted', 'running')"
183
+ ).fetchall()
184
+ for row in rows:
185
+ self.append(
186
+ str(row["run_id"]),
187
+ "run.failed",
188
+ {
189
+ "error": {
190
+ "code": "RUNTIME_UNAVAILABLE",
191
+ "message": "Speech Runtime 重启中断了任务,请使用新幂等键重试",
192
+ "requestId": "speech-recovery",
193
+ "retryable": True,
194
+ }
195
+ },
196
+ )
197
+
198
+
199
+ def encode(value: object) -> str:
200
+ return json.dumps(
201
+ value,
202
+ ensure_ascii=False,
203
+ separators=(",", ":"),
204
+ sort_keys=True,
205
+ )
206
+
207
+
208
+ def _missing() -> RunRecord:
209
+ raise RuntimeError("Speech Run 创建后不可读")
@@ -0,0 +1 @@
1
+ """Speech Runtime tests."""
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from southwind_speech_server.config import SpeechRuntimeConfig
6
+
7
+
8
+ def test_internal_endpoints_reject_public_hosts() -> None:
9
+ with pytest.raises(ValueError, match="禁止使用公网地址"):
10
+ SpeechRuntimeConfig.from_environment(
11
+ {
12
+ "SPEECH_SERVER_INTERNAL_TOKEN": "internal-token",
13
+ "SPEECH_ADAPTER_URL": "https://8.8.8.8",
14
+ }
15
+ )
16
+
17
+
18
+ def test_internal_endpoints_reject_public_dns_names() -> None:
19
+ with pytest.raises(ValueError, match="内部服务名"):
20
+ SpeechRuntimeConfig.from_environment(
21
+ {
22
+ "SPEECH_SERVER_INTERNAL_TOKEN": "internal-token",
23
+ "SPEECH_ADAPTER_URL": "https://public.example.com",
24
+ }
25
+ )
26
+
27
+
28
+ def test_unconfigured_adapter_keeps_runtime_bootable() -> None:
29
+ config = SpeechRuntimeConfig.from_environment(
30
+ {"SPEECH_SERVER_INTERNAL_TOKEN": "internal-token"}
31
+ )
32
+ assert config.adapter_url is None
33
+ assert config.artifact_url == "http://server:9747/api/internal/ai/artifacts"