tina4-python 3.13.113__tar.gz → 3.13.114__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 (169) hide show
  1. {tina4_python-3.13.113 → tina4_python-3.13.114}/PKG-INFO +1 -1
  2. {tina4_python-3.13.113 → tina4_python-3.13.114}/pyproject.toml +1 -1
  3. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/__init__.py +1 -1
  4. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/ai/client.py +367 -26
  5. {tina4_python-3.13.113 → tina4_python-3.13.114}/.gitignore +0 -0
  6. {tina4_python-3.13.113 → tina4_python-3.13.114}/README.md +0 -0
  7. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/CLAUDE.md +0 -0
  8. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/HtmlElement.py +0 -0
  9. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/Testing.py +0 -0
  10. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/ai/__init__.py +0 -0
  11. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/api/__init__.py +0 -0
  12. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/auth/__init__.py +0 -0
  13. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/cache/__init__.py +0 -0
  14. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/cli/__init__.py +0 -0
  15. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/container/__init__.py +0 -0
  16. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/context/__init__.py +0 -0
  17. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/context/chunker.py +0 -0
  18. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/core/__init__.py +0 -0
  19. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/core/cache.py +0 -0
  20. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/core/constants.py +0 -0
  21. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/core/events.py +0 -0
  22. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/core/middleware.py +0 -0
  23. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/core/port_takeover.py +0 -0
  24. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/core/rate_limiter.py +0 -0
  25. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/core/request.py +0 -0
  26. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/core/response.py +0 -0
  27. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/core/router.py +0 -0
  28. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/core/server.py +0 -0
  29. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/crud/__init__.py +0 -0
  30. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/database/__init__.py +0 -0
  31. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/database/adapter.py +0 -0
  32. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/database/connection.py +0 -0
  33. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/database/database_url.py +0 -0
  34. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/database/firebird.py +0 -0
  35. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/database/mongodb.py +0 -0
  36. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/database/mssql.py +0 -0
  37. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/database/mysql.py +0 -0
  38. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/database/odbc.py +0 -0
  39. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/database/postgres.py +0 -0
  40. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/database/sql_translator.py +0 -0
  41. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/database/sqlite.py +0 -0
  42. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/debug/__init__.py +0 -0
  43. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/debug/error_overlay.py +0 -0
  44. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/dev_admin/__init__.py +0 -0
  45. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/dev_admin/metrics.py +0 -0
  46. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/dev_admin/plan.py +0 -0
  47. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/dev_admin/project_index.py +0 -0
  48. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/docs.py +0 -0
  49. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/docstore/__init__.py +0 -0
  50. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/dotenv/__init__.py +0 -0
  51. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/env.py +0 -0
  52. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/frond/FROND.md +0 -0
  53. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/frond/__init__.py +0 -0
  54. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/frond/compiler.py +0 -0
  55. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/frond/engine.py +0 -0
  56. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/frond/parser.py +0 -0
  57. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/auth/meta.json +0 -0
  58. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  59. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/database/meta.json +0 -0
  60. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  61. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/error-overlay/meta.json +0 -0
  62. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  63. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/orm/meta.json +0 -0
  64. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  65. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  66. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/queue/meta.json +0 -0
  67. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  68. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/rest-api/meta.json +0 -0
  69. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  70. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/templates/meta.json +0 -0
  71. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  72. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  73. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/graph/__init__.py +0 -0
  74. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/graph/adapter.py +0 -0
  75. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/graph/adapters/__init__.py +0 -0
  76. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/graph/adapters/arango.py +0 -0
  77. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/graph/adapters/bolt.py +0 -0
  78. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/graph/adapters/ultipa.py +0 -0
  79. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/graph/graph_url.py +0 -0
  80. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/graphql/__init__.py +0 -0
  81. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/i18n/__init__.py +0 -0
  82. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/mcp/__init__.py +0 -0
  83. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/mcp/protocol.py +0 -0
  84. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/mcp/tools.py +0 -0
  85. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/messenger/__init__.py +0 -0
  86. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/migration/__init__.py +0 -0
  87. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/migration/runner.py +0 -0
  88. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/mqtt/__init__.py +0 -0
  89. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/mqtt/message.py +0 -0
  90. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/orm/__init__.py +0 -0
  91. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/orm/fields.py +0 -0
  92. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/orm/model.py +0 -0
  93. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/orm/point.py +0 -0
  94. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/public/__feedback/widget.js +0 -0
  95. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/public/css/tina4.css +0 -0
  96. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/public/css/tina4.min.css +0 -0
  97. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/public/favicon.ico +0 -0
  98. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/public/images/logo.svg +0 -0
  99. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  100. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/public/js/frond.js +0 -0
  101. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/public/js/frond.min.js +0 -0
  102. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  103. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/public/js/tina4.min.js +0 -0
  104. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/public/js/tina4js.min.js +0 -0
  105. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/public/swagger/index.html +0 -0
  106. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  107. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/query_builder/__init__.py +0 -0
  108. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/queue/__init__.py +0 -0
  109. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/queue/amqp_url.py +0 -0
  110. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/queue/job.py +0 -0
  111. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/queue/kafka_backend.py +0 -0
  112. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/queue/lite_backend.py +0 -0
  113. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/queue/mongo_backend.py +0 -0
  114. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/queue/rabbitmq_backend.py +0 -0
  115. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/queue_backends/__init__.py +0 -0
  116. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/queue_backends/kafka_backend.py +0 -0
  117. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/queue_backends/mongo_backend.py +0 -0
  118. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  119. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/realtime/__init__.py +0 -0
  120. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/realtime/models/Attachment.py +0 -0
  121. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/realtime/models/Channel.py +0 -0
  122. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/realtime/models/ChannelMember.py +0 -0
  123. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/realtime/models/Message.py +0 -0
  124. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/realtime/models/Workspace.py +0 -0
  125. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/realtime/models/__init__.py +0 -0
  126. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/realtime/storage.py +0 -0
  127. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/seeder/__init__.py +0 -0
  128. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/service/__init__.py +0 -0
  129. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/session/__init__.py +0 -0
  130. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/session_handlers/__init__.py +0 -0
  131. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/session_handlers/memcached_handler.py +0 -0
  132. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  133. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/session_handlers/redis_handler.py +0 -0
  134. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/session_handlers/valkey_handler.py +0 -0
  135. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/sso.py +0 -0
  136. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/swagger/__init__.py +0 -0
  137. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/components/crud.twig +0 -0
  138. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  139. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  140. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/docker/python/Dockerfile +0 -0
  141. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  142. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/errors/302.twig +0 -0
  143. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/errors/401.twig +0 -0
  144. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/errors/403.twig +0 -0
  145. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/errors/404.twig +0 -0
  146. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/errors/500.twig +0 -0
  147. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/errors/502.twig +0 -0
  148. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/errors/503.twig +0 -0
  149. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/errors/base.twig +0 -0
  150. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/frontend/README.md +0 -0
  151. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/templates/readme.md +0 -0
  152. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/test/__init__.py +0 -0
  153. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/test_client/__init__.py +0 -0
  154. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  155. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  156. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  157. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  158. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  159. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  160. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  161. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  162. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  163. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  164. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  165. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  166. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/validator/__init__.py +0 -0
  167. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/websocket/__init__.py +0 -0
  168. {tina4_python-3.13.113 → tina4_python-3.13.114}/tina4_python/websocket/backplane.py +0 -0
  169. {tina4_python-3.13.113 → tina4_python-3.13.114}/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.113
