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.
- {tina4_python-3.13.99 → tina4_python-3.13.101}/PKG-INFO +16 -1
- {tina4_python-3.13.99 → tina4_python-3.13.101}/README.md +15 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/pyproject.toml +1 -1
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/__init__.py +9 -1
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/ai/__init__.py +31 -6
- tina4_python-3.13.101/tina4_python/ai/client.py +460 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/cli/__init__.py +0 -88
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/dev_admin/__init__.py +0 -7
- tina4_python-3.13.101/tina4_python/dev_admin/metrics.py +118 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/frond/engine.py +236 -53
- tina4_python-3.13.99/tina4_python/dev_admin/metrics.py +0 -375
- {tina4_python-3.13.99 → tina4_python-3.13.101}/.gitignore +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/port_takeover.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/router.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/core/server.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/adapter.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/database_url.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/sql_translator.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/env.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/frond/compiler.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/frond/parser.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/mqtt/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/mqtt/message.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/orm/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/orm/fields.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/orm/model.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/query_builder/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/amqp_url.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/lite_backend.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/mongo_backend.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/session/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/session_handlers/memcached_handler.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/swagger/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.99 → tina4_python-3.13.101}/tina4_python/websocket/backplane.py +0 -0
- {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.
|
|
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
|
|
@@ -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 = [
|
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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()
|