anyray-agent 0.1.0__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.
- anyray_agent-0.1.0/.gitignore +2 -0
- anyray_agent-0.1.0/PKG-INFO +103 -0
- anyray_agent-0.1.0/README.md +87 -0
- anyray_agent-0.1.0/pyproject.toml +47 -0
- anyray_agent-0.1.0/src/anyray_agent/__init__.py +41 -0
- anyray_agent-0.1.0/src/anyray_agent/_bedrock.py +168 -0
- anyray_agent-0.1.0/src/anyray_agent/_core.py +418 -0
- anyray_agent-0.1.0/src/anyray_agent/_direct.py +60 -0
- anyray_agent-0.1.0/src/anyray_agent/_event_stream.py +132 -0
- anyray_agent-0.1.0/src/anyray_agent/_io.py +38 -0
- anyray_agent-0.1.0/src/anyray_agent/_link.py +281 -0
- anyray_agent-0.1.0/src/anyray_agent/_report.py +24 -0
- anyray_agent-0.1.0/src/anyray_agent/_rules.py +99 -0
- anyray_agent-0.1.0/src/anyray_agent/_runtime.py +15 -0
- anyray_agent-0.1.0/src/anyray_agent/_types.py +101 -0
- anyray_agent-0.1.0/src/anyray_agent/httpx.py +34 -0
- anyray_agent-0.1.0/src/anyray_agent/langchain.py +22 -0
- anyray_agent-0.1.0/src/anyray_agent/py.typed +0 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: anyray-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Keeps an agent answering when its Anyray gateway is unreachable: an httpx2 transport for the OpenAI, Anthropic and LangChain SDKs that resends a call straight to the provider only when the gateway never handled it.
|
|
5
|
+
Project-URL: Homepage, https://docs.anyray.ai/enroll/agent-fallback-reference
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: ai-gateway,anthropic,anyray,bedrock,fallback,langchain,llm,openai
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Typing :: Typed
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: httpx2<3,>=2.12
|
|
13
|
+
Provides-Extra: httpx
|
|
14
|
+
Requires-Dist: httpx<1,>=0.27; extra == 'httpx'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# anyray-agent
|
|
18
|
+
|
|
19
|
+
Keeps an agent answering when its [Anyray](https://anyray.ai) gateway is unreachable. Give your SDK an `httpx2`
|
|
20
|
+
client built on `AnyrayTransport()`. While the gateway is up, every call goes through it unchanged. When the gateway
|
|
21
|
+
is down, a call goes straight to the provider instead.
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pip install anyray-agent
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Python 3.11 or later. One dependency, `httpx2`, which the current OpenAI and Anthropic SDKs already use.
|
|
28
|
+
|
|
29
|
+
## Use it
|
|
30
|
+
|
|
31
|
+
Keep the client pointed at your gateway, with your agent key, exactly as before. Add one argument.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import os
|
|
35
|
+
|
|
36
|
+
import httpx2
|
|
37
|
+
from anthropic import Anthropic
|
|
38
|
+
from anyray_agent import AnyrayTransport
|
|
39
|
+
|
|
40
|
+
client = Anthropic(
|
|
41
|
+
base_url=os.environ["ANYRAY_GATEWAY_URL"],
|
|
42
|
+
api_key=os.environ["ANYRAY_CLIENT_KEY"],
|
|
43
|
+
http_client=httpx2.Client(transport=AnyrayTransport()),
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| Client | Where `AnyrayTransport()` goes |
|
|
48
|
+
| --- | --- |
|
|
49
|
+
| OpenAI SDK | `OpenAI(..., http_client=httpx2.Client(transport=AnyrayTransport()))` |
|
|
50
|
+
| Anthropic SDK | `Anthropic(..., http_client=httpx2.Client(transport=AnyrayTransport()))` |
|
|
51
|
+
| LangChain `ChatOpenAI` | `ChatOpenAI(..., http_client=httpx2.Client(transport=AnyrayTransport()))` |
|
|
52
|
+
| LangChain `ChatAnthropic` | `use_fallback(ChatAnthropic(...), transport=AnyrayTransport())` |
|
|
53
|
+
|
|
54
|
+
`use_fallback` comes from `anyray_agent.langchain`: `ChatAnthropic` has no client argument, so it swaps the clients
|
|
55
|
+
the model builds. Async code uses `AsyncAnyrayTransport` with `httpx2.AsyncClient` (`http_async_client=` on
|
|
56
|
+
`ChatOpenAI`, `async_transport=` on `use_fallback`). An SDK still on `httpx` 0.x (openai < 3, anthropic < 2) takes
|
|
57
|
+
the same classes from `anyray_agent.httpx` (`pip install "anyray-agent[httpx]"`).
|
|
58
|
+
|
|
59
|
+
## The provider key
|
|
60
|
+
|
|
61
|
+
A call can only go direct with a provider key on the host: `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` are read from the
|
|
62
|
+
environment, or pass them:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
AnyrayTransport(provider_keys={"openai": "…", "anthropic": "…"})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
To send Anthropic calls to AWS Bedrock instead of api.anthropic.com, name the region. Credentials come from
|
|
69
|
+
`AWS_BEARER_TOKEN_BEDROCK` or the standard AWS variables, or pass `api_key` or `credentials`. For an IAM role or a
|
|
70
|
+
profile, pass boto3's credential chain:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
import boto3
|
|
74
|
+
|
|
75
|
+
AnyrayTransport(bedrock={"region": "us-east-1", "credentials": boto3.Session().get_credentials})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Without a key for a provider, its calls are never sent direct.
|
|
79
|
+
|
|
80
|
+
## When a call goes direct
|
|
81
|
+
|
|
82
|
+
- **Sent direct:** the connection to the gateway never opened (refused, DNS, connect timeout) and the gateway's
|
|
83
|
+
`/livez` check also fails. After the outage has lasted your org's fallback time (120 seconds by default), calls go
|
|
84
|
+
direct without trying the gateway first. They return once the gateway has answered for your org's recover time
|
|
85
|
+
(10 minutes by default).
|
|
86
|
+
- **Never sent direct:** a call that may already have reached the provider (a reset mid-request, any 5xx), so
|
|
87
|
+
nothing is billed twice. Also a model or route only the gateway serves, and any call while your org has turned
|
|
88
|
+
fallback off. Nothing goes direct until the agent has read that setting from the gateway.
|
|
89
|
+
|
|
90
|
+
The gateway key is never sent to a provider.
|
|
91
|
+
|
|
92
|
+
## See what it is doing
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
transport = AnyrayTransport(on_status=lambda status: print(status.state))
|
|
96
|
+
transport.status() # FallbackStatus(state="armed" | "direct" | "blocked" | "off", reason=..., since=..., calls_direct=...)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The agent also reports its state to the gateway, without any request content, so it shows on the Agents page.
|
|
100
|
+
|
|
101
|
+
Full reference: [docs.anyray.ai/enroll/agent-fallback-reference](https://docs.anyray.ai/enroll/agent-fallback-reference).
|
|
102
|
+
|
|
103
|
+
MIT licensed.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# anyray-agent
|
|
2
|
+
|
|
3
|
+
Keeps an agent answering when its [Anyray](https://anyray.ai) gateway is unreachable. Give your SDK an `httpx2`
|
|
4
|
+
client built on `AnyrayTransport()`. While the gateway is up, every call goes through it unchanged. When the gateway
|
|
5
|
+
is down, a call goes straight to the provider instead.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install anyray-agent
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Python 3.11 or later. One dependency, `httpx2`, which the current OpenAI and Anthropic SDKs already use.
|
|
12
|
+
|
|
13
|
+
## Use it
|
|
14
|
+
|
|
15
|
+
Keep the client pointed at your gateway, with your agent key, exactly as before. Add one argument.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
import httpx2
|
|
21
|
+
from anthropic import Anthropic
|
|
22
|
+
from anyray_agent import AnyrayTransport
|
|
23
|
+
|
|
24
|
+
client = Anthropic(
|
|
25
|
+
base_url=os.environ["ANYRAY_GATEWAY_URL"],
|
|
26
|
+
api_key=os.environ["ANYRAY_CLIENT_KEY"],
|
|
27
|
+
http_client=httpx2.Client(transport=AnyrayTransport()),
|
|
28
|
+
)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
| Client | Where `AnyrayTransport()` goes |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| OpenAI SDK | `OpenAI(..., http_client=httpx2.Client(transport=AnyrayTransport()))` |
|
|
34
|
+
| Anthropic SDK | `Anthropic(..., http_client=httpx2.Client(transport=AnyrayTransport()))` |
|
|
35
|
+
| LangChain `ChatOpenAI` | `ChatOpenAI(..., http_client=httpx2.Client(transport=AnyrayTransport()))` |
|
|
36
|
+
| LangChain `ChatAnthropic` | `use_fallback(ChatAnthropic(...), transport=AnyrayTransport())` |
|
|
37
|
+
|
|
38
|
+
`use_fallback` comes from `anyray_agent.langchain`: `ChatAnthropic` has no client argument, so it swaps the clients
|
|
39
|
+
the model builds. Async code uses `AsyncAnyrayTransport` with `httpx2.AsyncClient` (`http_async_client=` on
|
|
40
|
+
`ChatOpenAI`, `async_transport=` on `use_fallback`). An SDK still on `httpx` 0.x (openai < 3, anthropic < 2) takes
|
|
41
|
+
the same classes from `anyray_agent.httpx` (`pip install "anyray-agent[httpx]"`).
|
|
42
|
+
|
|
43
|
+
## The provider key
|
|
44
|
+
|
|
45
|
+
A call can only go direct with a provider key on the host: `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` are read from the
|
|
46
|
+
environment, or pass them:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
AnyrayTransport(provider_keys={"openai": "…", "anthropic": "…"})
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
To send Anthropic calls to AWS Bedrock instead of api.anthropic.com, name the region. Credentials come from
|
|
53
|
+
`AWS_BEARER_TOKEN_BEDROCK` or the standard AWS variables, or pass `api_key` or `credentials`. For an IAM role or a
|
|
54
|
+
profile, pass boto3's credential chain:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
import boto3
|
|
58
|
+
|
|
59
|
+
AnyrayTransport(bedrock={"region": "us-east-1", "credentials": boto3.Session().get_credentials})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Without a key for a provider, its calls are never sent direct.
|
|
63
|
+
|
|
64
|
+
## When a call goes direct
|
|
65
|
+
|
|
66
|
+
- **Sent direct:** the connection to the gateway never opened (refused, DNS, connect timeout) and the gateway's
|
|
67
|
+
`/livez` check also fails. After the outage has lasted your org's fallback time (120 seconds by default), calls go
|
|
68
|
+
direct without trying the gateway first. They return once the gateway has answered for your org's recover time
|
|
69
|
+
(10 minutes by default).
|
|
70
|
+
- **Never sent direct:** a call that may already have reached the provider (a reset mid-request, any 5xx), so
|
|
71
|
+
nothing is billed twice. Also a model or route only the gateway serves, and any call while your org has turned
|
|
72
|
+
fallback off. Nothing goes direct until the agent has read that setting from the gateway.
|
|
73
|
+
|
|
74
|
+
The gateway key is never sent to a provider.
|
|
75
|
+
|
|
76
|
+
## See what it is doing
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
transport = AnyrayTransport(on_status=lambda status: print(status.state))
|
|
80
|
+
transport.status() # FallbackStatus(state="armed" | "direct" | "blocked" | "off", reason=..., since=..., calls_direct=...)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The agent also reports its state to the gateway, without any request content, so it shows on the Agents page.
|
|
84
|
+
|
|
85
|
+
Full reference: [docs.anyray.ai/enroll/agent-fallback-reference](https://docs.anyray.ai/enroll/agent-fallback-reference).
|
|
86
|
+
|
|
87
|
+
MIT licensed.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.33.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "anyray-agent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Keeps an agent answering when its Anyray gateway is unreachable: an httpx2 transport for the OpenAI, Anthropic and LangChain SDKs that resends a call straight to the provider only when the gateway never handled it."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = ["httpx2>=2.12,<3"]
|
|
13
|
+
keywords = ["anyray", "ai-gateway", "llm", "fallback", "openai", "anthropic", "bedrock", "langchain"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
# For SDKs still on httpx 0.x (openai < 3, anthropic < 2): `from anyray_agent.httpx import AnyrayTransport`.
|
|
22
|
+
httpx = ["httpx>=0.27,<1"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://docs.anyray.ai/enroll/agent-fallback-reference"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/anyray_agent"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.sdist]
|
|
31
|
+
# Hatchling also packs the nearest .gitignore whatever this says: the package keeps its own so the monorepo
|
|
32
|
+
# root's never ships, and scripts/checkDist.py refuses any other.
|
|
33
|
+
only-include = ["src/anyray_agent", "README.md", "pyproject.toml"]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
testpaths = ["tests"]
|
|
37
|
+
# Tests import the package from src/, so CI needs no build backend or install step.
|
|
38
|
+
pythonpath = ["src"]
|
|
39
|
+
|
|
40
|
+
[tool.coverage.run]
|
|
41
|
+
branch = true
|
|
42
|
+
source_pkgs = ["anyray_agent"]
|
|
43
|
+
|
|
44
|
+
[tool.coverage.report]
|
|
45
|
+
# Measured 95% (lines + branches) when the port landed; the untested lines are close-time and defensive edges.
|
|
46
|
+
fail_under = 94
|
|
47
|
+
show_missing = true
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Automatic direct-provider fallback for Anyray gateway clients using httpx2."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Mapping
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
import httpx2
|
|
7
|
+
|
|
8
|
+
from ._core import AsyncCore, SyncCore
|
|
9
|
+
from ._types import (FALLBACK_LEVELS, AsyncTransport, BedrockOptions, FallbackStatus,
|
|
10
|
+
Library, Request, SyncTransport)
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AnyrayTransport(SyncCore, httpx2.BaseTransport):
|
|
16
|
+
"""Wrap a sync transport; caller-supplied inner transports remain caller-owned."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, *, provider_keys: Mapping[str, str] | None = None,
|
|
19
|
+
bedrock: BedrockOptions | None = None, transport: httpx2.BaseTransport | None = None,
|
|
20
|
+
on_status: Callable[[FallbackStatus], None] | None = None) -> None:
|
|
21
|
+
super().__init__(cast(Library, httpx2), provider_keys=provider_keys, bedrock=bedrock,
|
|
22
|
+
transport=cast(SyncTransport | None, transport), on_status=on_status)
|
|
23
|
+
|
|
24
|
+
def handle_request(self, request: httpx2.Request) -> httpx2.Response:
|
|
25
|
+
return cast(httpx2.Response, self.run(self.send(cast(Request, request))))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsyncAnyrayTransport(AsyncCore, httpx2.AsyncBaseTransport):
|
|
29
|
+
"""Wrap an async transport and cancel its background work on aclose()."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, *, provider_keys: Mapping[str, str] | None = None,
|
|
32
|
+
bedrock: BedrockOptions | None = None, transport: httpx2.AsyncBaseTransport | None = None,
|
|
33
|
+
on_status: Callable[[FallbackStatus], None] | None = None) -> None:
|
|
34
|
+
super().__init__(cast(Library, httpx2), provider_keys=provider_keys, bedrock=bedrock,
|
|
35
|
+
transport=cast(AsyncTransport | None, transport), on_status=on_status)
|
|
36
|
+
|
|
37
|
+
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
|
|
38
|
+
return cast(httpx2.Response, await self.run(self.send(cast(Request, request))))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = ["AnyrayTransport", "AsyncAnyrayTransport", "FallbackStatus", "FALLBACK_LEVELS", "__version__"]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Bedrock request rewriting, model resolution and SigV4 signing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import cast
|
|
14
|
+
from urllib.parse import quote, urlsplit
|
|
15
|
+
|
|
16
|
+
from . import _runtime
|
|
17
|
+
from ._io import Close, Resolve, Send, Work
|
|
18
|
+
from ._rules import compact, header, provider_key
|
|
19
|
+
from ._types import BedrockOptions, CredentialSource, Library, Request, Response
|
|
20
|
+
|
|
21
|
+
RESOLVED_MODELS: dict[str, str] = {}
|
|
22
|
+
_CACHE_LOCK = threading.Lock()
|
|
23
|
+
BEDROCK_ID = re.compile(r"^([a-z-]+\.)?anthropic\.|^arn:aws[a-z-]*:bedrock:")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_bedrock_model(model: str) -> bool:
|
|
27
|
+
return model.startswith("claude-") or bool(BEDROCK_ID.search(model))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, repr=False)
|
|
31
|
+
class Keys:
|
|
32
|
+
access: str
|
|
33
|
+
secret: str
|
|
34
|
+
token: str | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def normalize(value: object) -> Keys | None:
|
|
38
|
+
frozen = getattr(value, "get_frozen_credentials", None)
|
|
39
|
+
if callable(frozen):
|
|
40
|
+
value = frozen()
|
|
41
|
+
if isinstance(value, Mapping):
|
|
42
|
+
access, secret, token = (value.get(name) for name in ("access_key_id", "secret_access_key", "session_token"))
|
|
43
|
+
else:
|
|
44
|
+
access, secret, token = (getattr(value, name, None) for name in ("access_key", "secret_key", "token"))
|
|
45
|
+
access = provider_key(access) if isinstance(access, str) else None
|
|
46
|
+
secret = provider_key(secret) if isinstance(secret, str) else None
|
|
47
|
+
raw_token = token.strip() if isinstance(token, str) else None
|
|
48
|
+
token = provider_key(raw_token)
|
|
49
|
+
if not access or not secret or (raw_token and not token):
|
|
50
|
+
return None
|
|
51
|
+
return Keys(access, secret, token)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def region_prefix(region: str) -> str:
|
|
55
|
+
if region.startswith("us-gov-"):
|
|
56
|
+
return ""
|
|
57
|
+
for family, prefix in (("us-", "us."), ("eu-", "eu."), ("ap-", "apac."), ("ca-", "ca.")):
|
|
58
|
+
if region.startswith(family):
|
|
59
|
+
return prefix
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sign(url: str, body: bytes, headers: dict[str, str], keys: Keys, region: str) -> None:
|
|
64
|
+
amz_date = datetime.fromtimestamp(_runtime.now(), timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
65
|
+
date = amz_date[:8]
|
|
66
|
+
headers["x-amz-date"] = amz_date
|
|
67
|
+
if keys.token:
|
|
68
|
+
headers["x-amz-security-token"] = keys.token
|
|
69
|
+
parts = urlsplit(url)
|
|
70
|
+
canonical_headers = {name: value for name, value in headers.items() if name != "accept"}
|
|
71
|
+
canonical_headers["host"] = parts.netloc
|
|
72
|
+
names = sorted(canonical_headers)
|
|
73
|
+
signed = ";".join(names)
|
|
74
|
+
values = {name: re.sub(r"\s+", " ", canonical_headers[name].strip()) for name in names}
|
|
75
|
+
text = "".join(f"{name}:{values[name]}\n" for name in names)
|
|
76
|
+
uri = "/".join(quote(part, safe="-_.~") for part in parts.path.split("/"))
|
|
77
|
+
canonical = f"POST\n{uri}\n\n{text}\n{signed}\n{hashlib.sha256(body).hexdigest()}"
|
|
78
|
+
scope = f"{date}/{region}/bedrock/aws4_request"
|
|
79
|
+
to_sign = f"AWS4-HMAC-SHA256\n{amz_date}\n{scope}\n{hashlib.sha256(canonical.encode()).hexdigest()}"
|
|
80
|
+
key = ("AWS4" + keys.secret).encode()
|
|
81
|
+
for value in (date, region, "bedrock", "aws4_request"):
|
|
82
|
+
key = hmac.digest(key, value.encode(), "sha256")
|
|
83
|
+
signature = hmac.new(key, to_sign.encode(), "sha256").hexdigest()
|
|
84
|
+
headers["authorization"] = (f"AWS4-HMAC-SHA256 Credential={keys.access}/{scope}, "
|
|
85
|
+
f"SignedHeaders={signed}, Signature={signature}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(repr=False)
|
|
89
|
+
class Plan:
|
|
90
|
+
requests: list[Request]
|
|
91
|
+
cache_key: str | None = None
|
|
92
|
+
candidates: list[str] | None = None
|
|
93
|
+
streaming: bool = False
|
|
94
|
+
|
|
95
|
+
def execute(self) -> Work[Response]:
|
|
96
|
+
for index, request in enumerate(self.requests):
|
|
97
|
+
response = cast(Response, (yield Send(request)))
|
|
98
|
+
if response.status_code < 400 and self.cache_key and self.candidates:
|
|
99
|
+
with _CACHE_LOCK:
|
|
100
|
+
RESOLVED_MODELS[self.cache_key] = self.candidates[index]
|
|
101
|
+
retry = response.status_code in (400, 404) and re.match(
|
|
102
|
+
r"^(ValidationException|ResourceNotFoundException)", header(response.headers, "x-amzn-errortype") or "")
|
|
103
|
+
if retry and index + 1 < len(self.requests):
|
|
104
|
+
yield Close(response)
|
|
105
|
+
continue
|
|
106
|
+
return response
|
|
107
|
+
raise RuntimeError("Empty direct plan")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Bedrock:
|
|
111
|
+
def __init__(self, options: BedrockOptions, *, defer_refresh: bool = False) -> None:
|
|
112
|
+
self.region = options["region"]
|
|
113
|
+
if not re.fullmatch(r"[a-z]{2}(-gov)?-[a-z]+-[0-9]+", self.region):
|
|
114
|
+
raise ValueError("Invalid Bedrock region")
|
|
115
|
+
self.api_key = provider_key(options.get("api_key")) or provider_key(os.environ.get("AWS_BEARER_TOKEN_BEDROCK"))
|
|
116
|
+
configured = options.get("credentials")
|
|
117
|
+
self.source: CredentialSource = configured if configured is not None else {
|
|
118
|
+
"access_key_id": os.environ.get("AWS_ACCESS_KEY_ID"),
|
|
119
|
+
"secret_access_key": os.environ.get("AWS_SECRET_ACCESS_KEY"),
|
|
120
|
+
"session_token": os.environ.get("AWS_SESSION_TOKEN"),
|
|
121
|
+
}
|
|
122
|
+
self.models = dict(options.get("models", {}))
|
|
123
|
+
try:
|
|
124
|
+
self.available = bool(self.api_key or callable(self.source)
|
|
125
|
+
or (defer_refresh and callable(getattr(self.source, "get_frozen_credentials", None)))
|
|
126
|
+
or normalize(self.source))
|
|
127
|
+
except Exception:
|
|
128
|
+
self.available = False
|
|
129
|
+
|
|
130
|
+
def plan(self, original: dict[str, object], incoming: Mapping[str, str], extensions: dict[str, object],
|
|
131
|
+
lib: Library) -> Work[Plan | str]:
|
|
132
|
+
keys = None
|
|
133
|
+
if not self.api_key:
|
|
134
|
+
try:
|
|
135
|
+
keys = cast(Keys | None, (yield Resolve(self.source)))
|
|
136
|
+
except Exception:
|
|
137
|
+
return "no_provider_key"
|
|
138
|
+
if keys is None:
|
|
139
|
+
return "no_provider_key"
|
|
140
|
+
model = cast(str, original["model"])
|
|
141
|
+
cache_key = f"{self.region}:{model}"
|
|
142
|
+
with _CACHE_LOCK:
|
|
143
|
+
known = self.models.get(model, RESOLVED_MODELS.get(cache_key))
|
|
144
|
+
base = f"{region_prefix(self.region)}anthropic.{model}"
|
|
145
|
+
candidates = [known] if known is not None else [model] if BEDROCK_ID.search(model) else [base, base + "-v1:0"]
|
|
146
|
+
body = {name: value for name, value in original.items()
|
|
147
|
+
if name not in ("model", "stream", "anthropic_version", "anthropic_beta")}
|
|
148
|
+
body["anthropic_version"] = "bedrock-2023-05-31"
|
|
149
|
+
betas = [beta.strip() for beta in (header(incoming, "anthropic-beta") or "").split(",") if beta.strip()]
|
|
150
|
+
if betas:
|
|
151
|
+
body["anthropic_beta"] = betas
|
|
152
|
+
content = compact(body).encode()
|
|
153
|
+
streaming = original.get("stream") is True
|
|
154
|
+
requests = []
|
|
155
|
+
for candidate in candidates:
|
|
156
|
+
# encodeURIComponent leaves these punctuation characters untouched; SigV4 encodes them again.
|
|
157
|
+
encoded = quote(candidate, safe="-_.!~*'()")
|
|
158
|
+
action = "invoke-with-response-stream" if streaming else "invoke"
|
|
159
|
+
url = f"https://bedrock-runtime.{self.region}.amazonaws.com/model/{encoded}/{action}"
|
|
160
|
+
headers = {"content-type": "application/json",
|
|
161
|
+
"accept": "application/vnd.amazon.eventstream" if streaming else "application/json"}
|
|
162
|
+
if self.api_key:
|
|
163
|
+
headers["authorization"] = f"Bearer {self.api_key}"
|
|
164
|
+
elif keys:
|
|
165
|
+
sign(url, content, headers, keys, self.region)
|
|
166
|
+
requests.append(lib.Request("POST", url, headers=headers, content=content, extensions=extensions.copy()))
|
|
167
|
+
# Explicit mappings belong to this transport, never the process-wide cache.
|
|
168
|
+
return Plan(requests, None if model in self.models else cache_key, candidates, streaming)
|