hubble-cli 4.1.2__tar.gz → 4.2.0__tar.gz

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 (65) hide show
  1. {hubble_cli-4.1.2/hubble_cli.egg-info → hubble_cli-4.2.0}/PKG-INFO +21 -5
  2. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/README.md +16 -3
  3. hubble_cli-4.2.0/hubble/__init__.py +3 -0
  4. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/agent.py +4 -1
  5. hubble_cli-4.2.0/hubble/anthropic_provider.py +256 -0
  6. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/banner.py +170 -18
  7. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/main.py +19 -7
  8. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/onboarding.py +17 -8
  9. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/provider.py +5 -2
  10. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/providers.py +79 -20
  11. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/repl.py +75 -23
  12. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/scanner.py +36 -1
  13. hubble_cli-4.2.0/hubble/setup_path.py +99 -0
  14. {hubble_cli-4.1.2 → hubble_cli-4.2.0/hubble_cli.egg-info}/PKG-INFO +21 -5
  15. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble_cli.egg-info/SOURCES.txt +4 -0
  16. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble_cli.egg-info/requires.txt +1 -0
  17. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/pyproject.toml +4 -1
  18. hubble_cli-4.2.0/tests/test_anthropic_provider.py +160 -0
  19. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_core.py +50 -8
  20. hubble_cli-4.2.0/tests/test_setup_path.py +80 -0
  21. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_update.py +15 -0
  22. hubble_cli-4.1.2/hubble/__init__.py +0 -3
  23. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/LICENSE +0 -0
  24. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/__main__.py +0 -0
  25. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/board.py +0 -0
  26. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/github.py +0 -0
  27. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/hooks.py +0 -0
  28. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/images.py +0 -0
  29. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/keystore.py +0 -0
  30. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/mcp.py +0 -0
  31. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/mcp_oauth.py +0 -0
  32. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/models.py +0 -0
  33. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/permissions.py +0 -0
  34. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/plugins.py +0 -0
  35. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/prompts.py +0 -0
  36. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/sandbox.py +0 -0
  37. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/session.py +0 -0
  38. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/settings.py +0 -0
  39. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/skills.py +0 -0
  40. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/spinner.py +0 -0
  41. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/subagents.py +0 -0
  42. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/tools.py +0 -0
  43. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/ui.py +0 -0
  44. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/update.py +0 -0
  45. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/web.py +0 -0
  46. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble/worktree.py +0 -0
  47. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble_cli.egg-info/dependency_links.txt +0 -0
  48. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble_cli.egg-info/entry_points.txt +0 -0
  49. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/hubble_cli.egg-info/top_level.txt +0 -0
  50. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/setup.cfg +0 -0
  51. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_agents_plugins.py +0 -0
  52. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_github.py +0 -0
  53. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_hook_events.py +0 -0
  54. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_hooks.py +0 -0
  55. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_images.py +0 -0
  56. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_keystore.py +0 -0
  57. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_mcp.py +0 -0
  58. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_mcp_remote.py +0 -0
  59. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_sandbox.py +0 -0
  60. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_scanner.py +0 -0
  61. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_skills.py +0 -0
  62. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_stream_json.py +0 -0
  63. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_task_tool.py +0 -0
  64. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_tools.py +0 -0
  65. {hubble_cli-4.1.2 → hubble_cli-4.2.0}/tests/test_worktree.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hubble-cli
3
- Version: 4.1.2
4
- Summary: Hubble: agentic coding CLI for AIHub and other OpenAI-compatible APIs
3
+ Version: 4.2.0
4
+ Summary: Agentic coding CLI for OpenAI-compatible APIs, Claude, and local LLMs
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/Hamdayrabby/hubble-cli
7
7
  Project-URL: Repository, https://github.com/Hamdayrabby/hubble-cli
@@ -15,6 +15,8 @@ Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3.10
16
16
  Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
18
20
  Classifier: Topic :: Software Development
19
21
  Classifier: Topic :: Utilities
20
22
  Requires-Python: >=3.10
@@ -24,6 +26,7 @@ Requires-Dist: httpx>=0.25.0
24
26
  Requires-Dist: rich>=13.0
25
27
  Requires-Dist: prompt_toolkit>=3.0.40
26
28
  Requires-Dist: keyring>=24
29
+ Requires-Dist: anthropic>=1.0
27
30
  Provides-Extra: dev
28
31
  Requires-Dist: pytest>=7; extra == "dev"
29
32
  Dynamic: license-file
@@ -42,6 +45,14 @@ pipx install hubble-cli
42
45
  ```
43
46
  (or `pip install --user hubble-cli`; the latest unreleased code: `pipx install git+https://github.com/Hamdayrabby/hubble-cli.git`)
44
47
 