3
+ Version: 3.13.114
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.113"
3
+ version = "3.13.114"
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.113"
54
+ return "3.13.114"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -110,16 +110,45 @@ class Ai:
110
110
  stream: bool = False,
111
111
  timeout: float | None = None,
112
112
  provider: str | None = None,
113
+ tools: list[dict[str, Any]] | None = None,
114
+ tool_choice: str | dict[str, Any] | None = None,
113
115
  ) -> "ChatResponse | Iterator[AiEvent]":
114
116
  """Send a chat completion request.
115
117
 
116
118
  ``stream=True`` returns an iterator of ``AiEvent`` records
117
119
  (``text_delta`` / ``tool_call`` / ``done`` / ``error``) per
118
120
  ADR-0060; ``stream=False`` returns a single ``ChatResponse``.
121
+
122
+ ``tools`` (ADR-0061) is an optional list of neutral tool
123
+ declarations - each ``{"name", "description", "parameters"}``
124
+ where ``parameters`` is a JSON Schema object. The client
125
+ translates the list to the current provider's outbound body:
126
+ OpenAI/local gets ``[{"type": "function", "function":
127
+ {"name", "description", "parameters"}}]``; Anthropic gets
128
+ ``[{"name", "description", "input_schema"}]``.
129
+
130
+ ``tool_choice`` (ADR-0061) is one of ``"auto"``, ``"none"``,
131
+ ``"required"``, or ``{"name": str}``. The client translates
132
+ each value to the provider's shape (OpenAI's ``tool_choice``
133
+ keyword; Anthropic's ``tool_choice`` object). On Anthropic
134
+ ``"none"`` the ``tools`` field is omitted entirely (Anthropic
135
+ has no ``"none"`` mode).
136
+
137
+ Messages may carry a tool result in either OpenAI form
138
+ (``{"role": "tool", "tool_call_id", "content"}``) or
139
+ Anthropic form (a user turn with ``{"type": "tool_result",
140
+ "tool_use_id", "content"}`` content parts). The client
141
+ normalises to whichever the current provider expects, so the
142
+ agent loop stays provider-neutral.
119
143
  """
