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.
@@ -0,0 +1,2 @@
1
+ .coverage
2
+ dist/
@@ -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)