tina4-python 3.13.100__tar.gz → 3.13.102__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.
- {tina4_python-3.13.100 → tina4_python-3.13.102}/PKG-INFO +16 -1
- {tina4_python-3.13.100 → tina4_python-3.13.102}/README.md +15 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/pyproject.toml +1 -1
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/__init__.py +9 -1
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/ai/__init__.py +10 -0
- tina4_python-3.13.102/tina4_python/ai/client.py +460 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/cli/__init__.py +0 -88
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/dev_admin/__init__.py +0 -7
- tina4_python-3.13.102/tina4_python/dev_admin/metrics.py +118 -0
- tina4_python-3.13.100/tina4_python/dev_admin/metrics.py +0 -375
- {tina4_python-3.13.100 → tina4_python-3.13.102}/.gitignore +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/port_takeover.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/core/server.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/database_url.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/sql_translator.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/env.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/frond/compiler.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/frond/parser.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/mqtt/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/mqtt/message.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/amqp_url.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/session_handlers/memcached_handler.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.100 → tina4_python-3.13.102}/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.
|
|
3
|
+
Version: 3.13.102
|
|
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
|
|
@@ -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.
|
|
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 = [
|
|
@@ -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()
|
|
@@ -521,93 +521,6 @@ def _console(args=None):
|
|
|
521
521
|
code.interact(banner=banner, local=local_vars)
|
|
522
522
|
|
|
523
523
|
|
|
524
|
-
# ── Metrics ───────────────────────────────────────────────────────────
|
|
525
|
-
|
|
526
|
-
def _metrics(args):
|
|
527
|
-
"""Report top code-quality offenders (complexity, size, maintainability, tests).
|
|
528
|
-
|
|
529
|
-
tina4python metrics # human report, scans src/ (or framework)
|
|
530
|
-
tina4python metrics --top 10 # only the worst 10
|
|
531
|
-
tina4python metrics --path tina4_python # scan a specific directory
|
|
532
|
-
tina4python metrics --json # machine-readable for CI
|
|
533
|
-
tina4python metrics --fail-on warn # exit 1 if any warn/error offender
|
|
534
|
-
tina4python metrics --fail-on error # exit 1 only on error-severity
|
|
535
|
-
"""
|
|
536
|
-
import json
|
|
537
|
-
from tina4_python.dev_admin import metrics as _m
|
|
538
|
-
|
|
539
|
-
flags, _ = _parse_flags(args)
|
|
540
|
-
|
|
541
|
-
top = int(flags["top"]) if "top" in flags and str(flags["top"]).isdigit() else 20
|
|
542
|
-
as_json = "json" in flags
|
|
543
|
-
path = flags.get("path", "src")
|
|
544
|
-
fail_on = flags.get("fail-on")
|
|
545
|
-
if fail_on not in (None, "warn", "error"):
|
|
546
|
-
print(f" invalid --fail-on '{fail_on}' (use warn or error)")
|
|
547
|
-
sys.exit(2)
|
|
548
|
-
|
|
549
|
-
result = _m.offenders(path, top=top)
|
|
550
|
-
summary = result["summary"]
|
|
551
|
-
found = result["offenders"]
|
|
552
|
-
|
|
553
|
-
if "error" in summary:
|
|
554
|
-
print(f" metrics error: {summary['error']}")
|
|
555
|
-
sys.exit(2)
|
|
556
|
-
|
|
557
|
-
# Decide exit code from the FULL offender set, not just the printed top-N.
|
|
558
|
-
# full_analysis is cached, so this reuses the same analysis.
|
|
559
|
-
all_offenders = _m.offenders(path, top=summary["total_offenders"] or 1)["offenders"]
|
|
560
|
-
severities = {o["severity"] for o in all_offenders}
|
|
561
|
-
exit_code = 0
|
|
562
|
-
if fail_on == "warn" and ({"warn", "error"} & severities):
|
|
563
|
-
exit_code = 1
|
|
564
|
-
elif fail_on == "error" and ("error" in severities):
|
|
565
|
-
exit_code = 1
|
|
566
|
-
|
|
567
|
-
if as_json:
|
|
568
|
-
print(json.dumps({"summary": summary, "offenders": found}, indent=2))
|
|
569
|
-
sys.exit(exit_code)
|
|
570
|
-
|
|
571
|
-
# ── Human report ──────────────────────────────────────────────────
|
|
572
|
-
use_color = sys.stdout.isatty()
|
|
573
|
-
|
|
574
|
-
def _c(text, code):
|
|
575
|
-
return f"\033[{code}m{text}\033[0m" if use_color else text
|
|
576
|
-
|
|
577
|
-
sev_color = {"error": "31", "warn": "33", "info": "2"} # red / yellow / dim
|
|
578
|
-
|
|
579
|
-
print()
|
|
580
|
-
print(f" Tina4 Metrics — {summary['scan_mode']} scan ({summary['scan_root']})")
|
|
581
|
-
print(f" files: {summary['files_analyzed']} "
|
|
582
|
-
f"functions: {summary['total_functions']} "
|
|
583
|
-
f"avg complexity: {summary['avg_complexity']} "
|
|
584
|
-
f"avg maintainability: {summary['avg_maintainability']}")
|
|
585
|
-
print(f" offenders: {summary['total_offenders']} total"
|
|
586
|
-
+ (f" (showing top {len(found)})" if found else ""))
|
|
587
|
-
print()
|
|
588
|
-
|
|
589
|
-
if not found:
|
|
590
|
-
print(" " + _c("✓ no offenders — clean", "32"))
|
|
591
|
-
print()
|
|
592
|
-
sys.exit(exit_code)
|
|
593
|
-
|
|
594
|
-
# Compute a column width for the file:line cell so the table lines up.
|
|
595
|
-
locs = [f"{o['file']}:{o['line']}" for o in found]
|
|
596
|
-
loc_w = max(len("FILE:LINE"), max(len(s) for s in locs))
|
|
597
|
-
kind_w = max(len("KIND"), max(len(o["kind"]) for o in found))
|
|
598
|
-
|
|
599
|
-
header = f" {'#':>3} {'SEVERITY':<8} {'KIND':<{kind_w}} {'FILE:LINE':<{loc_w}} DETAIL"
|
|
600
|
-
print(_c(header, "1"))
|
|
601
|
-
print(" " + "-" * (len(header) - 2))
|
|
602
|
-
for i, o in enumerate(found, 1):
|
|
603
|
-
sev = o["severity"]
|
|
604
|
-
sev_cell = _c(f"{sev:<8}", sev_color[sev])
|
|
605
|
-
print(f" {i:>3} {sev_cell} {o['kind']:<{kind_w}} "
|
|
606
|
-
f"{locs[i - 1]:<{loc_w}} {o['detail']}")
|
|
607
|
-
print()
|
|
608
|
-
sys.exit(exit_code)
|
|
609
|
-
|
|
610
|
-
|
|
611
524
|
# ── Init ──────────────────────────────────────────────────────────────
|
|
612
525
|
|
|
613
526
|
def _init(args):
|
|
@@ -3283,7 +3196,6 @@ COMMANDS = {
|
|
|
3283
3196
|
"ai": {"handler": _ai, "usage": "[--all]", "summary": "Install AI coding assistant context"},
|
|
3284
3197
|
"generate": {"handler": _generate, "usage": "<what> <name> [options]", "subcommands": list(GENERATORS), "summary": "Generate scaffolding (see Generators below)"},
|
|
3285
3198
|
"console": {"handler": _console, "summary": "Start interactive REPL with framework loaded"},
|
|
3286
|
-
"metrics": {"handler": _metrics, "usage": "[--top N] [--json] [--fail-on warn|error] [--path DIR]", "summary": "Rank top code-quality offenders"},
|
|
3287
3199
|
"commands": {"handler": _commands, "usage": "[--json]", "summary": "List available commands (add --json for machine form)"},
|
|
3288
3200
|
"help": {"handler": _help, "summary": "Show this help"},
|
|
3289
3201
|
}
|