tina4-python 3.13.99__tar.gz → 3.13.101__tar.gz

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 (161) hide show
  1. {tina4_python-3.13.99 → tina4_python-3.13.101}/PKG-INFO +16 -1
  2. {tina4_python-3.13.99 → tina4_python-3.13.101}/README.md +15 -0
  3. {tina4_python-3.13.99 → tina4_python-3.13.101}/pyproject.toml +1 -1
  4. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/__init__.py +9 -1
  5. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/ai/__init__.py +31 -6
  6. tina4_python-3.13.101/tina4_python/ai/client.py +460 -0
  7. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/cli/__init__.py +0 -88
  8. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/dev_admin/__init__.py +0 -7
  9. tina4_python-3.13.101/tina4_python/dev_admin/metrics.py +118 -0
  10. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/frond/engine.py +236 -53
  11. tina4_python-3.13.99/tina4_python/dev_admin/metrics.py +0 -375
  12. {tina4_python-3.13.99 → tina4_python-3.13.101}/.gitignore +0 -0
  13. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/CLAUDE.md +0 -0
  14. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/HtmlElement.py +0 -0
  15. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/Testing.py +0 -0
  16. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/api/__init__.py +0 -0
  17. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/auth/__init__.py +0 -0
  18. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/cache/__init__.py +0 -0
  19. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/container/__init__.py +0 -0
  20. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/context/__init__.py +0 -0
  21. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/context/chunker.py +0 -0
  22. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/__init__.py +0 -0
  23. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/cache.py +0 -0
  24. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/constants.py +0 -0
  25. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/events.py +0 -0
  26. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/middleware.py +0 -0
  27. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/port_takeover.py +0 -0
  28. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/rate_limiter.py +0 -0
  29. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/request.py +0 -0
  30. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/response.py +0 -0
  31. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/router.py +0 -0
  32. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/server.py +0 -0
  33. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/crud/__init__.py +0 -0
  34. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/__init__.py +0 -0
  35. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/adapter.py +0 -0
  36. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/connection.py +0 -0
  37. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/database_url.py +0 -0
  38. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/firebird.py +0 -0
  39. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/mongodb.py +0 -0
  40. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/mssql.py +0 -0
  41. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/mysql.py +0 -0
  42. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/odbc.py +0 -0
  43. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/postgres.py +0 -0
  44. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/sql_translator.py +0 -0
  45. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/sqlite.py +0 -0
  46. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/debug/__init__.py +0 -0
  47. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/debug/error_overlay.py +0 -0
  48. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/dev_admin/plan.py +0 -0
  49. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/dev_admin/project_index.py +0 -0
  50. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/docs.py +0 -0
  51. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/docstore/__init__.py +0 -0
  52. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/dotenv/__init__.py +0 -0
  53. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/env.py +0 -0
  54. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/frond/FROND.md +0 -0
  55. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/frond/__init__.py +0 -0
  56. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/frond/compiler.py +0 -0
  57. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/frond/parser.py +0 -0
  58. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/auth/meta.json +0 -0
  59. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  60. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/database/meta.json +0 -0
  61. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  62. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/error-overlay/meta.json +0 -0
  63. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  64. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/orm/meta.json +0 -0
  65. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  66. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  67. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/queue/meta.json +0 -0
  68. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  69. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/rest-api/meta.json +0 -0
  70. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  71. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/templates/meta.json +0 -0
  72. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  73. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  74. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/graphql/__init__.py +0 -0
  75. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/i18n/__init__.py +0 -0
  76. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/mcp/__init__.py +0 -0
  77. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/mcp/protocol.py +0 -0
  78. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/mcp/tools.py +0 -0
  79. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/messenger/__init__.py +0 -0
  80. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/migration/__init__.py +0 -0
  81. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/migration/runner.py +0 -0
  82. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/mqtt/__init__.py +0 -0
  83. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/mqtt/message.py +0 -0
  84. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/orm/__init__.py +0 -0
  85. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/orm/fields.py +0 -0
  86. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/orm/model.py +0 -0
  87. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/__feedback/widget.js +0 -0
  88. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/css/tina4.css +0 -0
  89. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/css/tina4.min.css +0 -0
  90. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/favicon.ico +0 -0
  91. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/images/logo.svg +0 -0
  92. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  93. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/js/frond.js +0 -0
  94. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/js/frond.min.js +0 -0
  95. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  96. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/js/tina4.min.js +0 -0
  97. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/js/tina4js.min.js +0 -0
  98. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/swagger/index.html +0 -0
  99. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  100. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/query_builder/__init__.py +0 -0
  101. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/__init__.py +0 -0
  102. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/amqp_url.py +0 -0
  103. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/job.py +0 -0
  104. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/kafka_backend.py +0 -0
  105. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/lite_backend.py +0 -0
  106. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/mongo_backend.py +0 -0
  107. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/rabbitmq_backend.py +0 -0
  108. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue_backends/__init__.py +0 -0
  109. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue_backends/kafka_backend.py +0 -0
  110. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue_backends/mongo_backend.py +0 -0
  111. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  112. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/__init__.py +0 -0
  113. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/models/Attachment.py +0 -0
  114. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/models/Channel.py +0 -0
  115. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/models/ChannelMember.py +0 -0
  116. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/models/Message.py +0 -0
  117. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/models/Workspace.py +0 -0
  118. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/models/__init__.py +0 -0
  119. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/storage.py +0 -0
  120. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/seeder/__init__.py +0 -0
  121. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/service/__init__.py +0 -0
  122. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/session/__init__.py +0 -0
  123. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/session_handlers/__init__.py +0 -0
  124. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/session_handlers/memcached_handler.py +0 -0
  125. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  126. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/session_handlers/redis_handler.py +0 -0
  127. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/session_handlers/valkey_handler.py +0 -0
  128. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/swagger/__init__.py +0 -0
  129. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/components/crud.twig +0 -0
  130. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  131. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  132. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/docker/python/Dockerfile +0 -0
  133. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  134. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/302.twig +0 -0
  135. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/401.twig +0 -0
  136. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/403.twig +0 -0
  137. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/404.twig +0 -0
  138. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/500.twig +0 -0
  139. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/502.twig +0 -0
  140. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/503.twig +0 -0
  141. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/base.twig +0 -0
  142. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/frontend/README.md +0 -0
  143. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/readme.md +0 -0
  144. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/test/__init__.py +0 -0
  145. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/test_client/__init__.py +0 -0
  146. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  147. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  148. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  155. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  156. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  157. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  158. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/validator/__init__.py +0 -0
  159. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/websocket/__init__.py +0 -0
  160. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/websocket/backplane.py +0 -0
  161. {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tina4-python
3
- Version: 3.13.99
3
+ Version: 3.13.101
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -583,6 +583,21 @@ api = Api("https://api.example.com", auth_header="Bearer xyz")
583
583
  result = api.send_request("/users/42")
584
584
  ```
585
585
 
586
+ ### AI Client
587
+
588
+ ```python
589
+ from tina4_python import Ai
590
+
591
+ reply = Ai.chat([{"role": "user", "content": "Summarise this text"}])
592
+ text = Ai.complete("Give me a title")
593
+ vector = Ai.embed("semantic search text")
594
+
595
+ for delta in Ai.chat([{"role": "user", "content": "Stream this"}], stream=True):
596
+ print(delta, end="")
597
+ ```
598
+
599
+ Configure `TINA4_AI_PROVIDER` as `local`, `openai`, or `anthropic`. Hosted providers require `TINA4_AI_KEY`; local OpenAI-compatible endpoints do not.
600
+
586
601
  ### Data Seeder
587
602
 
588
603
  ```python
@@ -532,6 +532,21 @@ api = Api("https://api.example.com", auth_header="Bearer xyz")
532
532
  result = api.send_request("/users/42")
533
533
  ```
534
534
 
535
+ ### AI Client
536
+
537
+ ```python
538
+ from tina4_python import Ai
539
+
540
+ reply = Ai.chat([{"role": "user", "content": "Summarise this text"}])
541
+ text = Ai.complete("Give me a title")
542
+ vector = Ai.embed("semantic search text")
543
+
544
+ for delta in Ai.chat([{"role": "user", "content": "Stream this"}], stream=True):
545
+ print(delta, end="")
546
+ ```
547
+
548
+ Configure `TINA4_AI_PROVIDER` as `local`, `openai`, or `anthropic`. Hosted providers require `TINA4_AI_KEY`; local OpenAI-compatible endpoints do not.
549
+
535
550
  ### Data Seeder
536
551
 
537
552
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.99"
3
+ version = "3.13.101"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
51
51
  #
52
52
  # test_version_constant.py now asserts this literal equals the pyproject
53
53
  # version, so the release bump cannot leave it behind again.
54
- return "3.13.99"
54
+ return "3.13.101"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -133,6 +133,14 @@ _LAZY: dict[str, tuple[str, str]] = {
133
133
  "Container": ("tina4_python.container", "Container"),
134
134
  # HTTP client
135
135
  "Api": ("tina4_python.api", "Api"),
136
+ # App-facing AI client (ADR-0053)
137
+ "Ai": ("tina4_python.ai", "Ai"),
138
+ "ChatResponse": ("tina4_python.ai", "ChatResponse"),
139
+ "AiError": ("tina4_python.ai", "AiError"),
140
+ "AiConfigError": ("tina4_python.ai", "AiConfigError"),
141
+ "AiHTTPError": ("tina4_python.ai", "AiHTTPError"),
142
+ "AiTimeoutError": ("tina4_python.ai", "AiTimeoutError"),
143
+ "AiParseError": ("tina4_python.ai", "AiParseError"),
136
144
  # SOAP / WSDL
137
145
  "WSDL": ("tina4_python.wsdl", "WSDL"),
138
146
  "wsdl_operation": ("tina4_python.wsdl", "wsdl_operation"),
@@ -15,6 +15,16 @@ import shutil
15
15
  import subprocess
16
16
  from pathlib import Path
17
17
 
18
+ from .client import (
19
+ Ai,
20
+ AiConfigError,
21
+ AiError,
22
+ AiHTTPError,
23
+ AiParseError,
24
+ AiTimeoutError,
25
+ ChatResponse,
26
+ )
27
+
18
28
 
19
29
  # Ordered list of supported AI tools
20
30
  AI_TOOLS = [
@@ -67,14 +77,29 @@ def _skills_ref() -> str:
67
77
  return "main"
68
78
 
69
79
 
70
- def _fetch_bytes(url: str) -> "bytes | None":
80
+ def _fetch_bytes(url: str, attempts: int = 5) -> "bytes | None":
81
+ # raw.githubusercontent.com returns an intermittent 503 under load (a freshly
82
+ # cut tag is "cold" until GitHub warms its CDN), so a single failed fetch out of
83
+ # the ~30 an install makes should not abort it. Retry a transient status
84
+ # (429 / 5xx) and connection errors; a permanent 404 is never retried.
85
+ import time
71
86
  import urllib.error
72
87
  import urllib.request
73
- try:
74
- with urllib.request.urlopen(url, timeout=15) as resp:
75
- return resp.read()
76
- except (urllib.error.URLError, OSError):
77
- return None
88
+ for attempt in range(attempts):
89
+ try:
90
+ with urllib.request.urlopen(url, timeout=15) as resp:
91
+ return resp.read()
92
+ except urllib.error.HTTPError as exc:
93
+ if exc.code in (429, 500, 502, 503, 504) and attempt < attempts - 1:
94
+ time.sleep(2 * (attempt + 1))
95
+ continue
96
+ return None
97
+ except (urllib.error.URLError, OSError):
98
+ if attempt < attempts - 1:
99
+ time.sleep(2 * (attempt + 1))
100
+ continue
101
+ return None
102
+ return None
78
103
 
79
104
 
80
105
  def install_skills(root: str = ".", targets: "list[Path] | None" = None) -> list[str]:
@@ -0,0 +1,460 @@
1
+ """Zero-dependency app-facing AI client (ADR-0053)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.client
6
+ import json
7
+ import os
8
+ import socket
9
+ import ssl
10
+ import time
11
+ from dataclasses import dataclass
12
+ from typing import Any, Iterator
13
+ from urllib.parse import urlparse
14
+
15
+
16
+ class AiError(RuntimeError):
17
+ """Base error for the app-facing AI client."""
18
+
19
+
20
+ class AiConfigError(AiError):
21
+ """Invalid or incomplete AI client configuration."""
22
+
23
+
24
+ class AiHTTPError(AiError):
25
+ """Provider or transport failure with no usable response."""
26
+
27
+ def __init__(self, message: str, status: int | None = None):
28
+ super().__init__(message)
29
+ self.status = status
30
+
31
+
32
+ class AiTimeoutError(AiError):
33
+ """The connection or total request deadline expired."""
34
+
35
+
36
+ class AiParseError(AiError):
37
+ """A successful provider response did not satisfy its wire contract."""
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ChatResponse:
42
+ text: str
43
+ model: str
44
+ usage: dict[str, int]
45
+ finish_reason: str | None
46
+ raw: dict[str, Any]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class _Config:
51
+ provider: str
52
+ url: str
53
+ model: str
54
+ key: str | None
55
+ total_timeout: float
56
+ connect_timeout: float
57
+ max_retries: int
58
+
59
+
60
+ class _TransportResponse:
61
+ def __init__(self, connection: http.client.HTTPConnection, response: http.client.HTTPResponse):
62
+ self.connection = connection
63
+ self.response = response
64
+
65
+ def close(self) -> None:
66
+ try:
67
+ self.response.close()
68
+ finally:
69
+ self.connection.close()
70
+
71
+
72
+ class Ai:
73
+ """Provider-neutral AI calls configured through ``TINA4_AI_*``.
74
+
75
+ Methods are static so the smallest useful form stays ``Ai.chat(...)``.
76
+ Explicit call options beat environment values, which beat defaults.
77
+ """
78
+
79
+ _PROVIDERS = {"local", "openai", "anthropic"}
80
+
81
+ @staticmethod
82
+ def chat(
83
+ messages: list[dict[str, Any]],
84
+ *,
85
+ model: str | None = None,
86
+ temperature: float | None = None,
87
+ max_tokens: int | None = None,
88
+ stream: bool = False,
89
+ timeout: float | None = None,
90
+ provider: str | None = None,
91
+ ) -> ChatResponse | Iterator[str]:
92
+ Ai._validate_messages(messages)
93
+ config = Ai._config("chat", model=model, timeout=timeout, provider=provider)
94
+ body = Ai._chat_body(config, messages, temperature, max_tokens, stream)
95
+ headers = Ai._headers(config)
96
+ if stream:
97
+ return Ai._stream(config, headers, body)
98
+ raw = Ai._request_json(config, headers, body)
99
+ return Ai._normalize_chat(config.provider, raw)
100
+
101
+ @staticmethod
102
+ def complete(prompt: str, **options: Any) -> str:
103
+ if not isinstance(prompt, str):
104
+ raise AiConfigError("AI prompt must be a string")
105
+ options.pop("stream", None)
106
+ response = Ai.chat([{"role": "user", "content": prompt}], stream=False, **options)
107
+ assert isinstance(response, ChatResponse)
108
+ return response.text
109
+
110
+ @staticmethod
111
+ def embed(
112
+ text_or_texts: str | list[str],
113
+ *,
114
+ model: str | None = None,
115
+ timeout: float | None = None,
116
+ provider: str | None = None,
117
+ ) -> list[float] | list[list[float]]:
118
+ single = isinstance(text_or_texts, str)
119
+ if not single and not (
120
+ isinstance(text_or_texts, list)
121
+ and text_or_texts
122
+ and all(isinstance(item, str) for item in text_or_texts)
123
+ ):
124
+ raise AiConfigError("AI embedding input must be a string or a non-empty list of strings")
125
+ config = Ai._config("embed", model=model, timeout=timeout, provider=provider)
126
+ if config.provider == "anthropic":
127
+ raise AiConfigError("Anthropic does not provide the embedding endpoint in this contract")
128
+ body = {"model": config.model, "input": text_or_texts}
129
+ raw = Ai._request_json(config, Ai._headers(config), body)
130
+ try:
131
+ ordered = sorted(raw["data"], key=lambda item: item.get("index", 0))
132
+ vectors = [item["embedding"] for item in ordered]
133
+ if not vectors or not all(
134
+ isinstance(vector, list)
135
+ and vector
136
+ and all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in vector)
137
+ for vector in vectors
138
+ ):
139
+ raise ValueError
140
+ expected = 1 if single else len(text_or_texts)
141
+ if len(vectors) != expected:
142
+ raise ValueError
143
+ except (KeyError, TypeError, ValueError):
144
+ raise AiParseError("AI provider returned a malformed embedding response") from None
145
+ return vectors[0] if single else vectors
146
+
147
+ @staticmethod
148
+ def _validate_messages(messages: list[dict[str, Any]]) -> None:
149
+ if not isinstance(messages, list) or not messages:
150
+ raise AiConfigError("AI messages must be a non-empty list")
151
+ for message in messages:
152
+ if not isinstance(message, dict):
153
+ raise AiConfigError("Each AI message must be an object")
154
+ if message.get("role") not in {"system", "user", "assistant"}:
155
+ raise AiConfigError("Each AI message needs a supported role")
156
+ if not isinstance(message.get("content"), str):
157
+ raise AiConfigError("Each AI message needs string content")
158
+
159
+ @staticmethod
160
+ def _number(name: str, default: str, *, minimum: float, integer: bool = False) -> float | int:
161
+ raw = os.environ.get(name, default)
162
+ try:
163
+ value = int(raw) if integer else float(raw)
164
+ except (TypeError, ValueError):
165
+ raise AiConfigError(f"{name} must be numeric") from None
166
+ if value < minimum:
167
+ raise AiConfigError(f"{name} must be at least {minimum:g}")
168
+ return value
169
+
170
+ @staticmethod
171
+ def _config(capability: str, *, model: str | None, timeout: float | None, provider: str | None) -> _Config:
172
+ selected = (provider or os.environ.get("TINA4_AI_PROVIDER") or "local").strip().lower()
173
+ if selected not in Ai._PROVIDERS:
174
+ raise AiConfigError("TINA4_AI_PROVIDER must be local, openai, or anthropic")
175
+ key = os.environ.get("TINA4_AI_KEY") or None
176
+ if selected in {"openai", "anthropic"} and not key:
177
+ raise AiConfigError(f"TINA4_AI_KEY is required for the {selected} provider")
178
+
179
+ defaults = {
180
+ "local": ("http://localhost:11437", "llama3.2"),
181
+ "openai": ("https://api.openai.com/v1", "gpt-4o-mini"),
182
+ "anthropic": ("https://api.anthropic.com/v1", "claude-3-5-haiku-latest"),
183
+ }
184
+ base, default_model = defaults[selected]
185
+ if capability == "embed" and os.environ.get("TINA4_EMBED_URL"):
186
+ url = os.environ["TINA4_EMBED_URL"]
187
+ else:
188
+ url = os.environ.get("TINA4_AI_URL") or base
189
+ url = Ai._endpoint(url, capability, selected)
190
+ chosen_model = model if model is not None else (os.environ.get("TINA4_AI_MODEL") or default_model)
191
+ if not isinstance(chosen_model, str) or not chosen_model.strip():
192
+ raise AiConfigError("AI model must be a non-empty string")
193
+
194
+ total = float(timeout) if timeout is not None else float(Ai._number("TINA4_AI_TIMEOUT", "60", minimum=0.001))
195
+ if total <= 0:
196
+ raise AiConfigError("AI timeout must be greater than zero")
197
+ connect = float(Ai._number("TINA4_AI_CONNECT_TIMEOUT", "10", minimum=0.001))
198
+ retries = int(Ai._number("TINA4_AI_MAX_RETRIES", "2", minimum=0, integer=True))
199
+ return _Config(selected, url, chosen_model.strip(), key, total, connect, retries)
200
+
201
+ @staticmethod
202
+ def _endpoint(value: str, capability: str, provider: str) -> str:
203
+ parsed = urlparse(value)
204
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
205
+ raise AiConfigError("AI URL must be an http or https URL")
206
+ path = parsed.path.rstrip("/")
207
+ if provider == "anthropic":
208
+ suffix = "/messages"
209
+ elif capability == "embed":
210
+ suffix = "/embeddings"
211
+ else:
212
+ suffix = "/chat/completions"
213
+ if path in {"", "/v1", "/api"}:
214
+ prefix = path or ("/v1" if provider in {"local", "openai"} else "/v1")
215
+ path = prefix + suffix
216
+ parsed = parsed._replace(path=path)
217
+ return parsed.geturl()
218
+ return value
219
+
220
+ @staticmethod
221
+ def _headers(config: _Config) -> dict[str, str]:
222
+ headers = {"content-type": "application/json", "accept": "application/json"}
223
+ if config.provider == "openai" and config.key:
224
+ headers["authorization"] = f"Bearer {config.key}"
225
+ elif config.provider == "anthropic" and config.key:
226
+ headers["x-api-key"] = config.key
227
+ headers["anthropic-version"] = "2023-06-01"
228
+ return headers
229
+
230
+ @staticmethod
231
+ def _chat_body(
232
+ config: _Config,
233
+ messages: list[dict[str, Any]],
234
+ temperature: float | None,
235
+ max_tokens: int | None,
236
+ stream: bool,
237
+ ) -> dict[str, Any]:
238
+ body: dict[str, Any] = {"model": config.model, "messages": messages, "stream": stream}
239
+ if temperature is not None:
240
+ body["temperature"] = temperature
241
+ if max_tokens is not None:
242
+ body["max_tokens"] = max_tokens
243
+ if config.provider == "anthropic":
244
+ system = [message["content"] for message in messages if message["role"] == "system"]
245
+ body["messages"] = [message for message in messages if message["role"] != "system"]
246
+ body["max_tokens"] = max_tokens if max_tokens is not None else 1024
247
+ if system:
248
+ body["system"] = "\n\n".join(system)
249
+ return body
250
+
251
+ @staticmethod
252
+ def _connect(config: _Config, deadline: float, body: dict[str, Any], headers: dict[str, str]) -> _TransportResponse:
253
+ parsed = urlparse(config.url)
254
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
255
+ remaining = deadline - time.monotonic()
256
+ if remaining <= 0:
257
+ raise AiTimeoutError("AI total request timeout expired")
258
+ connect_timeout = min(config.connect_timeout, remaining)
259
+ connection_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
260
+ kwargs: dict[str, Any] = {"timeout": connect_timeout}
261
+ if parsed.scheme == "https":
262
+ kwargs["context"] = ssl.create_default_context()
263
+ connection = connection_cls(parsed.hostname, port, **kwargs)
264
+ try:
265
+ try:
266
+ connection.connect()
267
+ except socket.timeout:
268
+ raise AiTimeoutError("AI connection timeout expired") from None
269
+ remaining = deadline - time.monotonic()
270
+ if remaining <= 0:
271
+ raise AiTimeoutError("AI total request timeout expired")
272
+ if connection.sock is not None:
273
+ connection.sock.settimeout(remaining)
274
+ payload = json.dumps(body, separators=(",", ":")).encode()
275
+ path = parsed.path or "/"
276
+ if parsed.query:
277
+ path += "?" + parsed.query
278
+ connection.request("POST", path, body=payload, headers=headers)
279
+ response = connection.getresponse()
280
+ return _TransportResponse(connection, response)
281
+ except socket.timeout:
282
+ connection.close()
283
+ raise AiTimeoutError("AI total request timeout expired") from None
284
+ except AiError:
285
+ connection.close()
286
+ raise
287
+ except (OSError, http.client.HTTPException) as exc:
288
+ connection.close()
289
+ raise AiHTTPError(f"AI transport failed ({type(exc).__name__})") from None
290
+
291
+ @staticmethod
292
+ def _retry_delay(response: http.client.HTTPResponse, deadline: float) -> float:
293
+ raw = response.getheader("retry-after")
294
+ try:
295
+ requested = max(0.0, float(raw)) if raw is not None else 0.1
296
+ except ValueError:
297
+ requested = 0.1
298
+ return min(requested, max(0.0, deadline - time.monotonic()))
299
+
300
+ @staticmethod
301
+ def _request_json(config: _Config, headers: dict[str, str], body: dict[str, Any]) -> dict[str, Any]:
302
+ deadline = time.monotonic() + config.total_timeout
303
+ last_error: AiError | None = None
304
+ for attempt in range(config.max_retries + 1):
305
+ transport: _TransportResponse | None = None
306
+ try:
307
+ transport = Ai._connect(config, deadline, body, headers)
308
+ response = transport.response
309
+ status = response.status
310
+ remaining = deadline - time.monotonic()
311
+ if remaining <= 0:
312
+ raise AiTimeoutError("AI total request timeout expired")
313
+ if transport.connection.sock is not None:
314
+ transport.connection.sock.settimeout(remaining)
315
+ raw_body = response.read()
316
+ if status < 200 or status >= 300:
317
+ error = AiHTTPError(f"AI provider returned HTTP {status}", status=status)
318
+ if status == 429 or status >= 500:
319
+ last_error = error
320
+ if attempt < config.max_retries:
321
+ delay = Ai._retry_delay(response, deadline)
322
+ transport.close()
323
+ transport = None
324
+ if delay:
325
+ time.sleep(delay)
326
+ continue
327
+ raise error
328
+ try:
329
+ parsed = json.loads(raw_body)
330
+ except (UnicodeDecodeError, json.JSONDecodeError):
331
+ raise AiParseError("AI provider returned malformed JSON") from None
332
+ if not isinstance(parsed, dict):
333
+ raise AiParseError("AI provider returned a non-object JSON response")
334
+ return parsed
335
+ except (AiTimeoutError, AiHTTPError) as exc:
336
+ last_error = exc
337
+ if isinstance(exc, AiHTTPError) and exc.status is not None:
338
+ raise
339
+ if attempt >= config.max_retries:
340
+ raise
341
+ except socket.timeout:
342
+ last_error = AiTimeoutError("AI total request timeout expired")
343
+ if attempt >= config.max_retries:
344
+ raise last_error from None
345
+ finally:
346
+ if transport is not None:
347
+ transport.close()
348
+ raise last_error or AiHTTPError("AI request failed")
349
+
350
+ @staticmethod
351
+ def _normalize_chat(provider: str, raw: dict[str, Any]) -> ChatResponse:
352
+ try:
353
+ if provider == "anthropic":
354
+ text_parts = [item["text"] for item in raw["content"] if item.get("type", "text") == "text"]
355
+ if not text_parts:
356
+ raise ValueError
357
+ input_tokens = int(raw.get("usage", {}).get("input_tokens", 0))
358
+ output_tokens = int(raw.get("usage", {}).get("output_tokens", 0))
359
+ usage = {
360
+ "prompt_tokens": input_tokens,
361
+ "completion_tokens": output_tokens,
362
+ "total_tokens": input_tokens + output_tokens,
363
+ }
364
+ return ChatResponse("".join(text_parts), str(raw.get("model", "")), usage, raw.get("stop_reason"), raw)
365
+ choice = raw["choices"][0]
366
+ text = choice["message"]["content"]
367
+ if not isinstance(text, str):
368
+ raise ValueError
369
+ provider_usage = raw.get("usage", {})
370
+ usage = {
371
+ "prompt_tokens": int(provider_usage.get("prompt_tokens", 0)),
372
+ "completion_tokens": int(provider_usage.get("completion_tokens", 0)),
373
+ "total_tokens": int(provider_usage.get("total_tokens", 0)),
374
+ }
375
+ return ChatResponse(text, str(raw.get("model", "")), usage, choice.get("finish_reason"), raw)
376
+ except (KeyError, IndexError, TypeError, ValueError):
377
+ raise AiParseError("AI provider returned a malformed chat response") from None
378
+
379
+ @staticmethod
380
+ def _stream_delta(provider: str, data: str) -> tuple[bool, str | None]:
381
+ if data == "[DONE]":
382
+ return True, None
383
+ try:
384
+ event = json.loads(data)
385
+ if provider == "anthropic":
386
+ delta = event.get("delta", {})
387
+ text = delta.get("text") if event.get("type") == "content_block_delta" else None
388
+ else:
389
+ text = event.get("choices", [{}])[0].get("delta", {}).get("content")
390
+ except (json.JSONDecodeError, IndexError, TypeError, UnicodeDecodeError):
391
+ raise AiParseError("AI provider returned malformed stream data") from None
392
+ if text is not None and not isinstance(text, str):
393
+ raise AiParseError("AI provider returned malformed stream data")
394
+ return False, text
395
+
396
+ @staticmethod
397
+ def _stream_data(transport: _TransportResponse, deadline: float) -> Iterator[str]:
398
+ while True:
399
+ remaining = deadline - time.monotonic()
400
+ if remaining <= 0:
401
+ raise AiTimeoutError("AI total request timeout expired")
402
+ if transport.connection.sock is not None:
403
+ transport.connection.sock.settimeout(remaining)
404
+ line = transport.response.readline()
405
+ if not line:
406
+ return
407
+ stripped = line.decode("utf-8", errors="strict").strip()
408
+ if stripped.startswith("data:"):
409
+ yield stripped[5:].strip()
410
+
411
+ @staticmethod
412
+ def _stream(config: _Config, headers: dict[str, str], body: dict[str, Any]) -> Iterator[str]:
413
+ def iterator() -> Iterator[str]:
414
+ deadline = time.monotonic() + config.total_timeout
415
+ yielded = False
416
+ for attempt in range(config.max_retries + 1):
417
+ transport: _TransportResponse | None = None
418
+ try:
419
+ stream_headers = {**headers, "accept": "text/event-stream"}
420
+ transport = Ai._connect(config, deadline, body, stream_headers)
421
+ response = transport.response
422
+ if response.status < 200 or response.status >= 300:
423
+ status = response.status
424
+ response.read()
425
+ error = AiHTTPError(f"AI provider returned HTTP {status}", status=status)
426
+ if (status == 429 or status >= 500) and attempt < config.max_retries:
427
+ delay = Ai._retry_delay(response, deadline)
428
+ transport.close()
429
+ transport = None
430
+ if delay:
431
+ time.sleep(delay)
432
+ continue
433
+ raise error
434
+ completed = False
435
+ for data in Ai._stream_data(transport, deadline):
436
+ completed, text = Ai._stream_delta(config.provider, data)
437
+ if completed:
438
+ break
439
+ if text is not None:
440
+ yielded = True
441
+ yield text
442
+ if completed:
443
+ return
444
+ raise AiParseError("AI provider stream ended before [DONE]")
445
+ except (AiHTTPError, AiTimeoutError):
446
+ if yielded or attempt >= config.max_retries:
447
+ raise
448
+ except (socket.timeout, OSError, http.client.HTTPException) as exc:
449
+ error: AiError = (
450
+ AiTimeoutError("AI total request timeout expired")
451
+ if isinstance(exc, socket.timeout)
452
+ else AiHTTPError(f"AI transport failed ({type(exc).__name__})")
453
+ )
454
+ if yielded or attempt >= config.max_retries:
455
+ raise error from None
456
+ finally:
457
+ if transport is not None:
458
+ transport.close()
459
+
460
+ return iterator()