021-python-clients 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
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.
- {021_python_clients-0.3.0.dist-info → 021_python_clients-0.4.0.dist-info}/METADATA +85 -11
- {021_python_clients-0.3.0.dist-info → 021_python_clients-0.4.0.dist-info}/RECORD +12 -6
- clients_021/__init__.py +4 -2
- clients_021/_observe.py +90 -4
- clients_021/compute/__init__.py +1 -0
- clients_021/compute/kubernetes.py +314 -0
- clients_021/llm/__init__.py +1 -0
- clients_021/llm/bedrock.py +250 -0
- clients_021/secrets/__init__.py +1 -0
- clients_021/secrets/secrets_manager.py +127 -0
- {021_python_clients-0.3.0.dist-info → 021_python_clients-0.4.0.dist-info}/WHEEL +0 -0
- {021_python_clients-0.3.0.dist-info → 021_python_clients-0.4.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: 021-python-clients
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Observed clients for Redis, SQS, QStash, S3, Vercel Blob, HTTP, email and
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Observed clients for Redis, SQS, QStash, S3, Vercel Blob, HTTP, email, KMS, Secrets Manager, Bedrock and Kubernetes Jobs, built on 021-python-observability
|
|
5
5
|
Project-URL: Repository, https://github.com/from021-io/python-clients
|
|
6
6
|
Project-URL: Changelog, https://github.com/from021-io/python-clients/blob/main/CHANGELOG.md
|
|
7
7
|
Project-URL: Contract, https://github.com/from021-io/python-clients/blob/main/CONTRACT.md
|
|
8
8
|
License-Expression: Apache-2.0
|
|
9
9
|
License-File: LICENSE
|
|
10
|
-
Keywords: email,httpx,kms,observability,opentelemetry,qstash,redis,resend,s3,ses,sqs
|
|
10
|
+
Keywords: bedrock,email,httpx,kms,kubernetes,observability,opentelemetry,qstash,redis,resend,s3,secrets-manager,ses,sqs
|
|
11
11
|
Classifier: Development Status :: 3 - Alpha
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
13
|
Classifier: Operating System :: OS Independent
|
|
@@ -58,6 +58,9 @@ uv add "021-python-clients[redis,aws,httpx2,vercel-blob]" # pick the extras yo
|
|
|
58
58
|
| `clients_021.email` | none | `EmailMessage`, the `EmailSender` protocol, `ResendSender` (Resend's HTTP API) |
|
|
59
59
|
| `clients_021.email.ses` | `aws` | `SesSender`: the same protocol over Amazon SES v2 |
|
|
60
60
|
| `clients_021.crypto.kms` | `aws` | `KmsClient`: encrypt, decrypt and data keys bound to an encryption context; AES-256-GCM envelopes |
|
|
61
|
+
| `clients_021.secrets.secrets_manager` | `aws` | `SecretsManagerClient`: read a secret by id, version id or stage; the value is never printed |
|
|
62
|
+
| `clients_021.compute.kubernetes` | none | `KubernetesJobsClient`: create (idempotent by name), read and delete Jobs in a namespace, in-cluster service account |
|
|
63
|
+
| `clients_021.llm.bedrock` | `aws` | `BedrockRuntime`: an observed `bedrock-runtime` client to pass to SDKs such as LangChain's `ChatBedrockConverse`; streams measured to the end, token usage counted |
|
|
61
64
|
|
|
62
65
|
Every client takes the service's `Observability` (from `setup_observability`) and a
|
|
63
66
|
`dependency` name, which becomes the `dependency` label of its metrics.
|
|
@@ -66,7 +69,7 @@ Every client takes the service's `Observability` (from `setup_observability`) an
|
|
|
66
69
|
|
|
67
70
|
Providers with the same purpose live under one capability package: `email/` contains
|
|
68
71
|
Resend and SES; `queue/` contains SQS and QStash; `storage/` contains S3 and Vercel Blob; `cache/`
|
|
69
|
-
contains Redis; `crypto/` contains KMS. `http/` contains the httpx client and httpx2
|
|
72
|
+
contains Redis; `crypto/` contains KMS; `secrets/` contains Secrets Manager; `llm/` contains Bedrock; `compute/` contains Kubernetes Jobs. `http/` contains the httpx client and httpx2
|
|
70
73
|
transport. Import providers explicitly:
|
|
71
74
|
|
|
72
75
|
```python
|
|
@@ -77,6 +80,9 @@ from clients_021.queue.qstash import QStashClient
|
|
|
77
80
|
from clients_021.storage.s3 import S3Client
|
|
78
81
|
from clients_021.cache.redis import RedisClient
|
|
79
82
|
from clients_021.crypto.kms import KmsClient
|
|
83
|
+
from clients_021.secrets.secrets_manager import SecretsManagerClient
|
|
84
|
+
from clients_021.llm.bedrock import BedrockRuntime
|
|
85
|
+
from clients_021.compute.kubernetes import KubernetesJobsClient
|
|
80
86
|
```
|
|
81
87
|
|
|
82
88
|
SQS supports polling, receipts and visibility changes; QStash delivers to an HTTP endpoint.
|
|
@@ -95,12 +101,16 @@ published exports; `clients_021.httpx2.ObservedTransport` remains an alias for
|
|
|
95
101
|
|
|
96
102
|
- A CLIENT span named `<dependency> <operation>` (`HTTP <dependency>` for HTTP), with the
|
|
97
103
|
system attribute (`db.system.name=redis`, `messaging.system=aws_sqs|resend|aws_ses`,
|
|
98
|
-
`rpc.service=S3|KMS`, `rpc.system=vercel-blob`
|
|
104
|
+
`rpc.service=S3|KMS|SecretsManager|BedrockRuntime`, `rpc.system=vercel-blob`, `rpc.system=kubernetes`;
|
|
105
|
+
Bedrock also `gen_ai.system=aws.bedrock`).
|
|
99
106
|
- A counter and a duration histogram: `db.client.operation.*` (Redis, shared with SQL),
|
|
100
107
|
`messaging.client.operation.*` (SQS, QStash, email), `storage.client.operation.*` (S3, Vercel Blob),
|
|
101
|
-
`crypto.client.operation.*` (KMS), `
|
|
108
|
+
`crypto.client.operation.*` (KMS), `secrets.client.operation.*` (Secrets Manager),
|
|
109
|
+
`llm.client.operation.*` and `llm.client.token.usage` (Bedrock), `compute.client.operation.*`
|
|
110
|
+
(Kubernetes Jobs),
|
|
111
|
+
`http.client.request.*` (HTTP, and the HTTP request
|
|
102
112
|
under QStash and Resend). Labels are bounded; see [CONTRACT.md](CONTRACT.md).
|
|
103
|
-
- Redis, SQS, QStash, S3, Vercel Blob, email and
|
|
113
|
+
- Redis, SQS, QStash, S3, Vercel Blob, email, KMS, Secrets Manager, Bedrock and Kubernetes Jobs emit a `dependency.call_failed` warning on failure,
|
|
104
114
|
with dependency, operation and error type only. HTTP/httpx2 also log failed attempts,
|
|
105
115
|
including body failures and cancellation; QStash/Resend suppress the nested HTTP log.
|
|
106
116
|
- `DependencyUnavailable(dependency, operation, reason)` when the service timed out,
|
|
@@ -109,7 +119,9 @@ published exports; `clients_021.httpx2.ObservedTransport` remains an alias for
|
|
|
109
119
|
original type: they are bugs, not outages.
|
|
110
120
|
|
|
111
121
|
Never in this library's spans, metrics or logs: keys, URLs, bodies, message ids or object
|
|
112
|
-
names; email addresses, subjects, bodies or tags;
|
|
122
|
+
names; email addresses, subjects, bodies or tags; secret ids, version ids, stages and
|
|
123
|
+
values; model ids, prompts and outputs; namespaces, Job names, manifests and service
|
|
124
|
+
account tokens; KMS key ids, encryption contexts,
|
|
113
125
|
plaintext, ciphertext or data keys. Use static dependency names such as `identity-provider` or `uploads`; never derive
|
|
114
126
|
them from a tenant, user or request. All clients apply observability's identifier rule
|
|
115
127
|
(1–96 letters, digits, `_`, `.` or `-`, starting with a letter or digit).
|
|
@@ -272,6 +284,64 @@ except EmailRejected as rejected: # the address, the domain or the credentials;
|
|
|
272
284
|
SES has no idempotency key (the argument is ignored): deduplicate in the caller when a retry
|
|
273
285
|
must not send twice. Tags are for provider analytics; never put personal data in them.
|
|
274
286
|
|
|
287
|
+
### Kubernetes Jobs
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
from clients_021.compute.kubernetes import KubernetesJobsClient
|
|
291
|
+
|
|
292
|
+
jobs = KubernetesJobsClient.in_cluster(obs, namespace="agent-runs")
|
|
293
|
+
created = await jobs.create({"metadata": {"name": "cmd-4821"}, "spec": job_spec})
|
|
294
|
+
status = await jobs.get("cmd-4821") # JobStatus(phase="running", ...) or None
|
|
295
|
+
await jobs.delete("cmd-4821", propagation="Background")
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
No SDK: it calls the `batch/v1` REST API through `HttpClient`. `in_cluster` uses the pod's
|
|
299
|
+
service account (CA bundle, namespace, and a token it re-reads at most once a minute because
|
|
300
|
+
projected tokens rotate). `create` is idempotent by name: `generateName` is refused, and an
|
|
301
|
+
existing Job returns `created=False`, so a caller can repeat it after a timeout. `get`
|
|
302
|
+
returns `None` and `delete` returns `False` for a missing Job. POST is sent once; GET and
|
|
303
|
+
DELETE retry 502, 503, 504 and network failures once. 429, 5xx, timeouts and connection
|
|
304
|
+
failures raise `DependencyUnavailable`; other rejections raise `KubernetesApiError` with the
|
|
305
|
+
status code and the API's reason, never its message.
|
|
306
|
+
|
|
307
|
+
### Bedrock
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
from clients_021.llm.bedrock import BedrockRuntime
|
|
311
|
+
from langchain_aws import ChatBedrockConverse
|
|
312
|
+
|
|
313
|
+
runtime = BedrockRuntime.create(obs, region="us-east-1")
|
|
314
|
+
model = ChatBedrockConverse(model=settings.inference_profile, client=runtime)
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`BedrockRuntime` keeps boto3's call signatures for `converse`, `converse_stream`,
|
|
318
|
+
`invoke_model`, `invoke_model_with_response_stream` and `count_tokens`, and passes every
|
|
319
|
+
other attribute (`meta`, `exceptions`) through to the boto3 client, so an SDK can take it
|
|
320
|
+
where it expects one. Calls stay synchronous like boto3's. A streaming call is measured
|
|
321
|
+
until its event stream ends; leaving the loop early or calling `close()` ends it as `ok`.
|
|
322
|
+
Converse and ConverseStream add the reported input, output and cache tokens to
|
|
323
|
+
`llm.client.token.usage`. Throttling (also mid-stream), 5xx, a model still loading and the
|
|
324
|
+
stream errors Bedrock documents as retryable raise `DependencyUnavailable`; access denied,
|
|
325
|
+
validation errors, a missing model and quota limits keep their botocore type. A retried
|
|
326
|
+
model call is billed again.
|
|
327
|
+
|
|
328
|
+
### Secrets Manager
|
|
329
|
+
|
|
330
|
+
```python
|
|
331
|
+
from clients_021.secrets.secrets_manager import SecretsManagerClient
|
|
332
|
+
|
|
333
|
+
secrets = SecretsManagerClient.create(obs, region="us-east-1")
|
|
334
|
+
secret = await secrets.get("connections/brain")
|
|
335
|
+
token = secret.text() # SecretString; secret.binary() for SecretBinary
|
|
336
|
+
previous = await secrets.get("connections/brain", version_stage="AWSPREVIOUS")
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
`get` reads the current version unless a `version_id` or a `version_stage` is given (not
|
|
340
|
+
both). The client does not cache: every call is a billed request, so keep a short-lived
|
|
341
|
+
copy when the same secret is read often. A missing secret, access denied, a value KMS cannot
|
|
342
|
+
decrypt or a secret scheduled for deletion keep their botocore type; throttling, 5xx and
|
|
343
|
+
timeouts raise `DependencyUnavailable`. `repr(secret)` never shows the value.
|
|
344
|
+
|
|
275
345
|
### KMS
|
|
276
346
|
|
|
277
347
|
The encryption context is required on every call, so a ciphertext only decrypts for the
|
|
@@ -375,6 +445,8 @@ async with AsyncExitStack() as resources:
|
|
|
375
445
|
resources.push_async_callback(email.aclose)
|
|
376
446
|
keys = KmsClient.create(obs, settings.session_key_arn, region="us-east-1")
|
|
377
447
|
resources.push_async_callback(keys.aclose)
|
|
448
|
+
secrets = SecretsManagerClient.create(obs, region="us-east-1")
|
|
449
|
+
resources.push_async_callback(secrets.aclose)
|
|
378
450
|
# Run the service here. Stop its background workers before leaving this scope.
|
|
379
451
|
```
|
|
380
452
|
|
|
@@ -386,7 +458,7 @@ Retry policies are explicit:
|
|
|
386
458
|
POST and PATCH run once. Invalid URLs/protocols and invalid request construction keep
|
|
387
459
|
their original exception. HTTP status responses are returned for the caller to handle.
|
|
388
460
|
- Resend sends each request once (POST); send an `idempotency_key` and retry in the caller.
|
|
389
|
-
- AWS factories (SQS, S3, SES, KMS) use botocore standard retries with at most **three total
|
|
461
|
+
- AWS factories (SQS, S3, SES, KMS, Secrets Manager, Bedrock) use botocore standard retries with at most **three total
|
|
390
462
|
attempts**.
|
|
391
463
|
Injected SDK clients keep their configured policy. SQS consumers must tolerate duplicate
|
|
392
464
|
messages; a timeout does not prove that a send or write was not applied.
|
|
@@ -409,7 +481,7 @@ any operation**. Map it at the service boundary and apply the operation's idempo
|
|
|
409
481
|
Its message and structured fields are safe to log when dependency names are static. Its
|
|
410
482
|
chained SDK exception may contain sensitive provider data; do not log exception chains.
|
|
411
483
|
|
|
412
|
-
S3, SQS, SES and
|
|
484
|
+
S3, SQS, SES, KMS and Secrets Manager run the synchronous AWS SDK in worker threads. Cancelling the await does not
|
|
413
485
|
stop an SDK request already running in its thread. Timeouts are per SDK/network attempt,
|
|
414
486
|
not an overall deadline. Limit consumer concurrency and let in-flight work finish before
|
|
415
487
|
closing the clients. Redis window counters require Redis server 7 or newer (`EXPIRE NX`).
|
|
@@ -433,6 +505,8 @@ deduplication, start the [official local server](https://upstash.com/docs/qstash
|
|
|
433
505
|
set its `QSTASH_URL` and `QSTASH_TOKEN`, and run `uv run python tests/qstash_local.py`.
|
|
434
506
|
That smoke test accepts only a loopback QStash server and binds its destination dynamically.
|
|
435
507
|
|
|
436
|
-
|
|
508
|
+
Kubernetes Jobs tests use httpx's `MockTransport`. Bedrock tests use botocore's Stubber and
|
|
509
|
+
fake event streams. SQS, S3, SES, KMS and Secrets
|
|
510
|
+
Manager tests run against [moto](https://github.com/getmoto/moto) (plus
|
|
437
511
|
botocore's Stubber where moto ignores a field or differs from AWS); Resend tests use httpx's
|
|
438
512
|
`MockTransport`; Redis tests need a real Redis. Licensed under Apache-2.0.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
clients_021/__init__.py,sha256=
|
|
1
|
+
clients_021/__init__.py,sha256=D7i865mBaxw32ycKWsMazMRljtYFdabdSG8dHjfWg3M,921
|
|
2
2
|
clients_021/_aws.py,sha256=sj2kCfod-vz9J93UyeVzNVLGK3M25yipKdW15KyIkw4,1722
|
|
3
|
-
clients_021/_observe.py,sha256=
|
|
3
|
+
clients_021/_observe.py,sha256=Kh-2gitT2bI4vNa-Y7PYsCns60XJzJ_N7ZbTuyX82lo,6977
|
|
4
4
|
clients_021/errors.py,sha256=GS_fN8mjr7MCOkhqPhH6LweIvQ20Vd29U-aZY8zBeJQ,972
|
|
5
5
|
clients_021/httpx2.py,sha256=rNbNGJuPJPZUwVbNN5L88IF0g3Tq6EHqj_uFmJ3RmLQ,161
|
|
6
6
|
clients_021/kms.py,sha256=YcZXURpqIZTkjcP0fpWMD6X_adMAYwN7zAJNhUImR4s,183
|
|
@@ -9,6 +9,8 @@ clients_021/redis.py,sha256=xC3tji_ldJAwaBntkZTuPhNHLIzl3F3nDzkikMrbJCc,169
|
|
|
9
9
|
clients_021/s3.py,sha256=6Q5_1emngSB00IwvBdrmV3iAg-Q_Ndco1HGzYObL5FI,231
|
|
10
10
|
clients_021/cache/__init__.py,sha256=3OyQQ-uZeghpgJvvJ2k56_t50q3CCwbAp3dBrCMMcSU,76
|
|
11
11
|
clients_021/cache/redis.py,sha256=DRXCMc8MqY8KsZ50j4X4mUsd0lvvmP-UDuUD2wJXyW8,4932
|
|
12
|
+
clients_021/compute/__init__.py,sha256=PP23LVKxoJGkwzzFbuKMRd35LugdYqJWElp9lWymllM,78
|
|
13
|
+
clients_021/compute/kubernetes.py,sha256=a0-9ldOg1mQjJBVzWAhZH4QGEN6qAzcgl_n8Ty0rSts,12456
|
|
12
14
|
clients_021/crypto/__init__.py,sha256=qESFBPQBKcHhdFXqeJzSyEP41yveG7rFrxnpITmqlNU,77
|
|
13
15
|
clients_021/crypto/kms.py,sha256=My5Xyg0bOiT9YHYWojQSszpREEh3bSYMMB26wy6fNEI,7261
|
|
14
16
|
clients_021/email/__init__.py,sha256=ivmdu9hhNDwPqBn3G7uNG0Zpc3DEMnlnWBVVoVQ-fQA,790
|
|
@@ -19,13 +21,17 @@ clients_021/http/__init__.py,sha256=P4OmYruhkQfJwJzHA90FXKWg_A2rh5iOl-ES_NTMMVQ,
|
|
|
19
21
|
clients_021/http/_observe.py,sha256=hpTzcej9MhlvM-RgUZWicR57ulE90GmZMs5U_qFoquQ,6922
|
|
20
22
|
clients_021/http/httpx.py,sha256=gvqSpO1rb6nBA5i01W1yos5mPBwLqa3-HDYwMtQXc6s,10627
|
|
21
23
|
clients_021/http/httpx2.py,sha256=fmqH4bikDKZ6YY0sNBHZbocff2zU40Gpks1aoRuOGeQ,2810
|
|
24
|
+
clients_021/llm/__init__.py,sha256=pEI4drpN0MKg_gp8BP1U7kpVd3NDV_Sa9eFD3J87LPw,86
|
|
25
|
+
clients_021/llm/bedrock.py,sha256=GJafCh6PhBrsDgs_GapoKiVflv32V7BUqyAj52pMQiA,9367
|
|
22
26
|
clients_021/queue/__init__.py,sha256=CesweOrXmX5cyU-mRvqu-4wLkNJI15zi_Lows6SPupY,338
|
|
23
27
|
clients_021/queue/qstash.py,sha256=NndlqmNkcBgs0ExGsT-3-bMAdU0skO8Djptqj8NoJAM,8335
|
|
24
28
|
clients_021/queue/sqs.py,sha256=jnccgbxyglzs2mR_DsH04IisEwygDlw6s3RxR0UtMTc,6693
|
|
29
|
+
clients_021/secrets/__init__.py,sha256=LdxphQvruITeP5jXZR69ly2jxMxuHCI1BDavw2mU0EY,76
|
|
30
|
+
clients_021/secrets/secrets_manager.py,sha256=ttP3_vCXRtOYvbUaNIkFlSTzYYr4yU3Uwim3juXUfPM,4746
|
|
25
31
|
clients_021/storage/__init__.py,sha256=KrJViRHBK7PkIYnagg7vyXp95SzMFp9cUCKEnMeFr84,78
|
|
26
32
|
clients_021/storage/s3.py,sha256=NDtG9QzHSXvrcazTYTq1EH5VjuRd2xMMsMvaumqci4Q,5946
|
|
27
33
|
clients_021/storage/vercel_blob.py,sha256=FlnoKiahqtk3PpLsGpyu_Gy87otg15i4BlJZsIB3LfM,6511
|
|
28
|
-
021_python_clients-0.
|
|
29
|
-
021_python_clients-0.
|
|
30
|
-
021_python_clients-0.
|
|
31
|
-
021_python_clients-0.
|
|
34
|
+
021_python_clients-0.4.0.dist-info/METADATA,sha256=Q4ITtnJ9tRJxxH3gdoE-1nl3A4ClHkAV5yihBGN1JTg,27808
|
|
35
|
+
021_python_clients-0.4.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
36
|
+
021_python_clients-0.4.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
37
|
+
021_python_clients-0.4.0.dist-info/RECORD,,
|
clients_021/__init__.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"""Observed clients for the external services Python services call.
|
|
2
2
|
|
|
3
|
-
`clients_021.http`, `clients_021.queue.qstash
|
|
3
|
+
`clients_021.http`, `clients_021.queue.qstash`, `clients_021.email` (Resend) and
|
|
4
|
+
`clients_021.compute.kubernetes` need no extras;
|
|
4
5
|
`clients_021.cache.redis` needs the `redis` extra; `clients_021.queue.sqs`,
|
|
5
|
-
`clients_021.storage.s3`, `clients_021.email.ses
|
|
6
|
+
`clients_021.storage.s3`, `clients_021.email.ses`, `clients_021.crypto.kms`,
|
|
7
|
+
`clients_021.secrets.secrets_manager` and `clients_021.llm.bedrock` the `aws` extra;
|
|
6
8
|
`clients_021.http.httpx2` the `httpx2` extra.
|
|
7
9
|
`clients_021.storage.vercel_blob` needs the `vercel-blob` extra.
|
|
8
10
|
"""
|
clients_021/_observe.py
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
The `db` family reuses the instruments 021-python-observability already creates for SQL
|
|
4
4
|
(`db.client.operation.*`), so Redis and Postgres share dashboards. `messaging` (SQS, email),
|
|
5
|
-
`storage` (S3, Vercel Blob)
|
|
6
|
-
|
|
5
|
+
`storage` (S3, Vercel Blob), `crypto` (KMS), `secrets` (Secrets Manager), `llm` (Bedrock) and
|
|
6
|
+
`compute` (Kubernetes Jobs) get their own pair on the same meter. `Call` is the same
|
|
7
|
+
measurement for synchronous SDK calls and for operations that end later than they start, such
|
|
8
|
+
as a model response stream. Attributes are bounded: dependency, operation, status and
|
|
9
|
+
error.type; never keys, URLs, bodies or ids.
|
|
7
10
|
"""
|
|
8
11
|
|
|
9
12
|
from __future__ import annotations
|
|
@@ -12,7 +15,7 @@ import asyncio
|
|
|
12
15
|
import time
|
|
13
16
|
from collections.abc import AsyncGenerator, Callable
|
|
14
17
|
from contextlib import asynccontextmanager
|
|
15
|
-
from typing import Literal
|
|
18
|
+
from typing import Literal, NoReturn
|
|
16
19
|
from weakref import WeakKeyDictionary
|
|
17
20
|
|
|
18
21
|
from observability_021 import Observability, get_logger
|
|
@@ -21,7 +24,7 @@ from opentelemetry.trace import SpanKind, StatusCode
|
|
|
21
24
|
|
|
22
25
|
from clients_021.errors import DependencyUnavailable
|
|
23
26
|
|
|
24
|
-
Family = Literal["db", "messaging", "storage", "crypto"]
|
|
27
|
+
Family = Literal["db", "messaging", "storage", "crypto", "secrets", "llm", "compute"]
|
|
25
28
|
# Returns the reason when an exception means "the dependency failed", None for a bug.
|
|
26
29
|
Classifier = Callable[[BaseException], str | None]
|
|
27
30
|
|
|
@@ -31,6 +34,9 @@ _UNITS = {
|
|
|
31
34
|
"messaging": "{operation}",
|
|
32
35
|
"storage": "{operation}",
|
|
33
36
|
"crypto": "{operation}",
|
|
37
|
+
"secrets": "{operation}",
|
|
38
|
+
"llm": "{operation}",
|
|
39
|
+
"compute": "{operation}",
|
|
34
40
|
}
|
|
35
41
|
_cache: WeakKeyDictionary[Observability, dict[Family, tuple[Counter, Histogram]]] = (
|
|
36
42
|
WeakKeyDictionary()
|
|
@@ -104,3 +110,83 @@ async def observed(
|
|
|
104
110
|
finally:
|
|
105
111
|
count.add(1, attributes)
|
|
106
112
|
duration.record(time.perf_counter() - started, attributes)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Call:
|
|
116
|
+
"""One observed operation started now and finished by `ok`, `fail` or `cancelled`.
|
|
117
|
+
|
|
118
|
+
The synchronous counterpart of `observed`, with the same span, instruments, labels and
|
|
119
|
+
warning. It also covers operations that outlive the call that started them: a stream is
|
|
120
|
+
finished when it is exhausted, fails or is closed. Finishing twice is ignored.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
obs: Observability,
|
|
126
|
+
*,
|
|
127
|
+
family: Family,
|
|
128
|
+
system: dict[str, str],
|
|
129
|
+
dependency: str,
|
|
130
|
+
operation: str,
|
|
131
|
+
classify: Classifier,
|
|
132
|
+
) -> None:
|
|
133
|
+
self._attributes: dict[str, str] = {"dependency": dependency, "operation": operation}
|
|
134
|
+
self._count, self._duration = instruments(obs, family)
|
|
135
|
+
self._classify = classify
|
|
136
|
+
self._started = time.perf_counter()
|
|
137
|
+
self._span = obs.tracer.start_span(
|
|
138
|
+
f"{dependency} {operation}",
|
|
139
|
+
kind=SpanKind.CLIENT,
|
|
140
|
+
attributes={**system, **self._attributes},
|
|
141
|
+
record_exception=False,
|
|
142
|
+
set_status_on_exception=False,
|
|
143
|
+
)
|
|
144
|
+
self._done = False
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def done(self) -> bool:
|
|
148
|
+
return self._done
|
|
149
|
+
|
|
150
|
+
def _finish(self) -> None:
|
|
151
|
+
self._done = True
|
|
152
|
+
self._span.set_attributes(self._attributes)
|
|
153
|
+
self._span.end()
|
|
154
|
+
self._count.add(1, self._attributes)
|
|
155
|
+
self._duration.record(time.perf_counter() - self._started, self._attributes)
|
|
156
|
+
|
|
157
|
+
def ok(self) -> None:
|
|
158
|
+
if self._done:
|
|
159
|
+
return
|
|
160
|
+
self._attributes["status"] = "ok"
|
|
161
|
+
self._finish()
|
|
162
|
+
|
|
163
|
+
def cancelled(self) -> None:
|
|
164
|
+
if self._done:
|
|
165
|
+
return
|
|
166
|
+
self._attributes["status"] = "error"
|
|
167
|
+
self._attributes["error.type"] = "cancelled"
|
|
168
|
+
self._span.set_status(StatusCode.ERROR)
|
|
169
|
+
self._finish()
|
|
170
|
+
|
|
171
|
+
def fail(self, error: BaseException) -> NoReturn:
|
|
172
|
+
"""Records the failure, then raises DependencyUnavailable or the error itself."""
|
|
173
|
+
if isinstance(error, asyncio.CancelledError | KeyboardInterrupt | GeneratorExit):
|
|
174
|
+
self.cancelled()
|
|
175
|
+
raise error
|
|
176
|
+
reason = self._classify(error)
|
|
177
|
+
if not self._done:
|
|
178
|
+
self._attributes["status"] = "error"
|
|
179
|
+
self._attributes["error.type"] = reason or type(error).__name__
|
|
180
|
+
self._span.set_status(StatusCode.ERROR)
|
|
181
|
+
logger.warning(
|
|
182
|
+
"dependency.call_failed",
|
|
183
|
+
dependency=self._attributes["dependency"],
|
|
184
|
+
operation=self._attributes["operation"],
|
|
185
|
+
error_type=self._attributes["error.type"],
|
|
186
|
+
)
|
|
187
|
+
self._finish()
|
|
188
|
+
if reason is None:
|
|
189
|
+
raise error
|
|
190
|
+
raise DependencyUnavailable(
|
|
191
|
+
self._attributes["dependency"], self._attributes["operation"], reason
|
|
192
|
+
) from error
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Compute clients. Import providers explicitly; SDKs are optional extras."""
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Kubernetes Jobs over the shared HTTP client: create, read and delete Jobs in one namespace.
|
|
2
|
+
|
|
3
|
+
from clients_021.compute.kubernetes import KubernetesJobsClient
|
|
4
|
+
|
|
5
|
+
jobs = KubernetesJobsClient.in_cluster(obs, namespace="agent-runs")
|
|
6
|
+
created = await jobs.create(manifest) # created.created is False if it already existed
|
|
7
|
+
status = await jobs.get("cmd-4821") # None when the Job does not exist
|
|
8
|
+
deleted = await jobs.delete("cmd-4821")
|
|
9
|
+
|
|
10
|
+
No optional SDK: it speaks the `batch/v1` REST API through `HttpClient`. `in_cluster` reads
|
|
11
|
+
the pod's service account (API host from `KUBERNETES_SERVICE_HOST`/`_PORT`, the CA bundle and
|
|
12
|
+
the namespace from `/var/run/secrets/kubernetes.io/serviceaccount`) and re-reads the token
|
|
13
|
+
file at most once a minute, because projected tokens rotate. Calls record
|
|
14
|
+
`compute.client.operation.*` with `operation` (`create`, `get`, `delete`) and a span with
|
|
15
|
+
`rpc.system=kubernetes`, `rpc.service=Job`; never namespaces, Job names, manifests, tokens
|
|
16
|
+
or URLs. The HTTP request under each operation is also measured by the transport.
|
|
17
|
+
|
|
18
|
+
`create` is idempotent by name: a Job is named by its manifest (`generateName` is refused),
|
|
19
|
+
and a 409 for an existing name returns `created=False` instead of failing, so a caller that
|
|
20
|
+
got a timeout can call it again. POST is sent once; GET and DELETE retry 502, 503, 504 and
|
|
21
|
+
network failures once. Timeouts, connection failures, 429 and 5xx raise
|
|
22
|
+
DependencyUnavailable. Other rejections (401, 403, 404 on create, 422) raise
|
|
23
|
+
`KubernetesApiError` with the status code and the API's machine-readable reason, never the
|
|
24
|
+
message or the manifest.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import ssl
|
|
32
|
+
import time
|
|
33
|
+
from collections.abc import Callable, Mapping
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any, Literal, cast
|
|
37
|
+
from urllib.parse import quote
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
from observability_021 import Observability
|
|
41
|
+
from observability_021.config import identifier
|
|
42
|
+
|
|
43
|
+
from clients_021._observe import observed
|
|
44
|
+
from clients_021.errors import DependencyUnavailable
|
|
45
|
+
from clients_021.http import HttpClient, HttpResponse
|
|
46
|
+
|
|
47
|
+
SYSTEM = {"rpc.system": "kubernetes", "rpc.service": "Job"}
|
|
48
|
+
SERVICE_ACCOUNT = Path("/var/run/secrets/kubernetes.io/serviceaccount")
|
|
49
|
+
_TOKEN_TTL_SECONDS = 60.0
|
|
50
|
+
_DNS_LABEL = 63
|
|
51
|
+
Phase = Literal["pending", "running", "succeeded", "failed"]
|
|
52
|
+
Propagation = Literal["Background", "Foreground", "Orphan"]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class KubernetesApiError(Exception):
|
|
56
|
+
"""A rejected request, with the status code and the API's reason, never its message."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, status_code: int, reason: str) -> None:
|
|
59
|
+
super().__init__(f"Kubernetes API: {reason} (HTTP {status_code})")
|
|
60
|
+
self.status_code = status_code
|
|
61
|
+
self.reason = reason
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class JobCreated:
|
|
66
|
+
name: str
|
|
67
|
+
created: bool
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class JobStatus:
|
|
72
|
+
name: str
|
|
73
|
+
phase: Phase
|
|
74
|
+
active: int
|
|
75
|
+
succeeded: int
|
|
76
|
+
failed: int
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _classify(error: BaseException) -> str | None:
|
|
80
|
+
if isinstance(error, DependencyUnavailable):
|
|
81
|
+
return error.reason
|
|
82
|
+
if isinstance(error, KubernetesApiError):
|
|
83
|
+
if error.status_code == 429:
|
|
84
|
+
return "throttled"
|
|
85
|
+
if 500 <= error.status_code < 600:
|
|
86
|
+
return f"status_{error.status_code}"
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _name(value: str, *, field: str = "name") -> str:
|
|
91
|
+
if (
|
|
92
|
+
not value
|
|
93
|
+
or len(value) > _DNS_LABEL
|
|
94
|
+
or not all(
|
|
95
|
+
char.isascii() and (char.islower() or char.isdigit() or char == "-") for char in value
|
|
96
|
+
)
|
|
97
|
+
or value[0] == "-"
|
|
98
|
+
or value[-1] == "-"
|
|
99
|
+
):
|
|
100
|
+
raise ValueError(f"{field} must be a DNS label (lowercase letters, digits and '-')")
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _json(response: HttpResponse) -> dict[str, Any]:
|
|
105
|
+
try:
|
|
106
|
+
data: Any = response.json()
|
|
107
|
+
except (ValueError, UnicodeDecodeError):
|
|
108
|
+
raise KubernetesApiError(response.status_code, "InvalidResponse") from None
|
|
109
|
+
if not isinstance(data, dict):
|
|
110
|
+
raise KubernetesApiError(response.status_code, "InvalidResponse")
|
|
111
|
+
return cast(dict[str, Any], data)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _rejected(response: HttpResponse) -> KubernetesApiError:
|
|
115
|
+
reason = "Unknown"
|
|
116
|
+
try:
|
|
117
|
+
data: Any = response.json()
|
|
118
|
+
if isinstance(data, dict):
|
|
119
|
+
value = cast(dict[str, Any], data).get("reason")
|
|
120
|
+
if isinstance(value, str) and value.isalnum() and len(value) <= 64:
|
|
121
|
+
reason = value
|
|
122
|
+
except (ValueError, UnicodeDecodeError):
|
|
123
|
+
pass
|
|
124
|
+
return KubernetesApiError(response.status_code, reason)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _count(status: Mapping[str, Any], field: str) -> int:
|
|
128
|
+
value = status.get(field, 0)
|
|
129
|
+
return value if isinstance(value, int) and value >= 0 else 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _status(name: str, job: Mapping[str, Any]) -> JobStatus:
|
|
133
|
+
raw: Any = job.get("status") or {}
|
|
134
|
+
status = cast(dict[str, Any], raw) if isinstance(raw, dict) else {}
|
|
135
|
+
conditions: Any = status.get("conditions") or []
|
|
136
|
+
true_types: set[str] = set()
|
|
137
|
+
if isinstance(conditions, list):
|
|
138
|
+
for condition in cast(list[Any], conditions):
|
|
139
|
+
if isinstance(condition, dict):
|
|
140
|
+
entry = cast(dict[str, Any], condition)
|
|
141
|
+
if entry.get("status") == "True" and isinstance(entry.get("type"), str):
|
|
142
|
+
true_types.add(cast(str, entry["type"]))
|
|
143
|
+
active, succeeded, failed = (_count(status, f) for f in ("active", "succeeded", "failed"))
|
|
144
|
+
phase: Phase
|
|
145
|
+
if "Complete" in true_types or "SuccessCriteriaMet" in true_types:
|
|
146
|
+
phase = "succeeded"
|
|
147
|
+
elif "Failed" in true_types or "FailureTarget" in true_types:
|
|
148
|
+
phase = "failed"
|
|
149
|
+
elif active > 0:
|
|
150
|
+
phase = "running"
|
|
151
|
+
else:
|
|
152
|
+
phase = "pending"
|
|
153
|
+
return JobStatus(name=name, phase=phase, active=active, succeeded=succeeded, failed=failed)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _FileToken:
|
|
157
|
+
"""Reads a projected service account token, at most once per TTL."""
|
|
158
|
+
|
|
159
|
+
def __init__(self, path: Path, *, ttl_seconds: float = _TOKEN_TTL_SECONDS) -> None:
|
|
160
|
+
self._path = path
|
|
161
|
+
self._ttl = ttl_seconds
|
|
162
|
+
self._value = ""
|
|
163
|
+
self._read_at = float("-inf")
|
|
164
|
+
|
|
165
|
+
def __call__(self) -> str:
|
|
166
|
+
now = time.monotonic()
|
|
167
|
+
if now - self._read_at >= self._ttl:
|
|
168
|
+
self._value = self._path.read_text().strip()
|
|
169
|
+
self._read_at = now
|
|
170
|
+
return self._value
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class KubernetesJobsClient:
|
|
174
|
+
def __init__(
|
|
175
|
+
self,
|
|
176
|
+
obs: Observability,
|
|
177
|
+
*,
|
|
178
|
+
api_url: str,
|
|
179
|
+
namespace: str,
|
|
180
|
+
token: Callable[[], str],
|
|
181
|
+
timeout_seconds: float = 10.0,
|
|
182
|
+
dependency: str = "kubernetes",
|
|
183
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
184
|
+
) -> None:
|
|
185
|
+
if not api_url.startswith(("https://", "http://")):
|
|
186
|
+
raise ValueError("api_url must be an HTTP(S) URL")
|
|
187
|
+
self.dependency = identifier(dependency)
|
|
188
|
+
self._obs = obs
|
|
189
|
+
namespace = quote(_name(namespace, field="namespace"))
|
|
190
|
+
self._jobs_url = f"{api_url.rstrip('/')}/apis/batch/v1/namespaces/{namespace}/jobs"
|
|
191
|
+
self._token = token
|
|
192
|
+
self._closed = False
|
|
193
|
+
self._http = HttpClient(
|
|
194
|
+
obs, timeout_seconds=timeout_seconds, retries=1, log_failures=False, transport=transport
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def in_cluster(
|
|
199
|
+
cls,
|
|
200
|
+
obs: Observability,
|
|
201
|
+
*,
|
|
202
|
+
namespace: str | None = None,
|
|
203
|
+
timeout_seconds: float = 10.0,
|
|
204
|
+
dependency: str = "kubernetes",
|
|
205
|
+
service_account: Path = SERVICE_ACCOUNT,
|
|
206
|
+
) -> KubernetesJobsClient:
|
|
207
|
+
"""The pod's own service account; fails before any request if it is not mounted."""
|
|
208
|
+
host = os.environ.get("KUBERNETES_SERVICE_HOST")
|
|
209
|
+
port = os.environ.get("KUBERNETES_SERVICE_PORT", "443")
|
|
210
|
+
if not host:
|
|
211
|
+
raise RuntimeError("KUBERNETES_SERVICE_HOST is not set: not running in a pod")
|
|
212
|
+
if ":" in host:
|
|
213
|
+
host = f"[{host}]"
|
|
214
|
+
ca = service_account / "ca.crt"
|
|
215
|
+
token = service_account / "token"
|
|
216
|
+
if not ca.is_file() or not token.is_file():
|
|
217
|
+
raise RuntimeError("the service account is not mounted")
|
|
218
|
+
if namespace is None:
|
|
219
|
+
namespace = (service_account / "namespace").read_text().strip()
|
|
220
|
+
transport = httpx.AsyncHTTPTransport(verify=ssl.create_default_context(cafile=str(ca)))
|
|
221
|
+
return cls(
|
|
222
|
+
obs,
|
|
223
|
+
api_url=f"https://{host}:{port}",
|
|
224
|
+
namespace=namespace,
|
|
225
|
+
token=_FileToken(token),
|
|
226
|
+
timeout_seconds=timeout_seconds,
|
|
227
|
+
dependency=dependency,
|
|
228
|
+
transport=transport,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def _headers(self) -> dict[str, str]:
|
|
232
|
+
return {"Authorization": f"Bearer {self._token()}", "Accept": "application/json"}
|
|
233
|
+
|
|
234
|
+
def _observed(self, operation: str): # noqa: ANN202 - an async context manager
|
|
235
|
+
if self._closed:
|
|
236
|
+
raise RuntimeError("KubernetesJobsClient is closed")
|
|
237
|
+
return observed(
|
|
238
|
+
self._obs,
|
|
239
|
+
family="compute",
|
|
240
|
+
system=SYSTEM,
|
|
241
|
+
dependency=self.dependency,
|
|
242
|
+
operation=operation,
|
|
243
|
+
classify=_classify,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
async def create(self, manifest: Mapping[str, Any]) -> JobCreated:
|
|
247
|
+
"""Creates the Job named by `metadata.name`; an existing Job with that name is kept."""
|
|
248
|
+
metadata: Any = manifest.get("metadata")
|
|
249
|
+
if not isinstance(metadata, dict):
|
|
250
|
+
raise ValueError("manifest.metadata is required")
|
|
251
|
+
fields = cast(dict[str, Any], metadata)
|
|
252
|
+
if "generateName" in fields:
|
|
253
|
+
raise ValueError("generateName is not idempotent; set metadata.name")
|
|
254
|
+
name = fields.get("name")
|
|
255
|
+
if not isinstance(name, str):
|
|
256
|
+
raise ValueError("manifest.metadata.name is required")
|
|
257
|
+
_name(name)
|
|
258
|
+
if manifest.get("kind", "Job") != "Job":
|
|
259
|
+
raise ValueError("manifest.kind must be Job")
|
|
260
|
+
body = {"apiVersion": "batch/v1", "kind": "Job", **manifest}
|
|
261
|
+
content = json.dumps(body, separators=(",", ":"), allow_nan=False).encode()
|
|
262
|
+
async with self._observed("create"):
|
|
263
|
+
response = await self._http.request(
|
|
264
|
+
"POST",
|
|
265
|
+
self._jobs_url,
|
|
266
|
+
dependency=self.dependency,
|
|
267
|
+
headers={**self._headers(), "Content-Type": "application/json"},
|
|
268
|
+
content=content,
|
|
269
|
+
)
|
|
270
|
+
if response.status_code == 409 and _rejected(response).reason == "AlreadyExists":
|
|
271
|
+
return JobCreated(name=name, created=False)
|
|
272
|
+
if response.status_code != 201:
|
|
273
|
+
raise _rejected(response)
|
|
274
|
+
created: Any = _json(response).get("metadata")
|
|
275
|
+
if not isinstance(created, dict) or cast(dict[str, Any], created).get("name") != name:
|
|
276
|
+
raise KubernetesApiError(response.status_code, "InvalidResponse")
|
|
277
|
+
return JobCreated(name=name, created=True)
|
|
278
|
+
|
|
279
|
+
async def get(self, name: str) -> JobStatus | None:
|
|
280
|
+
url = f"{self._jobs_url}/{quote(_name(name))}"
|
|
281
|
+
async with self._observed("get"):
|
|
282
|
+
response = await self._http.request(
|
|
283
|
+
"GET", url, dependency=self.dependency, headers=self._headers()
|
|
284
|
+
)
|
|
285
|
+
if response.status_code == 404:
|
|
286
|
+
return None
|
|
287
|
+
if response.status_code != 200:
|
|
288
|
+
raise _rejected(response)
|
|
289
|
+
return _status(name, _json(response))
|
|
290
|
+
|
|
291
|
+
async def delete(self, name: str, *, propagation: Propagation = "Background") -> bool:
|
|
292
|
+
"""True when a Job was deleted, False when there was none; its pods follow `propagation`."""
|
|
293
|
+
url = f"{self._jobs_url}/{quote(_name(name))}"
|
|
294
|
+
content = json.dumps(
|
|
295
|
+
{"kind": "DeleteOptions", "apiVersion": "v1", "propagationPolicy": propagation}
|
|
296
|
+
).encode()
|
|
297
|
+
async with self._observed("delete"):
|
|
298
|
+
response = await self._http.request(
|
|
299
|
+
"DELETE",
|
|
300
|
+
url,
|
|
301
|
+
dependency=self.dependency,
|
|
302
|
+
headers={**self._headers(), "Content-Type": "application/json"},
|
|
303
|
+
content=content,
|
|
304
|
+
)
|
|
305
|
+
if response.status_code == 404:
|
|
306
|
+
return False
|
|
307
|
+
if response.status_code not in (200, 202):
|
|
308
|
+
raise _rejected(response)
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
async def aclose(self) -> None:
|
|
312
|
+
"""Release the HTTP pool and injected transport after in-flight calls finish."""
|
|
313
|
+
self._closed = True
|
|
314
|
+
await self._http.aclose()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Model inference clients. Import providers explicitly; SDKs are optional extras."""
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Amazon Bedrock Runtime, observed, for SDKs that take a boto3 client.
|
|
2
|
+
|
|
3
|
+
from clients_021.llm.bedrock import BedrockRuntime
|
|
4
|
+
from langchain_aws import ChatBedrockConverse
|
|
5
|
+
|
|
6
|
+
runtime = BedrockRuntime.create(obs, region="us-east-1")
|
|
7
|
+
model = ChatBedrockConverse(model=settings.inference_profile, client=runtime)
|
|
8
|
+
|
|
9
|
+
`BedrockRuntime` has the same call signatures as boto3's `bedrock-runtime` client for
|
|
10
|
+
`converse`, `converse_stream`, `invoke_model`, `invoke_model_with_response_stream` and
|
|
11
|
+
`count_tokens`, and passes every other attribute (`meta`, `exceptions`, ...) through, so an
|
|
12
|
+
SDK can use it where it expects the boto3 client. Those five operations record
|
|
13
|
+
`llm.client.operation.*` and a span with `gen_ai.system=aws.bedrock`. Calls are synchronous,
|
|
14
|
+
like boto3's; an SDK that is async runs them in its own worker threads.
|
|
15
|
+
|
|
16
|
+
A streaming operation lasts until its event stream is exhausted, fails or is closed: the
|
|
17
|
+
response's `stream` (Converse) or `body` (InvokeModel) is wrapped, and closing it early ends
|
|
18
|
+
the operation as `ok` with a `dependency.stream_closed` log. Converse and ConverseStream also
|
|
19
|
+
add the reported token usage to `llm.client.token.usage` (`token.type`: `input`, `output`,
|
|
20
|
+
`cache_read`, `cache_write`); InvokeModel bodies are model-specific and are not read.
|
|
21
|
+
|
|
22
|
+
Timeouts, connection failures, throttling (also mid-stream), 5xx and the stream errors
|
|
23
|
+
Bedrock documents as retryable (a model still loading included) raise
|
|
24
|
+
DependencyUnavailable. Access denied, validation errors, a missing model and quota errors
|
|
25
|
+
that need a configuration change keep their botocore type. botocore's standard retry mode
|
|
26
|
+
makes up to 3 attempts per call: a retried model call is billed again. Never recorded:
|
|
27
|
+
model ids or ARNs, prompts, outputs, tool arguments, request ids or guardrail details.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
from collections.abc import Callable, Iterator
|
|
34
|
+
from typing import TYPE_CHECKING, Any
|
|
35
|
+
|
|
36
|
+
import boto3
|
|
37
|
+
from botocore.exceptions import ClientError
|
|
38
|
+
from observability_021 import Observability, get_logger
|
|
39
|
+
from observability_021.config import identifier
|
|
40
|
+
|
|
41
|
+
from clients_021 import _aws
|
|
42
|
+
from clients_021._observe import Call
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from mypy_boto3_bedrock_runtime import BedrockRuntimeClient
|
|
46
|
+
else:
|
|
47
|
+
BedrockRuntimeClient = Any
|
|
48
|
+
|
|
49
|
+
SYSTEM = {"rpc.system": "aws-api", "rpc.service": "BedrockRuntime", "gen_ai.system": "aws.bedrock"}
|
|
50
|
+
# Exception names Bedrock sends inside an event stream (lower camel case) and the HTTP ones it
|
|
51
|
+
# documents as retryable. Their status code is not always in the response metadata.
|
|
52
|
+
_STREAM_RETRYABLE = {
|
|
53
|
+
"throttlingException": "throttled",
|
|
54
|
+
"serviceUnavailableException": "status_503",
|
|
55
|
+
"internalServerException": "status_500",
|
|
56
|
+
"modelStreamErrorException": "status_500",
|
|
57
|
+
"ServiceUnavailableException": "status_503",
|
|
58
|
+
"InternalServerException": "status_500",
|
|
59
|
+
"ModelStreamErrorException": "status_500",
|
|
60
|
+
# The model is still loading; Bedrock documents it as safe to retry.
|
|
61
|
+
"ModelNotReadyException": "throttled",
|
|
62
|
+
}
|
|
63
|
+
_USAGE = {
|
|
64
|
+
"inputTokens": "input",
|
|
65
|
+
"outputTokens": "output",
|
|
66
|
+
"cacheReadInputTokens": "cache_read",
|
|
67
|
+
"cacheWriteInputTokens": "cache_write",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
logger = get_logger(__name__)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def classify(error: BaseException) -> str | None:
|
|
74
|
+
reason = _aws.classify(error)
|
|
75
|
+
if reason is None and isinstance(error, ClientError):
|
|
76
|
+
reason = _STREAM_RETRYABLE.get(_aws.error_code(error))
|
|
77
|
+
return reason
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _ObservedStream:
|
|
81
|
+
"""An event stream whose operation ends when it is exhausted, fails or is closed.
|
|
82
|
+
|
|
83
|
+
Leaving the loop early (`break`) or calling `close()` is a deliberate partial read: the
|
|
84
|
+
operation ends as `ok` and `dependency.stream_closed` is logged at INFO.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
inner: Any,
|
|
90
|
+
call: Call,
|
|
91
|
+
*,
|
|
92
|
+
dependency: str,
|
|
93
|
+
operation: str,
|
|
94
|
+
on_event: Callable[[Any], None] | None,
|
|
95
|
+
) -> None:
|
|
96
|
+
self._inner = inner
|
|
97
|
+
self._call = call
|
|
98
|
+
self._dependency = dependency
|
|
99
|
+
self._operation = operation
|
|
100
|
+
self._on_event = on_event
|
|
101
|
+
|
|
102
|
+
def __iter__(self) -> Iterator[Any]:
|
|
103
|
+
try:
|
|
104
|
+
for event in self._inner:
|
|
105
|
+
if self._on_event is not None:
|
|
106
|
+
self._on_event(event)
|
|
107
|
+
yield event
|
|
108
|
+
except GeneratorExit:
|
|
109
|
+
self._closed_early()
|
|
110
|
+
raise
|
|
111
|
+
except BaseException as error:
|
|
112
|
+
self._call.fail(error)
|
|
113
|
+
self._call.ok()
|
|
114
|
+
|
|
115
|
+
def _closed_early(self) -> None:
|
|
116
|
+
if not self._call.done:
|
|
117
|
+
logger.info(
|
|
118
|
+
"dependency.stream_closed", dependency=self._dependency, operation=self._operation
|
|
119
|
+
)
|
|
120
|
+
self._call.ok()
|
|
121
|
+
|
|
122
|
+
def close(self) -> None:
|
|
123
|
+
try:
|
|
124
|
+
self._inner.close()
|
|
125
|
+
finally:
|
|
126
|
+
self._closed_early()
|
|
127
|
+
|
|
128
|
+
def __getattr__(self, name: str) -> Any:
|
|
129
|
+
return getattr(self._inner, name)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class BedrockRuntime:
|
|
133
|
+
def __init__(
|
|
134
|
+
self, obs: Observability, client: BedrockRuntimeClient, *, dependency: str = "bedrock"
|
|
135
|
+
) -> None:
|
|
136
|
+
self._obs = obs
|
|
137
|
+
self._client = client
|
|
138
|
+
self.dependency = identifier(dependency)
|
|
139
|
+
self._tokens = obs.meter.create_counter("llm.client.token.usage", unit="{token}")
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def create(
|
|
143
|
+
cls,
|
|
144
|
+
obs: Observability,
|
|
145
|
+
*,
|
|
146
|
+
region: str | None = None,
|
|
147
|
+
endpoint_url: str | None = None,
|
|
148
|
+
dependency: str = "bedrock",
|
|
149
|
+
connect_seconds: float = 3.0,
|
|
150
|
+
read_seconds: float = 300.0,
|
|
151
|
+
) -> BedrockRuntime:
|
|
152
|
+
"""`read_seconds` bounds each wait for bytes; a long generation streams for longer."""
|
|
153
|
+
client: BedrockRuntimeClient = boto3.client( # pyright: ignore[reportUnknownMemberType]
|
|
154
|
+
"bedrock-runtime",
|
|
155
|
+
region_name=region,
|
|
156
|
+
endpoint_url=endpoint_url,
|
|
157
|
+
config=_aws.config(connect_seconds=connect_seconds, read_seconds=read_seconds),
|
|
158
|
+
)
|
|
159
|
+
return cls(obs, client, dependency=dependency)
|
|
160
|
+
|
|
161
|
+
def _call(self, operation: str) -> Call:
|
|
162
|
+
return Call(
|
|
163
|
+
self._obs,
|
|
164
|
+
family="llm",
|
|
165
|
+
system=SYSTEM,
|
|
166
|
+
dependency=self.dependency,
|
|
167
|
+
operation=operation,
|
|
168
|
+
classify=classify,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def _record_usage(self, operation: str, usage: Any) -> None:
|
|
172
|
+
if not isinstance(usage, dict):
|
|
173
|
+
return
|
|
174
|
+
for field, token_type in _USAGE.items():
|
|
175
|
+
value: Any = usage.get(field) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
|
176
|
+
if isinstance(value, int) and value > 0:
|
|
177
|
+
self._tokens.add(
|
|
178
|
+
value,
|
|
179
|
+
{
|
|
180
|
+
"dependency": self.dependency,
|
|
181
|
+
"operation": operation,
|
|
182
|
+
"token.type": token_type,
|
|
183
|
+
},
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def _unary(self, operation: str, method: Callable[..., Any], params: dict[str, Any]) -> Any:
|
|
187
|
+
call = self._call(operation)
|
|
188
|
+
try:
|
|
189
|
+
response = method(**params)
|
|
190
|
+
except BaseException as error:
|
|
191
|
+
call.fail(error)
|
|
192
|
+
call.ok()
|
|
193
|
+
if operation == "converse":
|
|
194
|
+
self._record_usage(operation, response.get("usage"))
|
|
195
|
+
return response
|
|
196
|
+
|
|
197
|
+
def _streaming(
|
|
198
|
+
self, operation: str, method: Callable[..., Any], params: dict[str, Any], key: str
|
|
199
|
+
) -> Any:
|
|
200
|
+
call = self._call(operation)
|
|
201
|
+
try:
|
|
202
|
+
response = method(**params)
|
|
203
|
+
except BaseException as error:
|
|
204
|
+
call.fail(error)
|
|
205
|
+
|
|
206
|
+
def on_event(event: Any) -> None:
|
|
207
|
+
metadata: Any = None
|
|
208
|
+
if isinstance(event, dict):
|
|
209
|
+
metadata = event.get("metadata") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
|
210
|
+
if isinstance(metadata, dict):
|
|
211
|
+
self._record_usage(operation, metadata.get("usage")) # pyright: ignore[reportUnknownMemberType]
|
|
212
|
+
|
|
213
|
+
stream = _ObservedStream(
|
|
214
|
+
response[key],
|
|
215
|
+
call,
|
|
216
|
+
dependency=self.dependency,
|
|
217
|
+
operation=operation,
|
|
218
|
+
on_event=on_event if operation == "converse_stream" else None,
|
|
219
|
+
)
|
|
220
|
+
return {**response, key: stream}
|
|
221
|
+
|
|
222
|
+
def converse(self, **params: Any) -> Any:
|
|
223
|
+
return self._unary("converse", self._client.converse, params)
|
|
224
|
+
|
|
225
|
+
def converse_stream(self, **params: Any) -> Any:
|
|
226
|
+
return self._streaming("converse_stream", self._client.converse_stream, params, "stream")
|
|
227
|
+
|
|
228
|
+
def invoke_model(self, **params: Any) -> Any:
|
|
229
|
+
return self._unary("invoke_model", self._client.invoke_model, params)
|
|
230
|
+
|
|
231
|
+
def invoke_model_with_response_stream(self, **params: Any) -> Any:
|
|
232
|
+
return self._streaming(
|
|
233
|
+
"invoke_model_with_response_stream",
|
|
234
|
+
self._client.invoke_model_with_response_stream,
|
|
235
|
+
params,
|
|
236
|
+
"body",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def count_tokens(self, **params: Any) -> Any:
|
|
240
|
+
return self._unary("count_tokens", self._client.count_tokens, params)
|
|
241
|
+
|
|
242
|
+
def close(self) -> None:
|
|
243
|
+
self._client.close()
|
|
244
|
+
|
|
245
|
+
async def aclose(self) -> None:
|
|
246
|
+
"""Release the underlying SDK connection pools after in-flight calls finish."""
|
|
247
|
+
await asyncio.to_thread(self._client.close)
|
|
248
|
+
|
|
249
|
+
def __getattr__(self, name: str) -> Any:
|
|
250
|
+
return getattr(self._client, name)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Secret stores. Import providers explicitly; SDKs are optional extras."""
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""AWS Secrets Manager, observed: read one secret value by id, version id or stage.
|
|
2
|
+
|
|
3
|
+
from clients_021.secrets.secrets_manager import SecretsManagerClient
|
|
4
|
+
|
|
5
|
+
secrets = SecretsManagerClient.create(obs, region="us-east-1")
|
|
6
|
+
secret = await secrets.get("connections/brain")
|
|
7
|
+
token = secret.text() # SecretString; secret.binary() for SecretBinary
|
|
8
|
+
previous = await secrets.get("connections/brain", version_stage="AWSPREVIOUS")
|
|
9
|
+
|
|
10
|
+
Needs the `aws` extra. Calls record `secrets.client.operation.*` with `operation=get` and a
|
|
11
|
+
span with `rpc.service=SecretsManager`; never secret ids, ARNs, version ids, stages or values.
|
|
12
|
+
Timeouts, connection failures, throttling and 5xx raise DependencyUnavailable. A missing
|
|
13
|
+
secret (`ResourceNotFoundException`), access denied, a secret KMS cannot decrypt
|
|
14
|
+
(`DecryptionFailure`) or one scheduled for deletion (`InvalidRequestException`) keep their
|
|
15
|
+
botocore type: they are misconfiguration, not outages. Reads are idempotent; botocore's
|
|
16
|
+
standard retry mode makes up to 3 attempts. The client does not cache: every call is a
|
|
17
|
+
billed request, so callers that read the same secret often keep their own short-lived copy.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
import boto3
|
|
27
|
+
from observability_021 import Observability
|
|
28
|
+
from observability_021.config import identifier
|
|
29
|
+
|
|
30
|
+
from clients_021 import _aws
|
|
31
|
+
from clients_021._observe import observed
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from mypy_boto3_secretsmanager import SecretsManagerClient as SecretsManagerSdk
|
|
35
|
+
else:
|
|
36
|
+
SecretsManagerSdk = Any
|
|
37
|
+
|
|
38
|
+
SYSTEM = {"rpc.system": "aws-api", "rpc.service": "SecretsManager"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, repr=False)
|
|
42
|
+
class Secret:
|
|
43
|
+
"""One secret version. `value` is the SecretString or the SecretBinary; never printed."""
|
|
44
|
+
|
|
45
|
+
value: str | bytes
|
|
46
|
+
version_id: str
|
|
47
|
+
|
|
48
|
+
def text(self) -> str:
|
|
49
|
+
if not isinstance(self.value, str):
|
|
50
|
+
raise TypeError("this secret is binary; use binary()")
|
|
51
|
+
return self.value
|
|
52
|
+
|
|
53
|
+
def binary(self) -> bytes:
|
|
54
|
+
if not isinstance(self.value, bytes):
|
|
55
|
+
raise TypeError("this secret is a string; use text()")
|
|
56
|
+
return self.value
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
return "Secret(<redacted>)"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SecretsManagerClient:
|
|
63
|
+
def __init__(
|
|
64
|
+
self, obs: Observability, client: SecretsManagerSdk, *, dependency: str = "secrets"
|
|
65
|
+
) -> None:
|
|
66
|
+
self._obs = obs
|
|
67
|
+
self._client = client
|
|
68
|
+
self.dependency = identifier(dependency)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def create(
|
|
72
|
+
cls,
|
|
73
|
+
obs: Observability,
|
|
74
|
+
*,
|
|
75
|
+
region: str | None = None,
|
|
76
|
+
endpoint_url: str | None = None,
|
|
77
|
+
dependency: str = "secrets",
|
|
78
|
+
timeout_seconds: float = 5.0,
|
|
79
|
+
) -> SecretsManagerClient:
|
|
80
|
+
client: SecretsManagerSdk = boto3.client( # pyright: ignore[reportUnknownMemberType]
|
|
81
|
+
"secretsmanager",
|
|
82
|
+
region_name=region,
|
|
83
|
+
endpoint_url=endpoint_url,
|
|
84
|
+
config=_aws.config(
|
|
85
|
+
connect_seconds=min(timeout_seconds, 3.0), read_seconds=timeout_seconds
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
return cls(obs, client, dependency=dependency)
|
|
89
|
+
|
|
90
|
+
async def get(
|
|
91
|
+
self,
|
|
92
|
+
secret_id: str,
|
|
93
|
+
*,
|
|
94
|
+
version_id: str | None = None,
|
|
95
|
+
version_stage: str | None = None,
|
|
96
|
+
) -> Secret:
|
|
97
|
+
"""The current version (`AWSCURRENT`) unless a version id or a stage is given."""
|
|
98
|
+
if not secret_id:
|
|
99
|
+
raise ValueError("secret_id is required")
|
|
100
|
+
if version_id is not None and version_stage is not None:
|
|
101
|
+
raise ValueError("pass version_id or version_stage, not both")
|
|
102
|
+
params: dict[str, str] = {"SecretId": secret_id}
|
|
103
|
+
if version_id is not None:
|
|
104
|
+
params["VersionId"] = version_id
|
|
105
|
+
if version_stage is not None:
|
|
106
|
+
params["VersionStage"] = version_stage
|
|
107
|
+
async with observed(
|
|
108
|
+
self._obs,
|
|
109
|
+
family="secrets",
|
|
110
|
+
system=SYSTEM,
|
|
111
|
+
dependency=self.dependency,
|
|
112
|
+
operation="get",
|
|
113
|
+
classify=_aws.classify,
|
|
114
|
+
):
|
|
115
|
+
response = await asyncio.to_thread(self._client.get_secret_value, **params)
|
|
116
|
+
value: str | bytes
|
|
117
|
+
if "SecretString" in response:
|
|
118
|
+
value = response["SecretString"]
|
|
119
|
+
elif "SecretBinary" in response:
|
|
120
|
+
value = response["SecretBinary"]
|
|
121
|
+
else:
|
|
122
|
+
raise ValueError("the secret version has no value")
|
|
123
|
+
return Secret(value=value, version_id=response.get("VersionId", ""))
|
|
124
|
+
|
|
125
|
+
async def aclose(self) -> None:
|
|
126
|
+
"""Release the underlying SDK connection pools after in-flight calls finish."""
|
|
127
|
+
await asyncio.to_thread(self._client.close)
|
|
File without changes
|
|
File without changes
|