48
+ **"hubble is not recognized" after `pip install`?** pip put the command in a folder that isn't on your
49
+ PATH (it warns: *"The script hubble.exe is installed in '...\Scripts' which is not on PATH"*). Either run
50
+ it as `python -m hubble`, or fix it once with:
51
+ ```
52
+ python -m hubble --add-to-path
53
+ ```
54
+ then open a new terminal and run `hubble`. (pipx avoids this: `pipx ensurepath` sets PATH up for you.)
55
+
45
56
  Upgrade with `pipx upgrade hubble-cli` (or `pip install -U hubble-cli`). Hubble tells you when a new
46
57
  version is out — it checks PyPI at most once a day; set `"update_check": false` to turn that off.
47
58
 
@@ -349,8 +360,13 @@ Allow rules never apply to chained commands (`&&`, `;`, `|`, redirects), so `she
349
360
 
350
361
  ## Providers (extra base URLs and API keys)
351
362
 
352
- Any OpenAI-compatible API can be added next to the built-in AIHub gateway: OpenRouter, Groq, OpenAI, Mistral,
353
- Gemini's OpenAI endpoint, a local Ollama server, and others.
363
+ Add providers next to the built-in AIHub gateway. The `/provider add` list has presets for:
364
+ - **Claude (Anthropic)** — native Messages API (not an OpenAI shim): tool use, images, thinking, prompt caching
365
+ of the system prompt, and the real context window of each model. An `ANTHROPIC_API_KEY` in your environment is
366
+ picked up automatically as a provider named `anthropic` (`ANTHROPIC_BASE_URL` too, for a proxy).
367
+ - **OpenAI-compatible:** OpenAI, OpenRouter, Gemini, Groq, Mistral, DeepSeek, xAI (Grok), Together, Fireworks,
368
+ Cerebras, NVIDIA, Moonshot (Kimi), and local Ollama / LM Studio servers.
369
+ - **Custom URL**, either OpenAI-compatible or Anthropic-compatible (a proxy or gateway speaking `/v1/messages`).
354
370
 
355
371
  1. Type `/provider add`, or open `/provider` and choose **+ Add a provider**.
356
372
  2. Pick a known provider or **Custom URL...**, then paste the API key. The key is hidden as you type.
@@ -368,7 +384,7 @@ Extra providers are stored in `~/.hubble/providers.json`. Their API keys go to y
368
384
  (Windows Credential Manager, macOS Keychain, or Secret Service on Linux); only where no credential store
369
385
  exists (headless Linux, containers) are they written into that file in plain text. `/provider secure` moves
370
386
  keys saved by older versions into the credential store.
371
- Their model lists are stored in `~/.hubble/models/<name>.json`. When a model is rate limited or down, Hubble retries that request with a fallback that the current provider actually has, in this order: the fallback you picked with `/fallback`, then `fallback_model` if the provider has it, then the provider's fastest verified model, then `fallback_model` on the built-in hubble provider. Sub-agents use the same route. `/fallback off` disables it for a provider.
387
+ Their model lists are stored in `~/.hubble/models/<name>.json`. When a model is rate limited or down, Hubble retries that request with a fallback that the current provider actually has, in this order: the fallback you picked with `/fallback`, then `fallback_model` if the provider has it, then the provider's fastest verified model, then `fallback_model` on the built-in `aihub` provider. Sub-agents use the same route. `/fallback off` disables it for a provider.
372
388
 
373
389
  ## Project memory
374
390
 
@@ -12,6 +12,14 @@ pipx install hubble-cli
12
12
  ```
13
13
  (or `pip install --user hubble-cli`; the latest unreleased code: `pipx install git+https://github.com/Hamdayrabby/hubble-cli.git`)
14
14
 
15
+ **"hubble is not recognized" after `pip install`?** pip put the command in a folder that isn't on your
16
+ PATH (it warns: *"The script hubble.exe is installed in '...\Scripts' which is not on PATH"*). Either run
17
+ it as `python -m hubble`, or fix it once with:
18
+ ```
19
+ python -m hubble --add-to-path
20
+ ```
21
+ then open a new terminal and run `hubble`. (pipx avoids this: `pipx ensurepath` sets PATH up for you.)
22
+
15
23
  Upgrade with `pipx upgrade hubble-cli` (or `pip install -U hubble-cli`). Hubble tells you when a new
16
24
  version is out — it checks PyPI at most once a day; set `"update_check": false` to turn that off.
17
25
 
