anyray-agent 0.1.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.
- anyray_agent/__init__.py +41 -0
- anyray_agent/_bedrock.py +168 -0
- anyray_agent/_core.py +418 -0
- anyray_agent/_direct.py +60 -0
- anyray_agent/_event_stream.py +132 -0
- anyray_agent/_io.py +38 -0
- anyray_agent/_link.py +281 -0
- anyray_agent/_report.py +24 -0
- anyray_agent/_rules.py +99 -0
- anyray_agent/_runtime.py +15 -0
- anyray_agent/_types.py +101 -0
- anyray_agent/httpx.py +34 -0
- anyray_agent/langchain.py +22 -0
- anyray_agent/py.typed +0 -0
- anyray_agent-0.1.0.dist-info/METADATA +103 -0
- anyray_agent-0.1.0.dist-info/RECORD +17 -0
- anyray_agent-0.1.0.dist-info/WHEEL +4 -0
anyray_agent/__init__.py
ADDED
|
@@ -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__"]
|
anyray_agent/_bedrock.py
ADDED
|
@@ -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)
|
anyray_agent/_core.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
"""One request flow for both libraries and both concurrency models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
12
|
+
from concurrent.futures import Future
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from typing import TypeVar, cast
|
|
15
|
+
|
|
16
|
+
from . import _runtime
|
|
17
|
+
from ._bedrock import Bedrock, Plan, normalize
|
|
18
|
+
from ._direct import plan_direct
|
|
19
|
+
from ._event_stream import convert
|
|
20
|
+
from ._io import Close, Effect, Read, Resolve, Send, Wait, Work
|
|
21
|
+
from ._link import Link, Outage, iso
|
|
22
|
+
from ._rules import gateway_answered, gateway_key, key_for, provider_for, provider_key
|
|
23
|
+
from ._types import AsyncTransport, BedrockOptions, FallbackStatus, Library, Request, Response, SyncTransport
|
|
24
|
+
|
|
25
|
+
T = TypeVar("T")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Core:
|
|
29
|
+
def __init__(self, lib: Library, *, provider_keys: Mapping[str, str] | None = None,
|
|
30
|
+
bedrock: BedrockOptions | None = None, on_status: Callable[[FallbackStatus], None] | None = None) -> None:
|
|
31
|
+
self.lib = lib
|
|
32
|
+
self.bedrock = Bedrock(bedrock, defer_refresh=isinstance(self, AsyncCore)) if bedrock is not None else None
|
|
33
|
+
self.keys = {name: provider_key((provider_keys or {}).get(name)) or provider_key(os.environ.get(env))
|
|
34
|
+
for name, env in (("openai", "OPENAI_API_KEY"), ("anthropic", "ANTHROPIC_API_KEY"))}
|
|
35
|
+
has_keys = bool(self.keys["openai"] or (self.bedrock.available if self.bedrock else self.keys["anthropic"]))
|
|
36
|
+
self.initial = FallbackStatus("blocked", reason="policy_unknown" if has_keys else "no_provider_key", since=iso())
|
|
37
|
+
self.delivered = self.initial
|
|
38
|
+
self.on_status = on_status
|
|
39
|
+
self.base: str | None = None
|
|
40
|
+
self.active: Link | None = None
|
|
41
|
+
self.links: OrderedDict[str, Link] = OrderedDict()
|
|
42
|
+
self.outage = Outage()
|
|
43
|
+
self.closed = False
|
|
44
|
+
self.lock = threading.RLock()
|
|
45
|
+
self.jobs: list[tuple[Work[object], Future[object]]] = []
|
|
46
|
+
self.notifications: list[FallbackStatus] = []
|
|
47
|
+
self.timers: list[tuple[Link, threading.Event]] = []
|
|
48
|
+
|
|
49
|
+
@contextmanager
|
|
50
|
+
def atomic(self) -> Iterator[None]:
|
|
51
|
+
with self.lock:
|
|
52
|
+
try:
|
|
53
|
+
yield
|
|
54
|
+
finally:
|
|
55
|
+
jobs, self.jobs = self.jobs, []
|
|
56
|
+
notifications, self.notifications = self.notifications, []
|
|
57
|
+
timers, self.timers = self.timers, []
|
|
58
|
+
for status in notifications:
|
|
59
|
+
if self.on_status and not self.closed:
|
|
60
|
+
try:
|
|
61
|
+
self.on_status(status)
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
for work, future in jobs:
|
|
65
|
+
self.launch(work, future)
|
|
66
|
+
for link, stop in timers:
|
|
67
|
+
self.launch_timer(link, stop)
|
|
68
|
+
|
|
69
|
+
def notify(self, link: Link, status: FallbackStatus) -> None:
|
|
70
|
+
if self.active is link and self.delivered != status:
|
|
71
|
+
self.delivered = status
|
|
72
|
+
self.notifications.append(status)
|
|
73
|
+
|
|
74
|
+
def schedule(self, work: Work[object], future: Future[object] | None = None) -> None:
|
|
75
|
+
self.jobs.append((work, future if future is not None else Future()))
|
|
76
|
+
|
|
77
|
+
def queue_timer(self, link: Link, stop: threading.Event) -> None:
|
|
78
|
+
self.timers.append((link, stop))
|
|
79
|
+
|
|
80
|
+
def launch(self, work: Work[object], future: Future[object]) -> None:
|
|
81
|
+
raise NotImplementedError
|
|
82
|
+
|
|
83
|
+
def launch_timer(self, link: Link, stop: threading.Event) -> None:
|
|
84
|
+
raise NotImplementedError
|
|
85
|
+
|
|
86
|
+
def status(self) -> FallbackStatus:
|
|
87
|
+
with self.lock:
|
|
88
|
+
return self.active.current if self.active else self.initial
|
|
89
|
+
|
|
90
|
+
def bind(self, request: Request) -> tuple[Link, str] | None:
|
|
91
|
+
path = request.url.raw_path.split(b"?", 1)[0].decode("ascii")
|
|
92
|
+
split = path.find("/v1/")
|
|
93
|
+
if split < 0:
|
|
94
|
+
return None
|
|
95
|
+
base = request.url.scheme + "://" + request.url.netloc.decode("ascii") + path[:split]
|
|
96
|
+
key = gateway_key(request.headers)
|
|
97
|
+
if not key:
|
|
98
|
+
return None
|
|
99
|
+
with self.atomic():
|
|
100
|
+
if self.base is not None and self.base != base:
|
|
101
|
+
return None
|
|
102
|
+
self.base = base
|
|
103
|
+
link = self.links.get(key)
|
|
104
|
+
if link is None:
|
|
105
|
+
link = Link(self, base, key, self.initial, self.outage)
|
|
106
|
+
self.links[key] = link
|
|
107
|
+
if len(self.links) > _runtime.MAX_LINKS:
|
|
108
|
+
self.links.popitem(last=False)
|
|
109
|
+
if self.active is not link:
|
|
110
|
+
self.active = link
|
|
111
|
+
self.notify(link, link.current)
|
|
112
|
+
return link, path[split:]
|
|
113
|
+
|
|
114
|
+
def note_keys(self, link: Link, path: str, request: Request) -> None:
|
|
115
|
+
provider = provider_for(path)
|
|
116
|
+
if provider:
|
|
117
|
+
available = self.bedrock.available if provider == "anthropic" and self.bedrock else bool(key_for(provider, request.headers, self.keys))
|
|
118
|
+
link.note_key(provider, available)
|
|
119
|
+
|
|
120
|
+
def direct_plan(self, link: Link, request: Request, path: str, content: bytes | None) -> Work[Plan | None]:
|
|
121
|
+
plan = yield from plan_direct(request, path, content, self.keys, self.bedrock, self.lib)
|
|
122
|
+
with self.atomic():
|
|
123
|
+
if isinstance(plan, str):
|
|
124
|
+
link.blocked(plan)
|
|
125
|
+
return None
|
|
126
|
+
# A policy flip and the decision to send direct must be one atomic transition.
|
|
127
|
+
if not link.auto_fallback or not link.policy_known or self.closed:
|
|
128
|
+
return None
|
|
129
|
+
link.sent_direct()
|
|
130
|
+
return plan
|
|
131
|
+
|
|
132
|
+
def execute(self, plan: Plan) -> Work[Response]:
|
|
133
|
+
response = yield from plan.execute()
|
|
134
|
+
# The outer client builds redirects from the original gateway request,
|
|
135
|
+
# including its credentials. Never let a direct response redirect it.
|
|
136
|
+
if 300 <= response.status_code < 400 and "location" in response.headers:
|
|
137
|
+
del response.headers["location"]
|
|
138
|
+
if plan.streaming and response.status_code == 200:
|
|
139
|
+
return convert(response, self.lib, isinstance(self, AsyncCore))
|
|
140
|
+
return response
|
|
141
|
+
|
|
142
|
+
def send(self, request: Request) -> Work[Response]:
|
|
143
|
+
binding = self.bind(request)
|
|
144
|
+
if binding is None:
|
|
145
|
+
return cast(Response, (yield Send(request)))
|
|
146
|
+
link, path = binding
|
|
147
|
+
try:
|
|
148
|
+
content: bytes | None = request.content
|
|
149
|
+
except self.lib.RequestNotRead:
|
|
150
|
+
content = None
|
|
151
|
+
attempted_direct = False
|
|
152
|
+
with self.atomic():
|
|
153
|
+
switched = link.auto_fallback and link.policy_known and link.outage.switched
|
|
154
|
+
if switched:
|
|
155
|
+
self.note_keys(link, path, request)
|
|
156
|
+
link.touch()
|
|
157
|
+
if switched:
|
|
158
|
+
plan = yield from self.direct_plan(link, request, path, content)
|
|
159
|
+
if plan is not None:
|
|
160
|
+
attempted_direct = True
|
|
161
|
+
try:
|
|
162
|
+
return (yield from self.execute(plan))
|
|
163
|
+
except Exception as error:
|
|
164
|
+
if not isinstance(error, (self.lib.ConnectError, self.lib.ConnectTimeout)):
|
|
165
|
+
raise
|
|
166
|
+
try:
|
|
167
|
+
with self.atomic():
|
|
168
|
+
link.touch()
|
|
169
|
+
response = cast(Response, (yield Send(request)))
|
|
170
|
+
except Exception as error:
|
|
171
|
+
with self.atomic():
|
|
172
|
+
link.touch()
|
|
173
|
+
self.note_keys(link, path, request)
|
|
174
|
+
enabled = link.auto_fallback
|
|
175
|
+
if not enabled:
|
|
176
|
+
raise
|
|
177
|
+
if not isinstance(error, (self.lib.ConnectError, self.lib.ConnectTimeout)):
|
|
178
|
+
with self.atomic():
|
|
179
|
+
link.probe()
|
|
180
|
+
raise
|
|
181
|
+
with self.atomic():
|
|
182
|
+
probe = link.probe()
|
|
183
|
+
up = yield Wait(probe)
|
|
184
|
+
if not up and not attempted_direct:
|
|
185
|
+
with self.atomic():
|
|
186
|
+
policy = link.ensure_policy() if link.auto_fallback else None
|
|
187
|
+
if policy is not None:
|
|
188
|
+
yield Wait(policy)
|
|
189
|
+
with self.atomic():
|
|
190
|
+
enabled = link.auto_fallback
|
|
191
|
+
known = link.policy_known
|
|
192
|
+
if enabled and not known:
|
|
193
|
+
link.blocked("policy_unknown")
|
|
194
|
+
if enabled and known:
|
|
195
|
+
plan = yield from self.direct_plan(link, request, path, content)
|
|
196
|
+
if plan is not None:
|
|
197
|
+
return (yield from self.execute(plan))
|
|
198
|
+
raise
|
|
199
|
+
with self.atomic():
|
|
200
|
+
self.note_keys(link, path, request)
|
|
201
|
+
if gateway_answered(response.status_code, response.headers):
|
|
202
|
+
link.healthy()
|
|
203
|
+
elif link.auto_fallback:
|
|
204
|
+
link.probe()
|
|
205
|
+
return response
|
|
206
|
+
|
|
207
|
+
def stop(self) -> bool:
|
|
208
|
+
with self.atomic():
|
|
209
|
+
if self.closed:
|
|
210
|
+
return False
|
|
211
|
+
self.closed = True
|
|
212
|
+
if self.outage.timer:
|
|
213
|
+
self.outage.timer.set()
|
|
214
|
+
self.outage.timer = None
|
|
215
|
+
return True
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def advance(work: Work[T], result: object, error: BaseException | None) -> Effect:
|
|
219
|
+
return work.throw(error) if error is not None else work.send(result)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def reject_closed(work: Work[object], future: Future[object]) -> None:
|
|
223
|
+
work.close()
|
|
224
|
+
future.set_exception(RuntimeError("Transport is closed"))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class SyncCore(Core):
|
|
228
|
+
def __init__(self, lib: Library, *, transport: SyncTransport | None = None,
|
|
229
|
+
provider_keys: Mapping[str, str] | None = None, bedrock: BedrockOptions | None = None,
|
|
230
|
+
on_status: Callable[[FallbackStatus], None] | None = None) -> None:
|
|
231
|
+
super().__init__(lib, provider_keys=provider_keys, bedrock=bedrock, on_status=on_status)
|
|
232
|
+
self.owned = transport is None
|
|
233
|
+
self.inner = transport if transport is not None else lib.HTTPTransport()
|
|
234
|
+
self.threads: set[threading.Thread] = set()
|
|
235
|
+
|
|
236
|
+
def run(self, work: Work[T]) -> T:
|
|
237
|
+
result: object = None
|
|
238
|
+
error: BaseException | None = None
|
|
239
|
+
while True:
|
|
240
|
+
try:
|
|
241
|
+
effect = advance(work, result, error)
|
|
242
|
+
except StopIteration as done:
|
|
243
|
+
return cast(T, done.value)
|
|
244
|
+
error = None
|
|
245
|
+
try:
|
|
246
|
+
result = self.effect(effect)
|
|
247
|
+
except BaseException as failure:
|
|
248
|
+
error = failure
|
|
249
|
+
|
|
250
|
+
def effect(self, effect: Effect) -> object:
|
|
251
|
+
if isinstance(effect, Send):
|
|
252
|
+
if self.closed:
|
|
253
|
+
raise RuntimeError("Transport is closed")
|
|
254
|
+
return self.inner.handle_request(effect.request)
|
|
255
|
+
if isinstance(effect, Read):
|
|
256
|
+
return effect.response.read()
|
|
257
|
+
if isinstance(effect, Close):
|
|
258
|
+
try:
|
|
259
|
+
effect.response.close()
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
return None
|
|
263
|
+
if isinstance(effect, Wait):
|
|
264
|
+
return effect.future.result()
|
|
265
|
+
value = effect.source() if callable(effect.source) else effect.source
|
|
266
|
+
if inspect.isawaitable(value):
|
|
267
|
+
if inspect.iscoroutine(value):
|
|
268
|
+
value.close()
|
|
269
|
+
raise TypeError("Async credentials require AsyncAnyrayTransport")
|
|
270
|
+
return normalize(value)
|
|
271
|
+
|
|
272
|
+
def spawn(self, target: Callable[[], None]) -> bool:
|
|
273
|
+
def run() -> None:
|
|
274
|
+
try:
|
|
275
|
+
target()
|
|
276
|
+
finally:
|
|
277
|
+
with self.lock:
|
|
278
|
+
self.threads.discard(threading.current_thread())
|
|
279
|
+
with self.lock:
|
|
280
|
+
if self.closed:
|
|
281
|
+
return False
|
|
282
|
+
thread = threading.Thread(target=run, daemon=True)
|
|
283
|
+
self.threads.add(thread)
|
|
284
|
+
thread.start()
|
|
285
|
+
return True
|
|
286
|
+
|
|
287
|
+
def launch(self, work: Work[object], future: Future[object]) -> None:
|
|
288
|
+
def run() -> None:
|
|
289
|
+
try:
|
|
290
|
+
result = self.run(work)
|
|
291
|
+
except BaseException as error:
|
|
292
|
+
future.set_exception(error)
|
|
293
|
+
else:
|
|
294
|
+
future.set_result(result)
|
|
295
|
+
if not self.spawn(run):
|
|
296
|
+
reject_closed(work, future)
|
|
297
|
+
|
|
298
|
+
def launch_timer(self, link: Link, stop: threading.Event) -> None:
|
|
299
|
+
def run() -> None:
|
|
300
|
+
while not stop.wait(_runtime.PROBE_INTERVAL):
|
|
301
|
+
with self.atomic():
|
|
302
|
+
if self.closed or stop.is_set():
|
|
303
|
+
return
|
|
304
|
+
future = link.probe()
|
|
305
|
+
try:
|
|
306
|
+
future.result()
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
self.spawn(run)
|
|
310
|
+
|
|
311
|
+
def close(self) -> None:
|
|
312
|
+
if not self.stop():
|
|
313
|
+
return
|
|
314
|
+
if self.owned:
|
|
315
|
+
self.inner.close()
|
|
316
|
+
deadline = time.monotonic() + 0.5
|
|
317
|
+
with self.lock:
|
|
318
|
+
threads = list(self.threads)
|
|
319
|
+
for thread in threads:
|
|
320
|
+
if thread is not threading.current_thread():
|
|
321
|
+
thread.join(max(0, deadline - time.monotonic()))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class AsyncCore(Core):
|
|
325
|
+
def __init__(self, lib: Library, *, transport: AsyncTransport | None = None,
|
|
326
|
+
provider_keys: Mapping[str, str] | None = None, bedrock: BedrockOptions | None = None,
|
|
327
|
+
on_status: Callable[[FallbackStatus], None] | None = None) -> None:
|
|
328
|
+
super().__init__(lib, provider_keys=provider_keys, bedrock=bedrock, on_status=on_status)
|
|
329
|
+
self.owned = transport is None
|
|
330
|
+
self.inner = transport if transport is not None else lib.AsyncHTTPTransport()
|
|
331
|
+
self.tasks: set[asyncio.Task[None]] = set()
|
|
332
|
+
|
|
333
|
+
async def run(self, work: Work[T]) -> T:
|
|
334
|
+
result: object = None
|
|
335
|
+
error: BaseException | None = None
|
|
336
|
+
while True:
|
|
337
|
+
try:
|
|
338
|
+
effect = advance(work, result, error)
|
|
339
|
+
except StopIteration as done:
|
|
340
|
+
return cast(T, done.value)
|
|
341
|
+
error = None
|
|
342
|
+
try:
|
|
343
|
+
result = await self.effect(effect)
|
|
344
|
+
except BaseException as failure:
|
|
345
|
+
error = failure
|
|
346
|
+
|
|
347
|
+
async def effect(self, effect: Effect) -> object:
|
|
348
|
+
if isinstance(effect, Send):
|
|
349
|
+
if self.closed:
|
|
350
|
+
raise RuntimeError("Transport is closed")
|
|
351
|
+
return await self.inner.handle_async_request(effect.request)
|
|
352
|
+
if isinstance(effect, Read):
|
|
353
|
+
return await effect.response.aread()
|
|
354
|
+
if isinstance(effect, Close):
|
|
355
|
+
try:
|
|
356
|
+
await effect.response.aclose()
|
|
357
|
+
except Exception:
|
|
358
|
+
pass
|
|
359
|
+
return None
|
|
360
|
+
if isinstance(effect, Wait):
|
|
361
|
+
return await asyncio.shield(asyncio.wrap_future(effect.future))
|
|
362
|
+
source = effect.source
|
|
363
|
+
if callable(source):
|
|
364
|
+
asynchronous = inspect.iscoroutinefunction(source) or inspect.iscoroutinefunction(source.__call__)
|
|
365
|
+
value = source() if asynchronous else await asyncio.to_thread(source)
|
|
366
|
+
else:
|
|
367
|
+
value = source
|
|
368
|
+
if inspect.isawaitable(value):
|
|
369
|
+
value = await value
|
|
370
|
+
return await asyncio.to_thread(normalize, value)
|
|
371
|
+
|
|
372
|
+
def launch(self, work: Work[object], future: Future[object]) -> None:
|
|
373
|
+
def finished(task: asyncio.Task[None]) -> None:
|
|
374
|
+
self.tasks.discard(task)
|
|
375
|
+
# Cancellation can happen before run() ever enters its try block.
|
|
376
|
+
if not future.done():
|
|
377
|
+
reject_closed(work, future)
|
|
378
|
+
|
|
379
|
+
async def run() -> None:
|
|
380
|
+
try:
|
|
381
|
+
result = await self.run(work)
|
|
382
|
+
except BaseException as error:
|
|
383
|
+
future.set_exception(error)
|
|
384
|
+
else:
|
|
385
|
+
future.set_result(result)
|
|
386
|
+
if self.closed:
|
|
387
|
+
reject_closed(work, future)
|
|
388
|
+
return
|
|
389
|
+
task = asyncio.create_task(run())
|
|
390
|
+
self.tasks.add(task)
|
|
391
|
+
task.add_done_callback(finished)
|
|
392
|
+
|
|
393
|
+
def launch_timer(self, link: Link, stop: threading.Event) -> None:
|
|
394
|
+
async def run() -> None:
|
|
395
|
+
try:
|
|
396
|
+
while not stop.is_set():
|
|
397
|
+
await asyncio.sleep(_runtime.PROBE_INTERVAL)
|
|
398
|
+
with self.atomic():
|
|
399
|
+
if self.closed or stop.is_set():
|
|
400
|
+
return
|
|
401
|
+
future = link.probe()
|
|
402
|
+
await asyncio.shield(asyncio.wrap_future(future))
|
|
403
|
+
except (Exception, asyncio.CancelledError):
|
|
404
|
+
pass
|
|
405
|
+
if not self.closed:
|
|
406
|
+
task = asyncio.create_task(run())
|
|
407
|
+
self.tasks.add(task)
|
|
408
|
+
task.add_done_callback(self.tasks.discard)
|
|
409
|
+
|
|
410
|
+
async def aclose(self) -> None:
|
|
411
|
+
if not self.stop():
|
|
412
|
+
return
|
|
413
|
+
tasks = [task for task in self.tasks if task is not asyncio.current_task()]
|
|
414
|
+
for task in tasks:
|
|
415
|
+
task.cancel()
|
|
416
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
417
|
+
if self.owned:
|
|
418
|
+
await self.inner.aclose()
|
anyray_agent/_direct.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Plan a replay without changing the original gateway request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
from ._bedrock import Bedrock, Plan, is_bedrock_model
|
|
9
|
+
from ._io import Work
|
|
10
|
+
from ._rules import ARK, MODELS, ORIGINS, has_gateway_key, key_for, parse_json, provider_for, same_origin
|
|
11
|
+
from ._types import Library, Request
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def plan_direct(request: Request, path: str, content: bytes | None, keys: Mapping[str, str | None],
|
|
15
|
+
bedrock: Bedrock | None, lib: Library) -> Work[Plan | str]:
|
|
16
|
+
provider = provider_for(path)
|
|
17
|
+
if not provider or request.method.upper() != "POST":
|
|
18
|
+
return "gateway_only"
|
|
19
|
+
headers = request.headers
|
|
20
|
+
if any(name in headers for name in ("x-anyray-provider", "x-anyray-config", "x-anyray-custom-host", "x-anyray-virtual-key")):
|
|
21
|
+
return "gateway_only"
|
|
22
|
+
use_bedrock = bedrock if provider == "anthropic" else None
|
|
23
|
+
if use_bedrock and path != "/v1/messages":
|
|
24
|
+
return "gateway_only"
|
|
25
|
+
key = key_for(provider, headers, keys)
|
|
26
|
+
if not (use_bedrock.available if use_bedrock else key):
|
|
27
|
+
return "no_provider_key"
|
|
28
|
+
if content is None:
|
|
29
|
+
return "body_not_replayable"
|
|
30
|
+
try:
|
|
31
|
+
parsed = parse_json(content)
|
|
32
|
+
except (ValueError, UnicodeError):
|
|
33
|
+
return "body_not_replayable"
|
|
34
|
+
if not isinstance(parsed, dict):
|
|
35
|
+
return "body_not_replayable"
|
|
36
|
+
body = cast(dict[str, object], parsed)
|
|
37
|
+
if has_gateway_key(body):
|
|
38
|
+
return "gateway_only"
|
|
39
|
+
model = body.get("model")
|
|
40
|
+
if not isinstance(model, str) or not (is_bedrock_model(model) if use_bedrock else MODELS[provider].search(model)):
|
|
41
|
+
return "gateway_only"
|
|
42
|
+
tools = body.get("tools")
|
|
43
|
+
servers = body.get("mcp_servers")
|
|
44
|
+
if isinstance(tools, list) and any(isinstance(tool, dict) and tool.get("type") == "mcp"
|
|
45
|
+
and same_origin(tool.get("server_url"), str(request.url)) for tool in tools):
|
|
46
|
+
return "gateway_only"
|
|
47
|
+
if isinstance(servers, list) and any(isinstance(server, dict) and same_origin(server.get("url"), str(request.url))
|
|
48
|
+
for server in servers):
|
|
49
|
+
return "gateway_only"
|
|
50
|
+
if path == "/v1/responses" and ("previous_response_id" in body or "conversation" in body):
|
|
51
|
+
return "gateway_only"
|
|
52
|
+
if use_bedrock:
|
|
53
|
+
return (yield from use_bedrock.plan(body, headers, request.extensions, lib))
|
|
54
|
+
direct_headers = {name: value.strip() for name, value in headers.items()
|
|
55
|
+
if not name.startswith("x-anyray-") and name not in ("authorization", "x-api-key", "host", "content-length")
|
|
56
|
+
and not ARK.search(value)}
|
|
57
|
+
direct_headers["authorization" if provider == "openai" else "x-api-key"] = f"Bearer {key}" if provider == "openai" else cast(str, key)
|
|
58
|
+
query = request.url.query.decode("ascii")
|
|
59
|
+
url = ORIGINS[provider] + path + ("?" + query if query else "")
|
|
60
|
+
return Plan([lib.Request("POST", url, headers=direct_headers, content=content, extensions=request.extensions.copy())])
|