120
144
  Ai._validate_messages(messages)
145
+ Ai._validate_tools(tools)
146
+ Ai._validate_tool_choice(tool_choice)
121
147
  config = Ai._config("chat", model=model, timeout=timeout, provider=provider)
122
- body = Ai._chat_body(config, messages, temperature, max_tokens, stream)
148
+ body = Ai._chat_body(
149
+ config, messages, temperature, max_tokens, stream,
150
+ tools=tools, tool_choice=tool_choice,
151
+ )
123
152
  headers = Ai._headers(config)
124
153
  if stream:
125
154
  return Ai._stream(config, headers, body)
@@ -179,18 +208,103 @@ class Ai:
179
208
  for message in messages:
180
209
  if not isinstance(message, dict):
181
210
  raise AiConfigError("Each AI message must be an object")
182
- if message.get("role") not in {"system", "user", "assistant"}:
211
+ role = message.get("role")
212
+ if role not in {"system", "user", "assistant", "tool"}:
183
213
  raise AiConfigError("Each AI message needs a supported role")
214
+ if role == "tool":
215
+ # ADR-0061: OpenAI-style tool-result message.
216
+ tool_call_id = message.get("tool_call_id")
217
+ if not isinstance(tool_call_id, str) or not tool_call_id:
218
+ raise AiConfigError(
219
+ "tool message needs a non-empty 'tool_call_id' string")
220
+ if not isinstance(message.get("content"), str):
221
+ raise AiConfigError("tool message needs a string 'content'")
222
+ continue
223
+ if role == "assistant" and message.get("tool_calls") is not None:
224
+ # OpenAI-style assistant tool_calls; content may be None/absent.
225
+ Ai._validate_tool_calls(message["tool_calls"])
226
+ content = message.get("content")
227
+ if content is not None:
228
+ Ai._validate_content(content)
229
+ continue
184
230
  Ai._validate_content(message.get("content"))
185
231
 
