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.
@@ -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)
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()
@@ -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())])