@@ -319,8 +327,13 @@ Allow rules never apply to chained commands (`&&`, `;`, `|`, redirects), so `she
319
327
 
320
328
  ## Providers (extra base URLs and API keys)
321
329
 
322
- Any OpenAI-compatible API can be added next to the built-in AIHub gateway: OpenRouter, Groq, OpenAI, Mistral,
323
- Gemini's OpenAI endpoint, a local Ollama server, and others.
330
+ Add providers next to the built-in AIHub gateway. The `/provider add` list has presets for:
331
+ - **Claude (Anthropic)** — native Messages API (not an OpenAI shim): tool use, images, thinking, prompt caching
332
+ of the system prompt, and the real context window of each model. An `ANTHROPIC_API_KEY` in your environment is
333
+ picked up automatically as a provider named `anthropic` (`ANTHROPIC_BASE_URL` too, for a proxy).
334
+ - **OpenAI-compatible:** OpenAI, OpenRouter, Gemini, Groq, Mistral, DeepSeek, xAI (Grok), Together, Fireworks,
335
+ Cerebras, NVIDIA, Moonshot (Kimi), and local Ollama / LM Studio servers.
336
+ - **Custom URL**, either OpenAI-compatible or Anthropic-compatible (a proxy or gateway speaking `/v1/messages`).
324
337
 
325
338
  1. Type `/provider add`, or open `/provider` and choose **+ Add a provider**.
326
339
  2. Pick a known provider or **Custom URL...**, then paste the API key. The key is hidden as you type.
@@ -338,7 +351,7 @@ Extra providers are stored in `~/.hubble/providers.json`. Their API keys go to y
338
351
  (Windows Credential Manager, macOS Keychain, or Secret Service on Linux); only where no credential store
339
352
  exists (headless Linux, containers) are they written into that file in plain text. `/provider secure` moves
340
353
  keys saved by older versions into the credential store.
341
- Their model lists are stored in `~/.hubble/models/<name>.json`. When a model is rate limited or down, Hubble retries that request with a fallback that the current provider actually has, in this order: the fallback you picked with `/fallback`, then `fallback_model` if the provider has it, then the provider's fastest verified model, then `fallback_model` on the built-in hubble provider. Sub-agents use the same route. `/fallback off` disables it for a provider.
354
+ Their model lists are stored in `~/.hubble/models/<name>.json`. When a model is rate limited or down, Hubble retries that request with a fallback that the current provider actually has, in this order: the fallback you picked with `/fallback`, then `fallback_model` if the provider has it, then the provider's fastest verified model, then `fallback_model` on the built-in `aihub` provider. Sub-agents use the same route. `/fallback off` disables it for a provider.
342
355
 
343
356
  ## Project memory
344
357
 
@@ -0,0 +1,3 @@
1
+ """Hubble: an agentic coding CLI for OpenAI-compatible APIs, Claude, and local LLMs."""
2
+
3
+ __version__ = "4.2.0"
@@ -96,7 +96,8 @@ class Agent:
96
96
  permissions: Permissions, events: Events, session: Optional[Session] = None,
97
97
  tools: Optional[List[Tool]] = None, system_override: Optional[str] = None):
98
98
  self.provider = provider
99
- self.provider_name: str = settings.get("provider") or "hubble"
99
+ from hubble.providers import normalize_provider_name
100
+ self.provider_name: str = normalize_provider_name(settings.get("provider"))
100
101
  self.fallback_client: Optional[OpenAICompatProvider] = provider
101
102
  # (provider_name, model) -> (client, fallback_model) or None; set by the REPL/headless runner.
102
103
  self.fallback_resolver = None
@@ -294,6 +295,8 @@ class Agent:
294
295
  if not text and not result.tool_calls:
295
296
  text = "(empty response)" # some backends reject empty assistant messages
296
297
  message: Dict[str, Any] = {"role": "assistant", "content": text}
298
+ if result.raw_content:
299
+ message["_anthropic_content"] = result.raw_content
297
300
  if result.tool_calls:
298
301
  message["tool_calls"] = [{"id": c.id, "type": "function",
299
302
  "function": {"name": c.name, "arguments": c.arguments}}
@@ -0,0 +1,256 @@
1
+ """Native Claude provider (Anthropic Messages API) with the same interface as OpenAICompatProvider.
2
+
3
+ Hubble keeps its conversation history in OpenAI chat format; this module translates it to
4
+ Messages API requests (system prompt, content blocks, tool_use / tool_result, images) and the
5
+ streamed reply back into a TurnResult. The assistant's own content blocks (thinking blocks with
6
+ their signatures included) are kept on the message under `_anthropic_content` and sent back
7
+ unchanged on the next request, as the API expects when continuing a tool-use turn.
8
+
9
+ Uses the official `anthropic` SDK: it handles SSE parsing, retries (429 / 5xx / 529 overloaded)
10
+ and partial tool-input JSON.
11
+ """
12
+
13
+ import json
14
+ import re
15
+ import time
16
+ from typing import Any, Callable, Dict, List, Optional
17
+ from urllib.parse import urlparse
18
+
19
+ from hubble.provider import ProviderError, ToolCall, TurnResult, parse_arguments
20
+
21
+ DEFAULT_BASE_URL = "https://api.anthropic.com"
22
+ # Models where the API's server-side refusal fallback is recommended (and supported).
23
+ FALLBACK_MODELS = ("claude-opus-5", "claude-fable-5-1", "claude-mythos-5-1")
24
+ FALLBACK_BETA = "server-side-fallback-2026-07-01"
25
+ STOP_REASONS = {"end_turn": "stop", "stop_sequence": "stop", "tool_use": "tool_calls",
26
+ "max_tokens": "length", "refusal": "refusal", "pause_turn": "pause_turn"}
27
+ ID_RX = re.compile(r"[^a-zA-Z0-9_-]")
28
+
29
+
30
+ def anthropic_base_url(url: str) -> str:
31
+ """The SDK adds /v1/... itself, so drop a trailing /v1 that normalize_base_url may have added."""
32
+ url = (url or DEFAULT_BASE_URL).strip().rstrip("/")
33
+ return url[:-3] if url.endswith("/v1") else url
34
+
35
+
36
+ def _tool_id(raw: str) -> str:
37
+ """Tool-use ids must match ^[a-zA-Z0-9_-]+$; ids from other providers may not."""
38
+ return ID_RX.sub("_", raw or "") or "toolu_x"
39
+
40
+
41
+ def _image_block(url: str) -> Optional[Dict[str, Any]]:
42
+ m = re.match(r"^data:(image/[\w.+-]+);base64,(.+)$", url or "", re.DOTALL)
43
+ if m:
44
+ return {"type": "image", "source": {"type": "base64", "media_type": m.group(1), "data": m.group(2)}}
45
+ if url.startswith(("http://", "https://")):
46
+ return {"type": "image", "source": {"type": "url", "url": url}}
47
+ return None
48
+
49
+
50
+ def _user_blocks(content: Any) -> List[Dict[str, Any]]:
51
+ if isinstance(content, str):
52
+ return [{"type": "text", "text": content}] if content else []
53
+ blocks = []
54
+ for part in content or []:
55
+ if not isinstance(part, dict):
56
+ continue
57
+ if part.get("type") == "text" and part.get("text"):
58
+ blocks.append({"type": "text", "text": part["text"]})
59
+ elif part.get("type") == "image_url":
60
+ img = _image_block((part.get("image_url") or {}).get("url", ""))
61
+ if img:
62
+ blocks.append(img)
63
+ return blocks
64
+
65
+
66
+ def to_anthropic(messages: List[Dict[str, Any]]):
67
+ """OpenAI-format history -> (system text, Messages API messages)."""
68
+ system_parts: List[str] = []
69
+ out: List[Dict[str, Any]] = []
70
+
71
+ def add(role: str, blocks: List[Dict[str, Any]]):
72
+ if not blocks:
73
+ return
74
+ if out and out[-1]["role"] == role:
75
+ out[-1]["content"].extend(blocks) # tool results of one turn share a single user message
76
+ else:
77
+ out.append({"role": role, "content": list(blocks)})
78
+
79
+ for i, m in enumerate(messages):
80
+ role = m.get("role")
81
+ if role == "system":
82
+ if i == 0:
83
+ system_parts.append(m.get("content") or "")
84
+ else:
85
+ add("user", [{"type": "text", "text": f"<system-note>\n{m.get('content') or ''}\n</system-note>"}])
86
+ elif role == "user":
87
+ add("user", _user_blocks(m.get("content")))
88
+ elif role == "assistant":
89
+ raw = m.get("_anthropic_content")
90
+ if raw:
91
+ add("assistant", [dict(b) for b in raw]) # exact blocks from Claude, thinking included
92
+ continue
93
+ blocks: List[Dict[str, Any]] = []
94
+ text = m.get("content") or ""
95
+ if isinstance(text, str) and text.strip():
96
+ blocks.append({"type": "text", "text": text})
97
+ for tc in m.get("tool_calls") or []:
98
+ fn = tc.get("function") or {}
99
+ try:
100
+ args = parse_arguments(fn.get("arguments") or "{}")
101
+ except ValueError:
102
+ args = {}
103
+ blocks.append({"type": "tool_use", "id": _tool_id(tc.get("id", "")), "name": fn.get("name", ""),
104
+ "input": args})
105
+ add("assistant", blocks or [{"type": "text", "text": "(no content)"}])
106
+ elif role == "tool":
107
+ add("user", [{"type": "tool_result", "tool_use_id": _tool_id(m.get("tool_call_id", "")),
108
+ "content": m.get("content") or "(no output)",
109
+ **({"is_error": True} if str(m.get("content", "")).startswith(("Error:", "Permission denied"))
110
+ else {})}])
111
+ if out and out[0]["role"] != "user":
112
+ out.insert(0, {"role": "user", "content": [{"type": "text", "text": "(conversation continues)"}]})
113
+ return "\n\n".join(p for p in system_parts if p), out
114
+
115
+
116
+ def to_anthropic_tools(tools: Optional[List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
117
+ out = []
118
+ for t in tools or []:
119
+ fn = t.get("function") or t
120
+ schema = fn.get("parameters") or {"type": "object", "properties": {}}
121
+ # Stream large tool inputs (file contents) as they are generated; Hubble validates every
122
+ # parsed input against the tool's schema before running it.
123
+ out.append({"name": fn["name"], "description": fn.get("description", "")[:4096],
124
+ "input_schema": schema, "eager_input_streaming": True})
125
+ return out
126
+
127
+
128
+ class AnthropicProvider:
129
+ """Talks to Claude through the official SDK. Same methods as OpenAICompatProvider."""
130
+
131
+ kind = "anthropic"
132
+
133
+ def __init__(self, base_url: str, api_key: str, client=None, max_retries: int = 3):
134
+ import anthropic
135
+ from hubble import __version__
136
+ self._anthropic = anthropic
137
+ self.base_url = anthropic_base_url(base_url)
138
+ self.api_key = api_key
139
+ self.client = client or anthropic.Anthropic(
140
+ api_key=api_key, base_url=self.base_url, max_retries=max_retries, timeout=600.0,
141
+ default_headers={"User-Agent": f"hubble-cli/{__version__} (+https://github.com/Hamdayrabby/hubble-cli)"})
142
+ # Server-side fallbacks are a first-party API feature; proxies and gateways may reject them.
143
+ self.first_party = urlparse(self.base_url).hostname == "api.anthropic.com"
144
+
145
+ # ----- errors ---------------------------------------------------------
146
+
147
+ def _error(self, e: Exception) -> ProviderError:
148
+ a = self._anthropic
149
+ if isinstance(e, a.APIStatusError):
150
+ msg = getattr(e, "message", None) or str(e)
151
+ return ProviderError(f"HTTP {e.status_code}: {str(msg)[:200]}", e.status_code)
152
+ if isinstance(e, a.APIConnectionError):
153
+ return ProviderError(f"{type(e).__name__}: {e}") # status None -> transient
154
+ return ProviderError(f"{type(e).__name__}: {e}", 400)
155
+
156
+ # ----- requests -------------------------------------------------------
157
+
158
+ def _params(self, model: str, messages, tools, max_tokens: int) -> Dict[str, Any]:
159
+ system, msgs = to_anthropic(messages)
160
+ params: Dict[str, Any] = {"model": model, "max_tokens": max_tokens, "messages": msgs}
161
+ if system:
162
+ # Cache the stable prefix (tools + system prompt) across the turns of a session.
163
+ params["system"] = [{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}]
164
+ if tools:
165
+ params["tools"] = to_anthropic_tools(tools)
166
+ # No temperature and no thinking config: current Claude models reject sampling
167
+ # parameters, and pick their own (adaptive) thinking by default.
168
+ return params
169
+
170
+ def _use_fallbacks(self, model: str) -> bool:
171
+ return self.first_party and any(model == m or model.startswith(m + "-") for m in FALLBACK_MODELS)
172
+
173
+ def stream(self, model: str, messages: List[Dict[str, Any]], tools: Optional[List[Dict]] = None,
174
+ temperature: float = 0.3, max_tokens: int = 8192,
175
+ on_text: Optional[Callable[[str], None]] = None,
176
+ on_reasoning: Optional[Callable[[str], None]] = None) -> TurnResult:
177
+ # Thinking counts against max_tokens and the reply is streamed (no timeout risk), so give
178
+ # the model room: Hubble's generic 8192 default would cut deep answers short.
179
+ params = self._params(model, messages, tools, max(max_tokens, 32000))
180
+ for attempt in range(3):
181
+ try:
182
+ return self._stream_once(model, params, on_text, on_reasoning)
183
+ except ValueError as e:
184
+ # Tool-input JSON the SDK could not parse at all (possible with eager input
185
+ # streaming). No tool_use id exists to answer, so re-issue the turn.
186
+ if attempt == 2:
187
+ raise ProviderError(f"Claude sent unparseable tool input: {e}", 400) from None
188
+ except self._anthropic.APIError as e:
189
+ raise self._error(e) from None
190
+ raise ProviderError("unreachable")
191
+
192
+ def _stream_once(self, model, params, on_text, on_reasoning) -> TurnResult:
193
+ start = time.time()
194
+ result = TurnResult()
195
+ if self._use_fallbacks(model):
196
+ ctx = self.client.beta.messages.stream(**params, betas=[FALLBACK_BETA], fallbacks="default")
197
+ else:
198
+ ctx = self.client.messages.stream(**params)
199
+ with ctx as stream:
200
+ for event in stream:
201
+ if event.type == "text" and event.text:
202
+ if not result.ttft_ms:
203
+ result.ttft_ms = round((time.time() - start) * 1000)
204
+ if on_text:
205
+ on_text(event.text)
206
+ elif event.type == "thinking" and getattr(event, "thinking", ""):
207
+ if on_reasoning:
208
+ on_reasoning(event.thinking)
209
+ final = stream.get_final_message()
210
+ data = final.to_dict()
211
+ blocks = data.get("content") or []
212
+ result.text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
213
+ result.reasoning = "".join(b.get("thinking", "") for b in blocks if b.get("type") == "thinking")
214
+ stop = data.get("stop_reason")
215
+ result.finish_reason = STOP_REASONS.get(stop, stop)
216
+ if stop != "max_tokens":
217
+ # On max_tokens a tool input may be cut off mid-JSON: never run it.
218
+ result.tool_calls = [ToolCall(b["id"], b["name"], json.dumps(b.get("input") or {}))
219
+ for b in blocks if b.get("type") == "tool_use"]
220
+ u = data.get("usage") or {}
221
+ prompt = sum(int(u.get(k) or 0) for k in ("input_tokens", "cache_read_input_tokens",
222
+ "cache_creation_input_tokens"))
223
+ result.usage = {"prompt_tokens": prompt, "completion_tokens": int(u.get("output_tokens") or 0),
224
+ "total_tokens": prompt + int(u.get("output_tokens") or 0)}
225
+ if stop != "max_tokens":
226
+ # Echo Claude's own blocks back next turn (keeps thinking signatures intact).
227
+ result.raw_content = blocks
228
+ result.duration = time.time() - start
229
+ return result
230
+
231
+ def complete(self, model: str, messages: List[Dict[str, Any]], max_tokens: int = 2048,
232
+ temperature: float = 0.2) -> str:
233
+ params = self._params(model, messages, None, max_tokens)
234
+ try:
235
+ with self.client.messages.stream(**params) as stream:
236
+ final = stream.get_final_message()
237
+ except self._anthropic.APIError as e:
238
+ raise self._error(e) from None
239
+ return "".join(b.text for b in final.content if b.type == "text")
240
+
241
+ def ping(self, model: str) -> Dict[str, Any]:
242
+ start = time.time()
243
+ try:
244
+ text = self.complete(model, [{"role": "user", "content": "Reply OK"}], max_tokens=64)
245
+ return {"ok": True, "latency_ms": round((time.time() - start) * 1000), "msg": text.strip()[:60]}
246
+ except ProviderError as e:
247
+ return {"ok": False, "latency_ms": round((time.time() - start) * 1000), "msg": str(e)[:120]}
248
+
249
+ def list_models(self) -> List[Dict[str, Any]]:
250
+ """[{id, context_length, max_output}] from GET /v1/models."""
251
+ try:
252
+ return [{"id": m.id, "context_length": getattr(m, "max_input_tokens", None),
253
+ "max_output": getattr(m, "max_tokens", None)} for m in self.client.models.list()]
254
+ except self._anthropic.APIError as e:
255
+ raise self._error(e) from None
256
+
@@ -299,6 +299,157 @@ def space_scene(width: int, t: float = 2.4, art: _Art = FULL) -> Text:
299
299
  return _grid_text(grid).append("\n")
300
300
 
301
301
 
302
+ # ----- retro arcade scene (Space Invaders style) ----------------------------
303
+ #
304
+ # Also a pure function of t: invaders march in discrete steps like the 1978 cabinet, the ship
305
+ # glides and fires on a fixed rhythm, and which invaders are gone is replayed from the shots
306
+ # fired so far in the current wave, so any frame can be drawn on its own.
307
+
308
+ INVADERS = [ # (frame A, frame B, color); one row each, top to bottom
309
+ ("▞█▚", "▚█▞", "#ff5fd7"),
310
+ ("▛▀▜", "▙▄▟", "#00d7ff"),
311
+ ]
312
+ _SHIP_ROWS = [
313
+ "▲",
314
+ "▟█▙",
315
+ "▗▄▄▄▟█████▙▄▄▄▖",
316
+ "◢████ H U B B L E ████◣",
317
+ "▀▀▀▜█████████▛▀▀▀",
318
+ "▀▼▀ ▀▼▀",
319
+ ]
320
+ SHIP_W = max(len(r) for r in _SHIP_ROWS)
321
+ SHIP = [r.center(SHIP_W) for r in _SHIP_ROWS] # the HUBBLE starship, nose on top
322
+ UFO = "<=●=>"
323
+
324
+
325
+ class _Arcade:
326
+ height = 5 + len(SHIP) # score/UFO row, 2 invader rows, 2 rows of open space, the ship
327
+ width = 44 # smallest field that still looks like a formation
328
+ COLS = 8 # invaders per row
329
+ GAP = 5 # columns from one invader to the next
330
+ STEP = 0.45 # seconds per march step
331
+ SHOT_EVERY = 0.7
332
+ SHOT_SPEED = 14.0 # rows per second
333
+ WAVE = 14.0 # seconds before a fresh wave marches in
334
+
335
+ def field(self, width: int, left: Optional[int] = None) -> Tuple[int, int]:
336
+ """(left, field width) of the play area inside a grid `width` wide; centered by default."""
337
+ fw = max(self.width, min(width - 4, 76))
338
+ if left is None:
339
+ left = max(2, (width - fw) // 2)
340
+ return left, fw
341
+
342
+ def _march(self, t: float, span: int) -> int:
343
+ """Formation x offset: steps right, then left, one column per STEP (triangle wave)."""
344
+ steps = int(t / self.STEP)
345
+ period = 2 * span
346
+ k = steps % period if period else 0
347
+ return k if k <= span else period - k
348
+
349
+ def _ship_x(self, t: float, fw: int) -> float:
350
+ """Left column of the ship: cruises around the middle of the field."""
351
+ room = max(0, fw - SHIP_W)
352
+ return room / 2 + room / 2 * 0.85 * math.sin(t * 0.6)
353
+
354
+ def state(self, t: float, fw: int):
355
+ """(offset, dead {(row, i)}, explosions [(row, col, age)], bullets [(y, x)], score)."""
356
+ wave_t = t % self.WAVE
357
+ wave_start = t - wave_t
358
+ formation = (self.COLS - 1) * self.GAP + 3
359
+ span = max(0, fw - formation)
360
+ dead, booms, bullets, score = set(), [], [], 0
361
+ n = int(wave_t / self.SHOT_EVERY)
362
+ for k in range(n + 1):
363
+ fired = wave_start + k * self.SHOT_EVERY
364
+ if fired > t:
365
+ break
366
+ x = int(round(self._ship_x(fired, fw))) + SHIP_W // 2 # the nose cannon
367
+ # Bullet leaves row 5 (ship tip) and climbs; invader rows are 1 and 2.
368
+ hit = None
369
+ for row in (1, 0): # lowest invader row first, the one a bullet meets first
370
+ travel = (5 - (row + 1)) / self.SHOT_SPEED
371
+ at = fired + travel
372
+ off = self._march(at, span)
373
+ i, rem = divmod(x - off, self.GAP)
374
+ if 0 <= i < self.COLS and rem < 3 and (row, i) not in dead:
375
+ hit = (row, i, at)
376
+ break
377
+ if hit and hit[2] <= t:
378
+ row, i, at = hit
379
+ dead.add((row, i))
380
+ score += 30 if row == 0 else 20
381
+ if t - at < 0.35:
382
+ booms.append((row + 1, self._march(at, span) + i * self.GAP + 1, t - at))
383
+ else:
384
+ y = 5 - (t - fired) * self.SHOT_SPEED
385
+ end = (hit[0] + 1) if hit else -1
386
+ if y > end:
387
+ bullets.append((int(y), x))
388
+ total_score = int(t / self.WAVE) * 400 + score
389
+ return self._march(t, span), dead, booms, bullets, total_score
390
+
391
+ def draw(self, grid, top: int, t: float, left: Optional[int] = None):
392
+ width = len(grid[0])
393
+ left, fw = self.field(width, left)
394
+ fw = min(fw, width - left)
395
+
396
+ def put(r, c, ch, style):
397
+ if 0 <= top + r < len(grid) and 0 <= left + c < min(width, left + fw):
398
+ grid[top + r][left + c] = (ch, style)
399
+
400
+ off, dead, booms, bullets, score = self.state(t, fw)
401
+ frame = int(t / self.STEP) % 2
402
+ for j, ch in enumerate(f"SCORE {score:05d}"):
403
+ put(0, j, ch, "#5fd787")
404
+ hi = "HI 09990"
405
+ for j, ch in enumerate(hi):
406
+ put(0, fw - len(hi) + j, ch, "#878787")
407
+ # Mothership crosses the top every so often (over the score, like the real thing).
408
+ ut = t % 9.0
409
+ if ut < 3.0:
410
+ ux = int((fw + len(UFO)) * ut / 3.0) - len(UFO)
411
+ for j, ch in enumerate(UFO):
412
+ put(0, ux + j, ch, "bold #ff5f5f" if ch == "●" and int(t * 8) % 2 else "#ff8787")
413
+ for row, (a, b, color) in enumerate(INVADERS):
414
+ sprite = a if frame == 0 else b
415
+ for i in range(self.COLS):
416
+ if (row, i) in dead:
417
+ continue
418
+ for j, ch in enumerate(sprite):
419
+ put(row + 1, off + i * self.GAP + j, ch, color)
420
+ for r, c, age in booms:
421
+ ch = "✶" if age < 0.12 else "✷" if age < 0.24 else "·"
422
+ for dc, g in ((-1, "╲"), (0, ch), (1, "╱")):
423
+ put(r, c + dc, g if age < 0.24 else " " if dc else ch, "bold #ffd75f")
424
+ for y, x in bullets:
425
+ if 0 <= y <= 5:
426
+ put(y, x, "│", "bold #ffffaf")
427
+ sx = int(round(self._ship_x(t, fw)))
428
+ flame = int(t * 10) % 3
429
+ name_row = next(i for i, r in enumerate(SHIP) if "H U B B L E" in r)
430
+ for r, line in enumerate(SHIP):
431
+ for j, ch in enumerate(line):
432
+ if ch == " " and r != name_row:
433
+ continue
434
+ if ch == "▲":
435
+ style = "bold #ffffff" if int(t * 6) % 2 else "bold #d7e7ff"
436
+ elif ch == "▼":
437
+ ch = ("▼", "▽", "▾")[(flame + j) % 3]
438
+ style = ("bold #ffd75f", "#ff8700", "#ff5f00")[(flame + j) % 3]
439
+ elif ch.isalpha():
440
+ style = f"bold {_blend(GRADIENT, ((j / SHIP_W) + t * 0.15) % 1.0)}" # glowing name
441
+ elif r == name_row and ch == " ":
442
+ style = "" # opaque inside the hull, so stars pass behind the ship
443
+ elif ch in "◢◣":
444
+ style = "#ff5f87" # wing-tip lights
445
+ else:
446
+ style = "#afc7e7" if r < name_row else "#5f87af"
447
+ put(5 + r, sx + j, ch, style)
448
+
449
+
450
+ ARCADE = _Arcade()
451
+
452
+
302
453
  # ----- composed home screen -------------------------------------------------
303
454
 
304
455
  _MEASURE = Console(width=400, color_system="truecolor", force_terminal=True, legacy_windows=False,
@@ -440,27 +591,25 @@ def compose_grid(width: int, height: int, t: float, status: List[Text], tips: Li
440
591
  """
441
592
  width = max(40, min(width - 1, 110))
442
593
  height = max(height, 1)
443
- # Logo sizes: 2 = large pixel letters, 1 = block letters, 0 = one-line wordmark.
594
+ # Logo sizes: 1 = block letters, 0 = one-line wordmark. (2, the large pixel letters, is kept
595
+ # for anyone who wants it back, but the HUBBLE starship is the star of the screen now.)
444
596
  logo_h = {2: 9, 1: 7, 0: 1} # letter rows + shadow row + tagline
445
- top = 2 if width >= BIG_LOGO_WIDTH + 4 else (1 if width >= 58 else 0)
446
- mid = min(top, 1)
447
- # Richest to poorest. The name is the last thing to shrink: first drop the scene, then the
448
- # tips, then status lines, and only then fall back to a smaller logo.
597
+ top = 1 if width >= 58 else 0
598
+ # Richest to poorest: the starship scene comes first, then the bigger logo, then tips,
599
+ # then status lines.
449
600
  n_status = len(status)
450
- layouts = []
451
- for cand_size in dict.fromkeys([top, mid, 0]):
452
- for cand_art in (FULL, COMPACT, None):
453
- layouts.append((cand_size, cand_art, True, n_status))
454
- for cand_art in (COMPACT, None):
455
- layouts.append((cand_size, cand_art, False, n_status))
456
- for n in (min(2, n_status), min(1, n_status), 0):
457
- layouts.append((cand_size, None, False, n))
601
+ shrink = [(True, n_status), (False, n_status), (False, min(2, n_status)), (False, min(1, n_status)),
602
+ (False, 0)]
603
+ layouts = [(s, a, tp, n) for a in (ARCADE, None) for s in dict.fromkeys([top, 0]) for tp, n in shrink]
458
604
  size, art, with_tips, n_status = layouts[-1]
605
+
606
+ def art_rows(a):
607
+ return a.height + 1 if a is not None else 0
608
+
459
609
  for cand_size, cand_art, cand_tips, cand_n in layouts:
460
- if cand_art is not None and width < cand_art.width + 24:
610
+ if cand_art is ARCADE and width < ARCADE.width + 4:
461
611
  continue
462
- need = logo_h[cand_size] + 1 + (cand_art.height + 1 if cand_art else 0) + cand_n + \
463
- (1 + len(tips) if cand_tips else 0) + 1
612
+ need = logo_h[cand_size] + 1 + art_rows(cand_art) + cand_n + (1 + len(tips) if cand_tips else 0) + 1
464
613
  if need <= height:
465
614
  size, art, with_tips, n_status = cand_size, cand_art, cand_tips, cand_n
466
615
  break
@@ -468,7 +617,7 @@ def compose_grid(width: int, height: int, t: float, status: List[Text], tips: Li
468
617
  use_big = size > 0
469
618
  logo_h = logo_h[size]
470
619
 
471
- rows_total = logo_h + 1 + (art.height + 1 if art else 0) + len(status) + (1 + len(tips) if with_tips else 0) + 1
620
+ rows_total = logo_h + 1 + art_rows(art) + len(status) + (1 + len(tips) if with_tips else 0) + 1
472
621
  rows_total = min(rows_total, height) if rows_total > height else rows_total # never exceed the budget
473
622
  grid = _new_grid(width, max(rows_total, 1))
474
623
  _draw_stars(grid, t)
@@ -490,7 +639,10 @@ def compose_grid(width: int, height: int, t: float, status: List[Text], tips: Li
490
639
  _stamp_text(grid, row, 2, Text.assemble(small_logo(), (" v" + version, "dim")))
491
640
  row += logo_h + 1
492
641
 
493
- if art is not None:
642
+ if art is ARCADE:
643
+ ARCADE.draw(grid, row, t)
644
+ row += ARCADE.height + 1
645
+ elif isinstance(art, _Art):
494
646
  _draw_comet(grid, row, t)
495
647
  _draw_scene(grid, row, t, art)
496
648
  row += art.height + 1