232
+ @staticmethod
233
+ def _validate_tool_calls(tool_calls: Any) -> None:
234
+ """Validate an OpenAI-style assistant.tool_calls list (ADR-0061)."""
235
+ if not isinstance(tool_calls, list) or not tool_calls:
236
+ raise AiConfigError("assistant tool_calls must be a non-empty list")
237
+ for tc in tool_calls:
238
+ if not isinstance(tc, dict):
239
+ raise AiConfigError("Each tool_call must be an object")
240
+ if not isinstance(tc.get("id"), str) or not tc["id"]:
241
+ raise AiConfigError(
242
+ "tool_call needs a non-empty 'id' string")
243
+ fn = tc.get("function")
244
+ if not isinstance(fn, dict):
245
+ raise AiConfigError("tool_call needs a 'function' object")
246
+ if not isinstance(fn.get("name"), str) or not fn["name"]:
247
+ raise AiConfigError(
248
+ "tool_call function needs a non-empty 'name' string")
249
+ args = fn.get("arguments")
250
+ if not isinstance(args, (str, dict)):
251
+ raise AiConfigError(
252
+ "tool_call function 'arguments' must be a JSON string or object")
253
+
254
+ @staticmethod
255
+ def _validate_tools(tools: Any) -> None:
256
+ """Validate an outbound tools list (ADR-0061)."""
257
+ if tools is None:
258
+ return
259
+ if not isinstance(tools, list) or not tools:
260
+ raise AiConfigError(
261
+ "AI tools must be a non-empty list when provided")
262
+ for tool in tools:
263
+ if not isinstance(tool, dict):
264
+ raise AiConfigError("Each AI tool must be an object")
265
+ name = tool.get("name")
266
+ if not isinstance(name, str) or not name:
267
+ raise AiConfigError(
268
+ "Each AI tool needs a non-empty 'name' string")
269
+ description = tool.get("description")
270
+ if description is not None and not isinstance(description, str):
271
+ raise AiConfigError(
272
+ "AI tool 'description' must be a string when provided")
273
+ params = tool.get("parameters")
274
+ if params is not None and not isinstance(params, dict):
275
+ raise AiConfigError(
276
+ "AI tool 'parameters' must be a JSON Schema object")
277
+
278
+ @staticmethod
279
+ def _validate_tool_choice(tool_choice: Any) -> None:
280
+ """Validate a tool_choice value (ADR-0061)."""
281
+ if tool_choice is None:
282
+ return
283
+ if isinstance(tool_choice, str):
284
+ if tool_choice not in ("auto", "none", "required"):
285
+ raise AiConfigError(
286
+ "AI tool_choice string must be 'auto', 'none', or 'required'")
287
+ return
288
+ if isinstance(tool_choice, dict):
289
+ name = tool_choice.get("name")
290
+ if not isinstance(name, str) or not name:
291
+ raise AiConfigError(
292
+ "AI tool_choice dict needs a non-empty 'name' string")
293
+ return
294
+ raise AiConfigError("AI tool_choice must be a string or dict")
295
+
186
296
  @staticmethod
187
297
  def _validate_content(content: Any) -> None:
