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.
Files changed (82) hide show
  1. axion/__init__.py +3 -0
  2. axion/api/__init__.py +0 -0
  3. axion/api/anthropic.py +460 -0
  4. axion/api/client.py +259 -0
  5. axion/api/error.py +161 -0
  6. axion/api/ollama.py +597 -0
  7. axion/api/openai_compat.py +805 -0
  8. axion/api/openai_responses.py +627 -0
  9. axion/api/prompt_cache.py +31 -0
  10. axion/api/sse.py +98 -0
  11. axion/api/types.py +451 -0
  12. axion/cli/__init__.py +0 -0
  13. axion/cli/init_cmd.py +50 -0
  14. axion/cli/input.py +290 -0
  15. axion/cli/main.py +2953 -0
  16. axion/cli/render.py +489 -0
  17. axion/cli/tui.py +766 -0
  18. axion/commands/__init__.py +0 -0
  19. axion/commands/handlers/__init__.py +0 -0
  20. axion/commands/handlers/agents.py +51 -0
  21. axion/commands/handlers/builtin_commands.py +367 -0
  22. axion/commands/handlers/mcp.py +59 -0
  23. axion/commands/handlers/models.py +75 -0
  24. axion/commands/handlers/plugins.py +55 -0
  25. axion/commands/handlers/skills.py +61 -0
  26. axion/commands/parsing.py +317 -0
  27. axion/commands/registry.py +166 -0
  28. axion/compat_harness/__init__.py +0 -0
  29. axion/compat_harness/extractor.py +145 -0
  30. axion/plugins/__init__.py +0 -0
  31. axion/plugins/hooks.py +22 -0
  32. axion/plugins/manager.py +391 -0
  33. axion/plugins/manifest.py +270 -0
  34. axion/runtime/__init__.py +0 -0
  35. axion/runtime/bash.py +388 -0
  36. axion/runtime/bootstrap.py +39 -0
  37. axion/runtime/claude_subscription.py +300 -0
  38. axion/runtime/compact.py +233 -0
  39. axion/runtime/config.py +397 -0
  40. axion/runtime/conversation.py +1073 -0
  41. axion/runtime/file_ops.py +613 -0
  42. axion/runtime/git.py +213 -0
  43. axion/runtime/hooks.py +235 -0
  44. axion/runtime/image.py +212 -0
  45. axion/runtime/lanes.py +282 -0
  46. axion/runtime/lsp.py +425 -0
  47. axion/runtime/mcp/__init__.py +0 -0
  48. axion/runtime/mcp/client.py +76 -0
  49. axion/runtime/mcp/lifecycle.py +96 -0
  50. axion/runtime/mcp/stdio.py +318 -0
  51. axion/runtime/mcp/tool_bridge.py +79 -0
  52. axion/runtime/memory.py +196 -0
  53. axion/runtime/oauth.py +329 -0
  54. axion/runtime/openai_subscription.py +346 -0
  55. axion/runtime/permissions.py +247 -0
  56. axion/runtime/plan_mode.py +96 -0
  57. axion/runtime/policy_engine.py +259 -0
  58. axion/runtime/prompt.py +586 -0
  59. axion/runtime/recovery.py +261 -0
  60. axion/runtime/remote.py +28 -0
  61. axion/runtime/sandbox.py +68 -0
  62. axion/runtime/scheduler.py +231 -0
  63. axion/runtime/session.py +365 -0
  64. axion/runtime/sharing.py +159 -0
  65. axion/runtime/skills.py +124 -0
  66. axion/runtime/tasks.py +258 -0
  67. axion/runtime/usage.py +241 -0
  68. axion/runtime/workers.py +186 -0
  69. axion/telemetry/__init__.py +0 -0
  70. axion/telemetry/events.py +67 -0
  71. axion/telemetry/profile.py +49 -0
  72. axion/telemetry/sink.py +60 -0
  73. axion/telemetry/tracer.py +95 -0
  74. axion/tools/__init__.py +0 -0
  75. axion/tools/lane_completion.py +33 -0
  76. axion/tools/registry.py +853 -0
  77. axion/tools/tool_search.py +226 -0
  78. axion_code-1.0.0.dist-info/METADATA +709 -0
  79. axion_code-1.0.0.dist-info/RECORD +82 -0
  80. axion_code-1.0.0.dist-info/WHEEL +4 -0
  81. axion_code-1.0.0.dist-info/entry_points.txt +2 -0
  82. axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
axion/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Axion Code - Python CLI agent harness."""
2
+
3
+ __version__ = "1.0.0"
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})"