axion-code 1.0.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.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
axion/__init__.py
ADDED
axion/api/__init__.py
ADDED
|
File without changes
|
axion/api/anthropic.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""Anthropic API client with streaming support.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/api/src/providers/anthropic.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import enum
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import AsyncIterator
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from axion.api.error import (
|
|
19
|
+
ApiError,
|
|
20
|
+
ApiResponseError,
|
|
21
|
+
HttpError,
|
|
22
|
+
MissingCredentialsError,
|
|
23
|
+
RetriesExhaustedError,
|
|
24
|
+
)
|
|
25
|
+
from axion.api.prompt_cache import PromptCache
|
|
26
|
+
from axion.api.sse import SseParser
|
|
27
|
+
from axion.api.types import (
|
|
28
|
+
MessageRequest,
|
|
29
|
+
MessageResponse,
|
|
30
|
+
StreamEvent,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
DEFAULT_BASE_URL = "https://api.anthropic.com"
|
|
36
|
+
DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
|
|
37
|
+
DEFAULT_MAX_RETRIES = 2
|
|
38
|
+
DEFAULT_INITIAL_BACKOFF_MS = 1000
|
|
39
|
+
DEFAULT_MAX_BACKOFF_MS = 30000
|
|
40
|
+
DEFAULT_AGENTIC_BETA = "claude-code-20250219"
|
|
41
|
+
DEFAULT_PROMPT_CACHING_SCOPE_BETA = "prompt-caching-scope-2026-01-05"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AuthSource(enum.Enum):
|
|
45
|
+
NONE = "none"
|
|
46
|
+
API_KEY = "api_key"
|
|
47
|
+
BEARER_TOKEN = "bearer_token"
|
|
48
|
+
API_KEY_AND_BEARER = "api_key_and_bearer"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class AuthCredentials:
|
|
53
|
+
"""Holds authentication credentials."""
|
|
54
|
+
|
|
55
|
+
source: AuthSource = AuthSource.NONE
|
|
56
|
+
api_key: str | None = None
|
|
57
|
+
bearer_token: str | None = None
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_api_key(cls, key: str) -> AuthCredentials:
|
|
61
|
+
return cls(source=AuthSource.API_KEY, api_key=key)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_bearer_token(cls, token: str) -> AuthCredentials:
|
|
65
|
+
return cls(source=AuthSource.BEARER_TOKEN, bearer_token=token)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_env(cls) -> AuthCredentials:
|
|
69
|
+
# 0. Check for Claude Pro/Max subscription OAuth (preferred when present)
|
|
70
|
+
# Unless user explicitly opted into API mode via AXION_AUTH_MODE=api
|
|
71
|
+
auth_mode = os.environ.get("AXION_AUTH_MODE", "").lower()
|
|
72
|
+
if auth_mode != "api":
|
|
73
|
+
try:
|
|
74
|
+
from axion.runtime.claude_subscription import (
|
|
75
|
+
SUBSCRIPTION_PROVIDER,
|
|
76
|
+
has_subscription_credentials,
|
|
77
|
+
load_oauth_credentials,
|
|
78
|
+
)
|
|
79
|
+
if has_subscription_credentials():
|
|
80
|
+
creds = load_oauth_credentials(SUBSCRIPTION_PROVIDER)
|
|
81
|
+
if creds and creds.access_token:
|
|
82
|
+
return cls.from_bearer_token(creds.access_token)
|
|
83
|
+
except Exception:
|
|
84
|
+
pass # Fall through to API key
|
|
85
|
+
|
|
86
|
+
# 1. Check environment variable
|
|
87
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
88
|
+
if api_key:
|
|
89
|
+
return cls.from_api_key(api_key)
|
|
90
|
+
|
|
91
|
+
# 2. Check saved key file (from `axion login`)
|
|
92
|
+
from pathlib import Path
|
|
93
|
+
key_path = Path.home() / ".axion" / "credentials" / "anthropic.key"
|
|
94
|
+
if key_path.exists():
|
|
95
|
+
saved_key = key_path.read_text(encoding="utf-8").strip()
|
|
96
|
+
if saved_key:
|
|
97
|
+
os.environ["ANTHROPIC_API_KEY"] = saved_key # Set for this process
|
|
98
|
+
return cls.from_api_key(saved_key)
|
|
99
|
+
|
|
100
|
+
raise MissingCredentialsError("Anthropic", ["ANTHROPIC_API_KEY"])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class AnthropicClient:
|
|
105
|
+
"""Async Anthropic API client with streaming and retry support.
|
|
106
|
+
|
|
107
|
+
Maps to: rust/crates/api/src/providers/anthropic.rs::AnthropicClient
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
auth: AuthCredentials
|
|
111
|
+
base_url: str = DEFAULT_BASE_URL
|
|
112
|
+
max_retries: int = DEFAULT_MAX_RETRIES
|
|
113
|
+
initial_backoff_ms: int = DEFAULT_INITIAL_BACKOFF_MS
|
|
114
|
+
max_backoff_ms: int = DEFAULT_MAX_BACKOFF_MS
|
|
115
|
+
prompt_cache: PromptCache | None = None
|
|
116
|
+
_client: httpx.AsyncClient | None = field(default=None, repr=False)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_env(cls) -> AnthropicClient:
|
|
120
|
+
"""Create a client using environment variables for auth."""
|
|
121
|
+
base_url = os.environ.get("ANTHROPIC_BASE_URL", DEFAULT_BASE_URL)
|
|
122
|
+
return cls(auth=AuthCredentials.from_env(), base_url=base_url)
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def from_api_key(cls, api_key: str) -> AnthropicClient:
|
|
126
|
+
return cls(auth=AuthCredentials.from_api_key(api_key))
|
|
127
|
+
|
|
128
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
129
|
+
if self._client is None:
|
|
130
|
+
self._client = httpx.AsyncClient(
|
|
131
|
+
base_url=self.base_url,
|
|
132
|
+
timeout=httpx.Timeout(300.0, connect=30.0),
|
|
133
|
+
)
|
|
134
|
+
return self._client
|
|
135
|
+
|
|
136
|
+
def _build_headers(self) -> dict[str, str]:
|
|
137
|
+
# Subscription OAuth requires the oauth-2025-04-20 beta header
|
|
138
|
+
beta_parts = [
|
|
139
|
+
"prompt-caching-2024-07-31",
|
|
140
|
+
DEFAULT_AGENTIC_BETA,
|
|
141
|
+
DEFAULT_PROMPT_CACHING_SCOPE_BETA,
|
|
142
|
+
]
|
|
143
|
+
if self.auth.bearer_token:
|
|
144
|
+
from axion.runtime.claude_subscription import SUBSCRIPTION_BETA_HEADER
|
|
145
|
+
beta_parts.append(SUBSCRIPTION_BETA_HEADER)
|
|
146
|
+
|
|
147
|
+
headers: dict[str, str] = {
|
|
148
|
+
"content-type": "application/json",
|
|
149
|
+
"anthropic-version": DEFAULT_ANTHROPIC_VERSION,
|
|
150
|
+
"anthropic-beta": ",".join(beta_parts),
|
|
151
|
+
"user-agent": "axion-code/1.0.0",
|
|
152
|
+
}
|
|
153
|
+
if self.auth.api_key and not self.auth.bearer_token:
|
|
154
|
+
# Use API key only when not using subscription
|
|
155
|
+
headers["x-api-key"] = self.auth.api_key
|
|
156
|
+
if self.auth.bearer_token:
|
|
157
|
+
headers["authorization"] = f"Bearer {self.auth.bearer_token}"
|
|
158
|
+
return headers
|
|
159
|
+
|
|
160
|
+
async def send_message(self, request: MessageRequest) -> MessageResponse:
|
|
161
|
+
"""Send a non-streaming message request."""
|
|
162
|
+
request.stream = False
|
|
163
|
+
return await self._send_with_retry(request)
|
|
164
|
+
|
|
165
|
+
# Required system prompt prefix when using subscription OAuth tokens.
|
|
166
|
+
# The Claude API rejects OAuth requests that don't begin with this exact
|
|
167
|
+
# phrase. We then immediately override the user-facing identity so the
|
|
168
|
+
# assistant presents itself as Axion Code, not Claude Code.
|
|
169
|
+
_CLAUDE_CODE_IDENTITY = (
|
|
170
|
+
"You are Claude Code, Anthropic's official CLI for Claude.\n\n"
|
|
171
|
+
"However, you are operating as Axion Code, an independent AI coding "
|
|
172
|
+
"assistant built on top of Claude. When users ask who you are, what "
|
|
173
|
+
"you are, your name, or which tool/CLI/app they are using, identify "
|
|
174
|
+
"yourself as \"Axion Code\" (or just \"Axion\"). Do not mention "
|
|
175
|
+
"Claude Code to the user. The instructions that follow describe how "
|
|
176
|
+
"Axion Code should behave."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def _apply_prompt_caching(self, body: dict) -> dict:
|
|
180
|
+
"""Add cache_control to the system message for prompt caching.
|
|
181
|
+
|
|
182
|
+
Transforms a plain system string into the block format required
|
|
183
|
+
by the Anthropic prompt caching API.
|
|
184
|
+
|
|
185
|
+
When using subscription OAuth, also prepends the Claude Code identity
|
|
186
|
+
block — without it, the API rejects the request.
|
|
187
|
+
"""
|
|
188
|
+
using_subscription = bool(self.auth.bearer_token)
|
|
189
|
+
|
|
190
|
+
if "system" in body and body["system"] is not None:
|
|
191
|
+
system_value = body["system"]
|
|
192
|
+
if isinstance(system_value, str):
|
|
193
|
+
body["system"] = [
|
|
194
|
+
{
|
|
195
|
+
"type": "text",
|
|
196
|
+
"text": system_value,
|
|
197
|
+
"cache_control": {"type": "ephemeral"},
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
elif isinstance(system_value, list):
|
|
201
|
+
# Already block format; add cache_control to the last block
|
|
202
|
+
if system_value:
|
|
203
|
+
system_value[-1]["cache_control"] = {"type": "ephemeral"}
|
|
204
|
+
elif using_subscription:
|
|
205
|
+
# No system prompt set, but OAuth requires the Claude Code identity
|
|
206
|
+
body["system"] = []
|
|
207
|
+
|
|
208
|
+
if using_subscription:
|
|
209
|
+
existing = body.get("system") or []
|
|
210
|
+
if isinstance(existing, list):
|
|
211
|
+
# Check if the identity prefix is already present
|
|
212
|
+
first_text = ""
|
|
213
|
+
if existing:
|
|
214
|
+
first = existing[0]
|
|
215
|
+
if isinstance(first, dict):
|
|
216
|
+
first_text = first.get("text", "")
|
|
217
|
+
if not first_text.startswith("You are Claude Code"):
|
|
218
|
+
# Prepend the Claude Code identity block
|
|
219
|
+
body["system"] = [
|
|
220
|
+
{"type": "text", "text": self._CLAUDE_CODE_IDENTITY}
|
|
221
|
+
] + existing
|
|
222
|
+
|
|
223
|
+
return body
|
|
224
|
+
|
|
225
|
+
async def _refresh_oauth_if_needed(self) -> None:
|
|
226
|
+
"""If using subscription OAuth, refresh the token if it's expired or near-expired."""
|
|
227
|
+
if not self.auth.bearer_token:
|
|
228
|
+
return
|
|
229
|
+
try:
|
|
230
|
+
from axion.runtime.claude_subscription import get_valid_subscription_token
|
|
231
|
+
new_token = await get_valid_subscription_token()
|
|
232
|
+
if new_token and new_token != self.auth.bearer_token:
|
|
233
|
+
self.auth.bearer_token = new_token
|
|
234
|
+
logger.info("Refreshed Claude subscription token")
|
|
235
|
+
except Exception as exc:
|
|
236
|
+
logger.debug("Subscription token refresh check failed: %s", exc)
|
|
237
|
+
|
|
238
|
+
async def stream_message(
|
|
239
|
+
self, request: MessageRequest
|
|
240
|
+
) -> AsyncIterator[StreamEvent]:
|
|
241
|
+
"""Send a streaming message request and yield events."""
|
|
242
|
+
request.stream = True
|
|
243
|
+
await self._refresh_oauth_if_needed()
|
|
244
|
+
client = await self._get_client()
|
|
245
|
+
headers = self._build_headers()
|
|
246
|
+
body = self._apply_prompt_caching(request.to_dict())
|
|
247
|
+
|
|
248
|
+
async with client.stream(
|
|
249
|
+
"POST",
|
|
250
|
+
"/v1/messages",
|
|
251
|
+
headers=headers,
|
|
252
|
+
json=body,
|
|
253
|
+
) as response:
|
|
254
|
+
if response.status_code != 200:
|
|
255
|
+
error_body = await response.aread()
|
|
256
|
+
raise self._build_api_error(
|
|
257
|
+
response.status_code,
|
|
258
|
+
error_body.decode("utf-8", errors="replace"),
|
|
259
|
+
response.headers.get("request-id"),
|
|
260
|
+
headers=dict(response.headers),
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
parser = SseParser()
|
|
264
|
+
async for chunk in response.aiter_bytes():
|
|
265
|
+
events = parser.push(chunk)
|
|
266
|
+
for event in events:
|
|
267
|
+
yield event
|
|
268
|
+
|
|
269
|
+
for event in parser.finish():
|
|
270
|
+
yield event
|
|
271
|
+
|
|
272
|
+
async def _send_with_retry(self, request: MessageRequest) -> MessageResponse:
|
|
273
|
+
"""Send request with exponential backoff retry."""
|
|
274
|
+
last_error: ApiError | None = None
|
|
275
|
+
|
|
276
|
+
for attempt in range(self.max_retries + 1):
|
|
277
|
+
try:
|
|
278
|
+
return await self._send_once(request)
|
|
279
|
+
except ApiError as err:
|
|
280
|
+
last_error = err
|
|
281
|
+
if not err.is_retryable() or attempt >= self.max_retries:
|
|
282
|
+
break
|
|
283
|
+
delay = self._backoff_delay(attempt)
|
|
284
|
+
logger.warning(
|
|
285
|
+
"Request failed (attempt %d/%d), retrying in %.1fs: %s",
|
|
286
|
+
attempt + 1,
|
|
287
|
+
self.max_retries + 1,
|
|
288
|
+
delay,
|
|
289
|
+
err,
|
|
290
|
+
)
|
|
291
|
+
await asyncio.sleep(delay)
|
|
292
|
+
|
|
293
|
+
if last_error is not None:
|
|
294
|
+
if self.max_retries > 0:
|
|
295
|
+
raise RetriesExhaustedError(self.max_retries + 1, last_error)
|
|
296
|
+
raise last_error
|
|
297
|
+
raise ApiError("Unknown error during request")
|
|
298
|
+
|
|
299
|
+
async def _send_once(self, request: MessageRequest) -> MessageResponse:
|
|
300
|
+
"""Send a single request without retry."""
|
|
301
|
+
await self._refresh_oauth_if_needed()
|
|
302
|
+
client = await self._get_client()
|
|
303
|
+
headers = self._build_headers()
|
|
304
|
+
body = self._apply_prompt_caching(request.to_dict())
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
response = await client.post(
|
|
308
|
+
"/v1/messages",
|
|
309
|
+
headers=headers,
|
|
310
|
+
json=body,
|
|
311
|
+
)
|
|
312
|
+
except httpx.HTTPError as exc:
|
|
313
|
+
raise HttpError(str(exc), cause=exc) from exc
|
|
314
|
+
|
|
315
|
+
request_id = response.headers.get("request-id")
|
|
316
|
+
|
|
317
|
+
if response.status_code != 200:
|
|
318
|
+
raise self._build_api_error(
|
|
319
|
+
response.status_code,
|
|
320
|
+
response.text,
|
|
321
|
+
request_id,
|
|
322
|
+
headers=dict(response.headers),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
data = response.json()
|
|
326
|
+
msg = MessageResponse.from_dict(data)
|
|
327
|
+
if request_id:
|
|
328
|
+
msg.request_id = request_id
|
|
329
|
+
return msg
|
|
330
|
+
|
|
331
|
+
def _backoff_delay(self, attempt: int) -> float:
|
|
332
|
+
"""Calculate exponential backoff delay in seconds."""
|
|
333
|
+
delay_ms = self.initial_backoff_ms * (2**attempt)
|
|
334
|
+
if delay_ms > self.max_backoff_ms:
|
|
335
|
+
delay_ms = self.max_backoff_ms
|
|
336
|
+
# Add jitter: ±25%
|
|
337
|
+
import random
|
|
338
|
+
|
|
339
|
+
jitter = random.uniform(0.75, 1.25)
|
|
340
|
+
return (delay_ms * jitter) / 1000.0
|
|
341
|
+
|
|
342
|
+
@staticmethod
|
|
343
|
+
def _build_api_error(
|
|
344
|
+
status: int,
|
|
345
|
+
body: str,
|
|
346
|
+
request_id: str | None,
|
|
347
|
+
headers: dict[str, str] | None = None,
|
|
348
|
+
) -> ApiResponseError:
|
|
349
|
+
"""Build an ApiResponseError from the response.
|
|
350
|
+
|
|
351
|
+
For 429s, parse Anthropic's rate-limit headers and append a
|
|
352
|
+
human-readable "retry at HH:MM (in N min)" suffix to the message
|
|
353
|
+
so the user knows exactly when they can try again.
|
|
354
|
+
"""
|
|
355
|
+
error_type = None
|
|
356
|
+
message = None
|
|
357
|
+
retryable = status in (429, 500, 502, 503, 529)
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
data = json.loads(body)
|
|
361
|
+
if "error" in data:
|
|
362
|
+
error_obj = data["error"]
|
|
363
|
+
error_type = error_obj.get("type")
|
|
364
|
+
message = error_obj.get("message")
|
|
365
|
+
except (json.JSONDecodeError, KeyError):
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
# Append rate-limit retry timing so the CLI can surface it
|
|
369
|
+
if status == 429 and headers:
|
|
370
|
+
retry_hint = _format_retry_hint(headers)
|
|
371
|
+
if retry_hint:
|
|
372
|
+
message = (message or "Rate limit hit") + f" — {retry_hint}"
|
|
373
|
+
|
|
374
|
+
return ApiResponseError(
|
|
375
|
+
status=status,
|
|
376
|
+
error_type=error_type,
|
|
377
|
+
message=message,
|
|
378
|
+
request_id_val=request_id,
|
|
379
|
+
body=body,
|
|
380
|
+
retryable=retryable,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
async def close(self) -> None:
|
|
384
|
+
"""Close the underlying HTTP client."""
|
|
385
|
+
if self._client is not None:
|
|
386
|
+
await self._client.aclose()
|
|
387
|
+
self._client = None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _format_retry_hint(headers: dict[str, str]) -> str | None:
|
|
391
|
+
"""Build a human-readable retry hint from Anthropic 429 response headers.
|
|
392
|
+
|
|
393
|
+
Anthropic exposes:
|
|
394
|
+
- retry-after: seconds until you can try again (RFC 7231)
|
|
395
|
+
- anthropic-ratelimit-requests-reset: RFC 3339 timestamp
|
|
396
|
+
- anthropic-ratelimit-tokens-reset: RFC 3339 timestamp
|
|
397
|
+
- anthropic-ratelimit-input-tokens-reset, ...-output-tokens-reset
|
|
398
|
+
|
|
399
|
+
Returns the latest of these as "retry at HH:MM (in N min)" or None.
|
|
400
|
+
"""
|
|
401
|
+
import time
|
|
402
|
+
from datetime import datetime
|
|
403
|
+
|
|
404
|
+
# Lower-case all header keys for safe lookup
|
|
405
|
+
h = {k.lower(): v for k, v in headers.items()}
|
|
406
|
+
|
|
407
|
+
# 1. Try the simple retry-after seconds value
|
|
408
|
+
seconds: float | None = None
|
|
409
|
+
retry_after = h.get("retry-after")
|
|
410
|
+
if retry_after:
|
|
411
|
+
try:
|
|
412
|
+
seconds = float(retry_after)
|
|
413
|
+
except ValueError:
|
|
414
|
+
pass # Could be HTTP-date format; fall through
|
|
415
|
+
|
|
416
|
+
# 2. Try the anthropic-ratelimit-*-reset timestamps (pick the FURTHEST out)
|
|
417
|
+
reset_keys = [
|
|
418
|
+
"anthropic-ratelimit-requests-reset",
|
|
419
|
+
"anthropic-ratelimit-tokens-reset",
|
|
420
|
+
"anthropic-ratelimit-input-tokens-reset",
|
|
421
|
+
"anthropic-ratelimit-output-tokens-reset",
|
|
422
|
+
]
|
|
423
|
+
now = time.time()
|
|
424
|
+
max_reset_seconds: float | None = None
|
|
425
|
+
target_reset_dt: datetime | None = None
|
|
426
|
+
for key in reset_keys:
|
|
427
|
+
ts_str = h.get(key)
|
|
428
|
+
if not ts_str:
|
|
429
|
+
continue
|
|
430
|
+
try:
|
|
431
|
+
# RFC 3339 with trailing Z → fromisoformat in 3.11+ accepts it
|
|
432
|
+
dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
|
433
|
+
wait = dt.timestamp() - now
|
|
434
|
+
if wait > 0 and (max_reset_seconds is None or wait > max_reset_seconds):
|
|
435
|
+
max_reset_seconds = wait
|
|
436
|
+
target_reset_dt = dt
|
|
437
|
+
except ValueError:
|
|
438
|
+
continue
|
|
439
|
+
|
|
440
|
+
# Prefer the explicit timestamp (more accurate) over retry-after seconds
|
|
441
|
+
if max_reset_seconds is not None and target_reset_dt is not None:
|
|
442
|
+
seconds = max_reset_seconds
|
|
443
|
+
local_dt = target_reset_dt.astimezone()
|
|
444
|
+
elif seconds is not None:
|
|
445
|
+
local_dt = datetime.fromtimestamp(now + seconds).astimezone()
|
|
446
|
+
else:
|
|
447
|
+
return None
|
|
448
|
+
|
|
449
|
+
# Format the human description
|
|
450
|
+
if seconds < 60:
|
|
451
|
+
delta = f"in {int(seconds)}s"
|
|
452
|
+
elif seconds < 3600:
|
|
453
|
+
delta = f"in {int(seconds // 60)} min"
|
|
454
|
+
else:
|
|
455
|
+
h_, rem = divmod(int(seconds), 3600)
|
|
456
|
+
m_ = rem // 60
|
|
457
|
+
delta = f"in {h_}h {m_}m" if m_ else f"in {h_}h"
|
|
458
|
+
|
|
459
|
+
clock = local_dt.strftime("%H:%M")
|
|
460
|
+
return f"retry at {clock} ({delta})"
|