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
@@ -0,0 +1,300 @@
1
+ """Claude Pro/Max subscription OAuth — bypass API billing using your subscription.
2
+
3
+ This is the same OAuth flow Claude Code uses. When authenticated via subscription,
4
+ requests are billed against your Claude Pro/Max plan instead of pay-per-token API.
5
+
6
+ Flow (paste-style, like Claude Code):
7
+ 1. Open https://claude.ai/oauth/authorize?...&redirect_uri=https://platform.claude.com/oauth/code/success?app=claude-code
8
+ 2. User logs in and authorizes
9
+ 3. claude.ai redirects to platform.claude.com/oauth/code/success which displays the code
10
+ 4. User copies the code and pastes it into the CLI
11
+ 5. We exchange the code for an access token at console.anthropic.com
12
+ 6. Use the token as `Authorization: Bearer <token>` with `anthropic-beta: oauth-2025-04-20`
13
+
14
+ Tokens are saved to ~/.axion/credentials/anthropic-oauth.json and auto-refreshed.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ import time
21
+ import urllib.parse
22
+ from dataclasses import dataclass
23
+
24
+ from axion.runtime.oauth import (
25
+ OAuthTokenSet,
26
+ PkceCodePair,
27
+ clear_oauth_credentials,
28
+ generate_pkce_pair,
29
+ generate_state,
30
+ load_oauth_credentials,
31
+ open_browser,
32
+ save_oauth_credentials,
33
+ )
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Claude Code's well-known OAuth client ID for subscription auth
38
+ CLAUDE_CODE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
39
+
40
+ # Subscription OAuth endpoints
41
+ CLAUDE_AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
42
+ CLAUDE_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
43
+
44
+ # Registered redirect URI for Claude Code's OAuth client.
45
+ # When `code=true` is set in the authorize URL, claude.ai displays the code
46
+ # on https://platform.claude.com/oauth/code/success?app=claude-code instead
47
+ # of actually redirecting — but the OAuth `redirect_uri` parameter must still
48
+ # match what's registered for this client.
49
+ CLAUDE_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
50
+
51
+ # OAuth requires this beta header on Messages API requests
52
+ SUBSCRIPTION_BETA_HEADER = "oauth-2025-04-20"
53
+
54
+ # Scopes required for subscription-based inference
55
+ SUBSCRIPTION_SCOPES = ["org:create_api_key", "user:profile", "user:inference"]
56
+
57
+ # Provider key for credential storage
58
+ SUBSCRIPTION_PROVIDER = "anthropic-oauth"
59
+
60
+
61
+ @dataclass
62
+ class SubscriptionAuthResult:
63
+ """Result of a subscription OAuth login attempt."""
64
+
65
+ success: bool
66
+ token_set: OAuthTokenSet | None = None
67
+ error: str | None = None
68
+
69
+
70
+ def build_subscription_authorize_url(pkce: PkceCodePair, state: str) -> str:
71
+ """Build the claude.ai authorize URL for subscription auth."""
72
+ params = {
73
+ "code": "true", # Tells claude.ai this is for paste-style flow
74
+ "client_id": CLAUDE_CODE_CLIENT_ID,
75
+ "response_type": "code",
76
+ "redirect_uri": CLAUDE_REDIRECT_URI,
77
+ "scope": " ".join(SUBSCRIPTION_SCOPES),
78
+ "state": state,
79
+ "code_challenge": pkce.code_challenge,
80
+ "code_challenge_method": "S256",
81
+ }
82
+ return f"{CLAUDE_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
83
+
84
+
85
+ def parse_pasted_code(pasted: str) -> tuple[str, str | None]:
86
+ """Parse the code from whatever the user pasted.
87
+
88
+ Handles all common paste formats:
89
+ - `code` — just the code
90
+ - `code#state` — Claude's success page format ("Paste this into Claude Code")
91
+ - `https://platform.claude.com/oauth/code/callback?code=ABC&state=XYZ` — full URL
92
+ - `?code=ABC&state=XYZ` — query string only
93
+ - URLs with the authorize endpoint (user pasted wrong thing) — return empty
94
+ """
95
+ pasted = pasted.strip()
96
+
97
+ if not pasted:
98
+ return "", None
99
+
100
+ # If they pasted the AUTHORIZE URL by mistake, reject it
101
+ if "/oauth/authorize" in pasted:
102
+ return "", None
103
+
104
+ # If they pasted a full URL or query string, extract code & state
105
+ if pasted.startswith(("http://", "https://", "?")):
106
+ from urllib.parse import parse_qs, urlparse
107
+
108
+ if pasted.startswith("?"):
109
+ # Just a query string
110
+ qs = pasted[1:]
111
+ else:
112
+ qs = urlparse(pasted).query
113
+
114
+ params = parse_qs(qs)
115
+ code = (params.get("code") or [""])[0].strip()
116
+ state = (params.get("state") or [None])[0]
117
+ if state:
118
+ state = state.strip() or None
119
+ return code, state
120
+
121
+ # Claude's success page format: code#state
122
+ if "#" in pasted:
123
+ code, _, state = pasted.partition("#")
124
+ return code.strip(), state.strip() or None
125
+
126
+ # Plain code
127
+ return pasted, None
128
+
129
+
130
+ async def exchange_subscription_code(
131
+ code: str,
132
+ code_verifier: str,
133
+ state: str,
134
+ ) -> OAuthTokenSet:
135
+ """Exchange an authorization code for subscription tokens."""
136
+ import httpx
137
+
138
+ payload = {
139
+ "grant_type": "authorization_code",
140
+ "code": code,
141
+ "code_verifier": code_verifier,
142
+ "client_id": CLAUDE_CODE_CLIENT_ID,
143
+ "redirect_uri": CLAUDE_REDIRECT_URI,
144
+ "state": state,
145
+ }
146
+
147
+ async with httpx.AsyncClient(timeout=30.0) as client:
148
+ response = await client.post(
149
+ CLAUDE_TOKEN_URL,
150
+ json=payload,
151
+ headers={"Content-Type": "application/json"},
152
+ )
153
+ if response.status_code != 200:
154
+ raise RuntimeError(
155
+ f"Token exchange failed ({response.status_code}): {response.text[:500]}"
156
+ )
157
+ data = response.json()
158
+
159
+ expires_in = data.get("expires_in")
160
+ expires_at = int(time.time()) + expires_in if expires_in else None
161
+
162
+ return OAuthTokenSet(
163
+ access_token=data["access_token"],
164
+ refresh_token=data.get("refresh_token"),
165
+ expires_at=expires_at,
166
+ scopes=data.get("scope", "").split() if data.get("scope") else SUBSCRIPTION_SCOPES,
167
+ )
168
+
169
+
170
+ async def refresh_subscription_token(refresh_token_str: str) -> OAuthTokenSet:
171
+ """Refresh an expired subscription access token."""
172
+ import httpx
173
+
174
+ payload = {
175
+ "grant_type": "refresh_token",
176
+ "refresh_token": refresh_token_str,
177
+ "client_id": CLAUDE_CODE_CLIENT_ID,
178
+ }
179
+
180
+ async with httpx.AsyncClient(timeout=30.0) as client:
181
+ response = await client.post(
182
+ CLAUDE_TOKEN_URL,
183
+ json=payload,
184
+ headers={"Content-Type": "application/json"},
185
+ )
186
+ if response.status_code != 200:
187
+ raise RuntimeError(
188
+ f"Token refresh failed ({response.status_code}): {response.text[:500]}"
189
+ )
190
+ data = response.json()
191
+
192
+ expires_in = data.get("expires_in")
193
+ expires_at = int(time.time()) + expires_in if expires_in else None
194
+
195
+ return OAuthTokenSet(
196
+ access_token=data["access_token"],
197
+ refresh_token=data.get("refresh_token", refresh_token_str),
198
+ expires_at=expires_at,
199
+ scopes=data.get("scope", "").split() if data.get("scope") else SUBSCRIPTION_SCOPES,
200
+ )
201
+
202
+
203
+ async def begin_subscription_login(
204
+ *, open_browser_automatically: bool = True
205
+ ) -> tuple[str, PkceCodePair, str]:
206
+ """Start the subscription login flow.
207
+
208
+ Returns (auth_url, pkce_pair, state). Caller should:
209
+ 1. Tell the user to open auth_url (we try to open the browser)
210
+ 2. After authorizing, the user will see a code on the success page
211
+ 3. Caller prompts the user to paste the code
212
+ 4. Caller calls complete_subscription_login(code, pkce, state)
213
+ """
214
+ pkce = generate_pkce_pair()
215
+ state = generate_state()
216
+ auth_url = build_subscription_authorize_url(pkce, state)
217
+
218
+ if open_browser_automatically:
219
+ open_browser(auth_url)
220
+
221
+ return auth_url, pkce, state
222
+
223
+
224
+ async def complete_subscription_login(
225
+ pasted: str,
226
+ pkce: PkceCodePair,
227
+ expected_state: str,
228
+ ) -> SubscriptionAuthResult:
229
+ """Finish subscription login using the pasted code from the success page."""
230
+ # Detect if user pasted the authorize URL by mistake
231
+ if "/oauth/authorize" in pasted:
232
+ return SubscriptionAuthResult(
233
+ success=False,
234
+ error="That's the authorize URL, not the code. After logging in, the success "
235
+ "page shows an 'Authentication Code'. Copy that and paste it here.",
236
+ )
237
+
238
+ code, pasted_state = parse_pasted_code(pasted)
239
+
240
+ if not code:
241
+ return SubscriptionAuthResult(
242
+ success=False,
243
+ error="No code found in what you pasted. Copy the value from the "
244
+ "'Authentication Code' box on the success page.",
245
+ )
246
+
247
+ # If the user pasted code#state, verify state for CSRF protection
248
+ if pasted_state and pasted_state != expected_state:
249
+ return SubscriptionAuthResult(
250
+ success=False,
251
+ error="State mismatch — the code didn't come from the login you started. "
252
+ "Try again from scratch.",
253
+ )
254
+
255
+ try:
256
+ token_set = await exchange_subscription_code(
257
+ code=code,
258
+ code_verifier=pkce.code_verifier,
259
+ state=expected_state,
260
+ )
261
+ except Exception as exc:
262
+ return SubscriptionAuthResult(
263
+ success=False,
264
+ error=f"Token exchange failed: {exc}",
265
+ )
266
+
267
+ save_oauth_credentials(SUBSCRIPTION_PROVIDER, token_set)
268
+ return SubscriptionAuthResult(success=True, token_set=token_set)
269
+
270
+
271
+ async def get_valid_subscription_token() -> str | None:
272
+ """Get a valid subscription access token, refreshing if needed.
273
+
274
+ Returns None if no subscription credentials are saved.
275
+ """
276
+ creds = load_oauth_credentials(SUBSCRIPTION_PROVIDER)
277
+ if creds is None:
278
+ return None
279
+
280
+ # If expired, refresh
281
+ if creds.is_expired() and creds.refresh_token:
282
+ try:
283
+ new_creds = await refresh_subscription_token(creds.refresh_token)
284
+ save_oauth_credentials(SUBSCRIPTION_PROVIDER, new_creds)
285
+ return new_creds.access_token
286
+ except Exception as exc:
287
+ logger.warning("Subscription token refresh failed: %s", exc)
288
+ return None
289
+
290
+ return creds.access_token
291
+
292
+
293
+ def has_subscription_credentials() -> bool:
294
+ """Check if subscription credentials are saved (without validating)."""
295
+ return load_oauth_credentials(SUBSCRIPTION_PROVIDER) is not None
296
+
297
+
298
+ def logout_subscription() -> None:
299
+ """Remove subscription credentials."""
300
+ clear_oauth_credentials(SUBSCRIPTION_PROVIDER)
@@ -0,0 +1,233 @@
1
+ """Session compaction.
2
+
3
+ Maps to: rust/crates/runtime/src/compact.rs
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from dataclasses import dataclass
10
+ from typing import TYPE_CHECKING
11
+
12
+ from axion.runtime.session import (
13
+ ConversationMessage,
14
+ MessageRole,
15
+ Session,
16
+ SessionCompaction,
17
+ TextBlock,
18
+ )
19
+
20
+ if TYPE_CHECKING:
21
+ from axion.api.client import ProviderClient
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class CompactionConfig:
28
+ """Configuration for session compaction."""
29
+
30
+ max_tokens: int = 100_000
31
+ preserve_recent_messages: int = 4
32
+ summary_max_tokens: int = 2000
33
+
34
+
35
+ @dataclass
36
+ class CompactionResult:
37
+ """Result of a compaction operation."""
38
+
39
+ removed_count: int
40
+ summary: str
41
+ estimated_tokens_before: int
42
+ estimated_tokens_after: int
43
+
44
+
45
+ def estimate_session_tokens(session: Session) -> int:
46
+ """Rough estimate of token count in a session.
47
+
48
+ Uses ~4 chars per token heuristic.
49
+ """
50
+ total_chars = 0
51
+ for msg in session.messages:
52
+ for block in msg.blocks:
53
+ if isinstance(block, TextBlock):
54
+ total_chars += len(block.text)
55
+ else:
56
+ total_chars += 100 # Rough estimate for non-text blocks
57
+ return total_chars // 4
58
+
59
+
60
+ def compact_session(
61
+ session: Session,
62
+ config: CompactionConfig | None = None,
63
+ ) -> CompactionResult | None:
64
+ """Compact a session by summarizing old messages.
65
+
66
+ Preserves the most recent messages and replaces older ones
67
+ with a summary message.
68
+ """
69
+ cfg = config or CompactionConfig()
70
+
71
+ estimated_tokens = estimate_session_tokens(session)
72
+ if estimated_tokens < cfg.max_tokens:
73
+ return None
74
+
75
+ total_messages = len(session.messages)
76
+ if total_messages <= cfg.preserve_recent_messages:
77
+ return None
78
+
79
+ # Split into old and recent
80
+ split_idx = total_messages - cfg.preserve_recent_messages
81
+ old_messages = session.messages[:split_idx]
82
+ recent_messages = session.messages[split_idx:]
83
+
84
+ # Build summary from old messages
85
+ summary_parts: list[str] = []
86
+ for msg in old_messages:
87
+ for block in msg.blocks:
88
+ if isinstance(block, TextBlock):
89
+ # Truncate long texts
90
+ text = block.text[:200] + "..." if len(block.text) > 200 else block.text
91
+ summary_parts.append(f"[{msg.role.value}] {text}")
92
+
93
+ summary = "\n".join(summary_parts)
94
+ if len(summary) > cfg.summary_max_tokens * 4:
95
+ summary = summary[: cfg.summary_max_tokens * 4] + "\n... (summary truncated)"
96
+
97
+ # Replace old messages with summary
98
+ summary_message = ConversationMessage(
99
+ role=MessageRole.USER,
100
+ blocks=[TextBlock(text=f"[Session compacted. Summary of {split_idx} earlier messages:]\n{summary}")],
101
+ )
102
+
103
+ session.messages = [summary_message] + recent_messages
104
+
105
+ # Update compaction metadata
106
+ compaction_count = (session.compaction.count + 1) if session.compaction else 1
107
+ session.compaction = SessionCompaction(
108
+ count=compaction_count,
109
+ removed_message_count=split_idx,
110
+ summary=summary[:500],
111
+ )
112
+
113
+ tokens_after = estimate_session_tokens(session)
114
+
115
+ return CompactionResult(
116
+ removed_count=split_idx,
117
+ summary=summary[:200],
118
+ estimated_tokens_before=estimated_tokens,
119
+ estimated_tokens_after=tokens_after,
120
+ )
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Model-based intelligent compaction
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ async def model_compact_session(
129
+ session: Session,
130
+ provider: "ProviderClient",
131
+ model: str,
132
+ config: CompactionConfig | None = None,
133
+ ) -> CompactionResult | None:
134
+ """Use a model to produce an intelligent summary for compaction.
135
+
136
+ 1. Builds a summary request asking the model to summarize old messages.
137
+ 2. Sends it to the API.
138
+ 3. Replaces old messages with the model-generated summary.
139
+
140
+ Falls back to the heuristic ``compact_session`` if the API call fails.
141
+ """
142
+ from axion.api.client import max_tokens_for_model, resolve_model_alias
143
+ from axion.api.types import InputMessage, MessageRequest, TextInputBlock
144
+
145
+ cfg = config or CompactionConfig()
146
+
147
+ estimated_tokens = estimate_session_tokens(session)
148
+ if estimated_tokens < cfg.max_tokens:
149
+ return None
150
+
151
+ total_messages = len(session.messages)
152
+ if total_messages <= cfg.preserve_recent_messages:
153
+ return None
154
+
155
+ split_idx = total_messages - cfg.preserve_recent_messages
156
+ old_messages = session.messages[:split_idx]
157
+ recent_messages = session.messages[split_idx:]
158
+
159
+ # Build text representation of old messages for the summariser
160
+ old_text_parts: list[str] = []
161
+ for msg in old_messages:
162
+ for block in msg.blocks:
163
+ if isinstance(block, TextBlock):
164
+ text = block.text[:500] if len(block.text) > 500 else block.text
165
+ old_text_parts.append(f"[{msg.role.value}] {text}")
166
+
167
+ old_text = "\n".join(old_text_parts)
168
+
169
+ # Build a summarisation request
170
+ summary_prompt = (
171
+ "Summarise the following conversation history concisely. "
172
+ "Preserve key decisions, tool outputs, file paths, and any context "
173
+ "the assistant will need to continue the conversation.\n\n"
174
+ f"{old_text}"
175
+ )
176
+
177
+ resolved_model = resolve_model_alias(model)
178
+ request = MessageRequest(
179
+ model=resolved_model,
180
+ max_tokens=min(cfg.summary_max_tokens, max_tokens_for_model(resolved_model)),
181
+ messages=[
182
+ InputMessage(
183
+ role="user",
184
+ content=[TextInputBlock(text=summary_prompt)],
185
+ )
186
+ ],
187
+ system="You are a conversation summariser. Produce a concise summary.",
188
+ stream=False,
189
+ )
190
+
191
+ try:
192
+ response = await provider.send_message(request)
193
+ # Extract text from the response
194
+ summary_text = ""
195
+ if response.content:
196
+ for block in response.content:
197
+ if hasattr(block, "text"):
198
+ summary_text += block.text
199
+ if not summary_text:
200
+ raise ValueError("Empty summary from model")
201
+ except Exception as exc:
202
+ logger.warning(
203
+ "Model-based compaction failed, falling back to heuristic: %s", exc
204
+ )
205
+ return compact_session(session, cfg)
206
+
207
+ # Replace old messages with model-generated summary
208
+ summary_message = ConversationMessage(
209
+ role=MessageRole.USER,
210
+ blocks=[
211
+ TextBlock(
212
+ text=f"[Session compacted by model. Summary of {split_idx} earlier messages:]\n{summary_text}"
213
+ )
214
+ ],
215
+ )
216
+
217
+ session.messages = [summary_message] + recent_messages
218
+
219
+ compaction_count = (session.compaction.count + 1) if session.compaction else 1
220
+ session.compaction = SessionCompaction(
221
+ count=compaction_count,
222
+ removed_message_count=split_idx,
223
+ summary=summary_text[:500],
224
+ )
225
+
226
+ tokens_after = estimate_session_tokens(session)
227
+
228
+ return CompactionResult(
229
+ removed_count=split_idx,
230
+ summary=summary_text[:200],
231
+ estimated_tokens_before=estimated_tokens,
232
+ estimated_tokens_after=tokens_after,
233
+ )