188
298
  """Accept a plain string OR a non-empty list of content parts.
189
299
 
190
- Parts (ADR-0060 multimodal):
300
+ Parts (ADR-0060 multimodal + ADR-0061 tool-loop):
191
301
  - ``{"type": "text", "text": <str>}``
192
302
  - ``{"type": "image", "source": <str>}`` where ``source`` is a
193
303
  ``data:<media_type>;base64,<payload>`` URI or an http(s) URL.
304
+ - ``{"type": "tool_result", "tool_use_id": <str>, "content": <str>}``
305
+ (Anthropic-form tool-result inside a user turn).
306
+ - ``{"type": "tool_use", "id": <str>, "name": <str>, "input": <dict>}``
307
+ (Anthropic-form assistant tool-call block).
194
308
 
195
309
  Anything else raises ``AiConfigError`` before the request goes
196
310
  out. Both the OpenAI and Anthropic provider builders trust the
@@ -217,8 +331,28 @@ class Ai:
217
331
  or source.startswith("http://")):
218
332
  raise AiConfigError(
219
333
  "image part 'source' must be a data: URI or http(s) URL")
334
+ elif kind == "tool_result":
335
+ tool_use_id = part.get("tool_use_id")
336
+ if not isinstance(tool_use_id, str) or not tool_use_id:
337
+ raise AiConfigError(
338
+ "tool_result part needs a non-empty 'tool_use_id' string")
339
+ if not isinstance(part.get("content"), str):
340
+ raise AiConfigError(
341
+ "tool_result part needs a string 'content'")
342
+ elif kind == "tool_use":
343
+ if not isinstance(part.get("id"), str) or not part["id"]:
344
+ raise AiConfigError(
345
+ "tool_use part needs a non-empty 'id' string")
346
+ if not isinstance(part.get("name"), str) or not part["name"]:
347
+ raise AiConfigError(
348
+ "tool_use part needs a non-empty 'name' string")
349
+ input_val = part.get("input")
350
+ if input_val is not None and not isinstance(input_val, dict):
351
+ raise AiConfigError(
352
+ "tool_use part 'input' must be a JSON object")
220
353
  else:
221
- raise AiConfigError("Content part 'type' must be text or image")
354
+ raise AiConfigError(
355
+ "Content part 'type' must be text, image, tool_result, or tool_use")
222
356
 
223
357
  @staticmethod
224
358
  def _number(name: str, default: str, *, minimum: float, integer: bool = False) -> float | int:
@@ -298,14 +432,37 @@ class Ai:
298
432
  temperature: float | None,
299
433
  max_tokens: int | None,
300
434
  stream: bool,
435
+ *,
436
+ tools: list[dict[str, Any]] | None = None,
437
+ tool_choice: str | dict[str, Any] | None = None,
301
438
  ) -> dict[str, Any]:
302
- translated = [Ai._translate_message(config.provider, message)
303
- for message in messages]
439
+ translated: list[dict[str, Any]] = []
440
+ for message in messages:
441
+ result = Ai._translate_message(config.provider, message)
442
+ if isinstance(result, list):
443
+ translated.extend(result)
444
+ else:
445
+ translated.append(result)
304
446
  body: dict[str, Any] = {"model": config.model, "messages": translated, "stream": stream}
305
447
  if temperature is not None:
306
448
  body["temperature"] = temperature
307
449
  if max_tokens is not None:
308
450
  body["max_tokens"] = max_tokens
451
+
452
+ # Tools + tool_choice - ADR-0061. Anthropic has no "none" mode,
453
+ # so tool_choice='none' on Anthropic OMITS the tools field
454
+ # entirely (which achieves the same effect: the model cannot
455
+ # emit a tool_call because it does not know any tool exists).
456
+ omit_tools_for_anthropic_none = (
457
+ config.provider == "anthropic" and tool_choice == "none"
458
+ )
459
+ if tools and not omit_tools_for_anthropic_none:
460
+ body["tools"] = Ai._translate_tools(config.provider, tools)
461
+ if tool_choice is not None:
462
+ translated_choice = Ai._translate_tool_choice(config.provider, tool_choice)
463
+ if translated_choice is not None:
464
+ body["tool_choice"] = translated_choice
465
+
309
466
  if config.provider == "anthropic":
310
467
  # System messages are hoisted to the top-level `system` field on
311
468
  # Anthropic; only string system content is supported (multimodal
@@ -313,13 +470,13 @@ class Ai:
313
470
  system_texts: list[str] = []
314
471
  non_system: list[dict[str, Any]] = []
315
472
  for message in translated:
316
- if message["role"] == "system":
317
- content = message["content"]
473
+ if message.get("role") == "system":
474
+ content = message.get("content")
318
475
  if isinstance(content, str):
319
476
  system_texts.append(content)
320
477
  else:
321
478
  # Extract text parts only; drop images from system prompt.
322
- for part in content:
479
+ for part in content or []:
323
480
  if isinstance(part, dict) and part.get("type") == "text":
324
481
  system_texts.append(part.get("text", ""))
325
482
  else:
@@ -331,33 +488,217 @@ class Ai:
331
488
  return body
332
489
 
333
490
  @staticmethod
334
- def _translate_message(provider: str, message: dict[str, Any]) -> dict[str, Any]:
491
+ def _translate_tools(provider: str, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
492
+ """Translate a neutral tools list to the provider's shape (ADR-0061)."""
493
+ if provider == "anthropic":
494
+ return [
495
+ {
496
+ "name": tool["name"],
497
+ "description": tool.get("description", ""),
498
+ "input_schema": tool.get("parameters") or {},
499
+ }
500
+ for tool in tools
501
+ ]
502
+ # openai / local (llama.cpp, ollama openai-shim, etc.)
503
+ return [
504
+ {
505
+ "type": "function",
506
+ "function": {
507
+ "name": tool["name"],
508
+ "description": tool.get("description", ""),
509
+ "parameters": tool.get("parameters") or {},
510
+ },
511
+ }
512
+ for tool in tools
513
+ ]
514
+
515
+ @staticmethod
516
+ def _translate_tool_choice(provider: str, tool_choice: Any) -> Any:
517
+ """Translate a Tina4 tool_choice value to the provider's shape (ADR-0061).
518
+
519
+ Returns ``None`` when the caller's value maps to "omit the field"
520
+ for this provider (currently only Anthropic + ``"none"``).
521
+ """
522
+ if provider == "anthropic":
523
+ if tool_choice == "auto":
524
+ return {"type": "auto"}
525
+ if tool_choice == "none":
526
+ # Anthropic has no "none" mode; the effect is achieved by
527
+ # omitting the `tools` field in _chat_body. No tool_choice
528
+ # goes on the wire either.
529
+ return None
530
+ if tool_choice == "required":
531
+ return {"type": "any"}
532
+ if isinstance(tool_choice, dict) and isinstance(tool_choice.get("name"), str):
533
+ return {"type": "tool", "name": tool_choice["name"]}
534
+ return None
535
+ # openai / local
536
+ if tool_choice in ("auto", "none", "required"):
537
+ return tool_choice
538
+ if isinstance(tool_choice, dict) and isinstance(tool_choice.get("name"), str):
539
+ return {
540
+ "type": "function",
541
+ "function": {"name": tool_choice["name"]},
542
+ }
543
+ return None
544
+
545
+ @staticmethod
546
+ def _translate_message(
547
+ provider: str,
548
+ message: dict[str, Any],
549
+ ) -> dict[str, Any] | list[dict[str, Any]]:
335
550
  """Translate a Tina4-shape message to the provider-native shape.
336
551
 
337
- String content passes through unchanged in every provider. A
338
- list-of-parts message translates image parts to each provider's
339
- native shape and leaves text parts alone.
552
+ Returns a single message dict OR a list of dicts (a user turn
553
+ carrying multiple Anthropic tool_result parts becomes multiple
554
+ OpenAI ``role='tool'`` messages, one per part).
555
+
556
+ Handles:
557
+ - String content passthrough on every provider.
558
+ - Multimodal parts (text/image) translated per provider.
559
+ - Tool-result messages (OpenAI ``role='tool'`` form or
560
+ Anthropic user turn with ``tool_result`` parts) normalised
561
+ to the current provider's shape (ADR-0061).
562
+ - Assistant tool_calls (OpenAI form) or assistant content
563
+ with ``tool_use`` parts (Anthropic form) translated per
564
+ provider (ADR-0061).
340
565
  """
566
+ role = message.get("role")
567
+
568
+ # ADR-0061: OpenAI-style tool-result message.
569
+ if role == "tool":
570
+ if provider == "anthropic":
571
+ return {
572
+ "role": "user",
573
+ "content": [{
574
+ "type": "tool_result",
575
+ "tool_use_id": message["tool_call_id"],
576
+ "content": message["content"],
577
+ }],
578
+ }
579
+ # openai / local: passthrough, but keep only the wire fields.
580
+ return {
581
+ "role": "tool",
582
+ "tool_call_id": message["tool_call_id"],
583
+ "content": message["content"],
584
+ }
585
+
586
+ # ADR-0061: assistant tool_calls (OpenAI form).
587
+ if role == "assistant" and isinstance(message.get("tool_calls"), list):
588
+ if provider != "anthropic":
589
+ # openai / local: passthrough.
590
+ out: dict[str, Any] = {
591
+ "role": "assistant",
592
+ "tool_calls": message["tool_calls"],
593
+ }
594
+ content = message.get("content")
595
+ if content is None:
596
+ out["content"] = None
597
+ elif isinstance(content, str):
598
+ out["content"] = content
599
+ return out
600
+ # Anthropic: fold tool_calls into a content list with tool_use parts.
601
+ parts: list[dict[str, Any]] = []
602
+ content = message.get("content")
603
+ if isinstance(content, str) and content:
604
+ parts.append({"type": "text", "text": content})
605
+ for tc in message["tool_calls"]:
606
+ fn = tc.get("function", {})
607
+ args_val = fn.get("arguments")
608
+ if isinstance(args_val, str):
609
+ try:
610
+ parsed = json.loads(args_val) if args_val else {}
611
+ except (json.JSONDecodeError, ValueError):
612
+ parsed = {}
613
+ else:
614
+ parsed = args_val or {}
615
+ parts.append({
616
+ "type": "tool_use",
617
+ "id": tc.get("id"),
618
+ "name": fn.get("name"),
619
+ "input": parsed,
620
+ })
621
+ return {"role": "assistant", "content": parts}
622
+
623
+ # ADR-0061: Anthropic-form tool_result parts on a user turn.
624
+ if role == "user" and isinstance(message.get("content"), list):
625
+ has_tool_result = any(
626
+ isinstance(p, dict) and p.get("type") == "tool_result"
627
+ for p in message["content"]
628
+ )
629
+ if has_tool_result:
630
+ if provider == "anthropic":
631
+ # Passthrough — the content shape is native.
632
+ return {"role": "user", "content": list(message["content"])}
633
+ # openai / local: split into one role='tool' message per part.
634
+ # Non-tool_result parts (e.g. text) are dropped: OpenAI wants
635
+ # tool_result content on a role='tool' message alone.
636
+ results: list[dict[str, Any]] = []
637
+ for part in message["content"]:
638
+ if isinstance(part, dict) and part.get("type") == "tool_result":
639
+ results.append({
640
+ "role": "tool",
641
+ "tool_call_id": part["tool_use_id"],
642
+ "content": part.get("content", ""),
643
+ })
644
+ return results
645
+
646
+ # ADR-0061: Anthropic-form assistant with tool_use content parts.
647
+ if role == "assistant" and isinstance(message.get("content"), list):
648
+ has_tool_use = any(
649
+ isinstance(p, dict) and p.get("type") == "tool_use"
650
+ for p in message["content"]
651
+ )
652
+ if has_tool_use:
653
+ if provider == "anthropic":
654
+ return {"role": "assistant", "content": list(message["content"])}
655
+ # openai / local: fold tool_use parts into tool_calls.
656
+ tool_calls: list[dict[str, Any]] = []
657
+ text_parts: list[str] = []
658
+ for part in message["content"]:
659
+ if not isinstance(part, dict):
660
+ continue
661
+ if part.get("type") == "tool_use":
662
+ tool_calls.append({
663
+ "id": part.get("id"),
664
+ "type": "function",
665
+ "function": {
666
+ "name": part.get("name"),
667
+ "arguments": json.dumps(part.get("input") or {}),
668
+ },
669
+ })
670
+ elif part.get("type") == "text":
671
+ text_parts.append(part.get("text", ""))
672
+ out = {"role": "assistant", "tool_calls": tool_calls}
673
+ out["content"] = "".join(text_parts) if text_parts else None
674
+ return out
675
+
676
+ # Default: string content passthrough, absent content preserved,
677
+ # or multimodal parts translated per provider.
341
678
  content = message.get("content")
342
- if isinstance(content, str):
679
+ if isinstance(content, str) or content is None:
343
680
  return dict(message)
681
+
344
682
  translated: list[dict[str, Any]] = []
345
683
  for part in content or []:
346
- kind = part.get("type")
684
+ kind = part.get("type") if isinstance(part, dict) else None
347
685
  if kind == "text":
348
686
  translated.append({"type": "text", "text": part.get("text", "")})
349
687
  continue
350
- # kind == "image" — validated already
351
- source = part["source"]
352
- if provider == "anthropic":
353
- translated.append(Ai._anthropic_image_part(source))
354
- else:
355
- # openai + local (llama.cpp, ollama openai-shim, etc.) share
356
- # OpenAI's image_url shape.
357
- translated.append({
358
- "type": "image_url",
359
- "image_url": {"url": source},
360
- })
688
+ if kind == "image":
689
+ source = part["source"]
690
+ if provider == "anthropic":
691
+ translated.append(Ai._anthropic_image_part(source))
692
+ else:
693
+ # openai + local (llama.cpp, ollama openai-shim, etc.) share
694
+ # OpenAI's image_url shape.
695
+ translated.append({
696
+ "type": "image_url",
697
+ "image_url": {"url": source},
698
+ })
699
+ continue
700
+ # Unknown part type after validation - pass through as-is.
701
+ translated.append(dict(part) if isinstance(part, dict) else part)
361
702
  new_message = dict(message)
362
703
  new_message["content"] = translated
363
704
  return new_message