hubble-cli 4.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.
- hubble/__init__.py +3 -0
- hubble/__main__.py +4 -0
- hubble/agent.py +546 -0
- hubble/banner.py +484 -0
- hubble/board.py +118 -0
- hubble/main.py +299 -0
- hubble/models.py +92 -0
- hubble/onboarding.py +94 -0
- hubble/permissions.py +139 -0
- hubble/prompts.py +157 -0
- hubble/provider.py +283 -0
- hubble/providers.py +186 -0
- hubble/repl.py +976 -0
- hubble/scanner.py +134 -0
- hubble/session.py +128 -0
- hubble/settings.py +184 -0
- hubble/skills.py +173 -0
- hubble/spinner.py +77 -0
- hubble/tools.py +763 -0
- hubble/ui.py +469 -0
- hubble/web.py +279 -0
- hubble_cli-4.0.0.dist-info/METADATA +275 -0
- hubble_cli-4.0.0.dist-info/RECORD +27 -0
- hubble_cli-4.0.0.dist-info/WHEEL +5 -0
- hubble_cli-4.0.0.dist-info/entry_points.txt +3 -0
- hubble_cli-4.0.0.dist-info/licenses/LICENSE +21 -0
- hubble_cli-4.0.0.dist-info/top_level.txt +1 -0
hubble/prompts.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""System prompt: persona, tool guidance, environment and project memory files."""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import platform
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Tuple
|
|
9
|
+
|
|
10
|
+
from hubble.settings import HOME_DIR
|
|
11
|
+
|
|
12
|
+
MEMORY_FILENAMES = ("HUBBLE.md", "AGENTS.md", "CLAUDE.md", "GEMINI.md")
|
|
13
|
+
MAX_MEMORY_CHARS = 20000
|
|
14
|
+
|
|
15
|
+
PERSONAS = {
|
|
16
|
+
"code": "You are Hubble, an expert software engineer working directly in the user's repository.",
|
|
17
|
+
"debug": ("You are Hubble in debug mode. Reproduce the problem, find the root cause before "
|
|
18
|
+
"changing code, explain it in one or two sentences, apply the minimal fix and verify it."),
|
|
19
|
+
"review": ("You are Hubble in review mode, a strict senior reviewer. Read the relevant code and "
|
|
20
|
+
"report findings grouped as Bug, Security, Performance or Style, each with file:line and a "
|
|
21
|
+
"concrete fix. Do not edit files unless asked."),
|
|
22
|
+
"architect": ("You are Hubble in architect mode. Explore the codebase structure and produce designs, "
|
|
23
|
+
"trade-offs and phased implementation plans. Do not edit files unless asked."),
|
|
24
|
+
"chat": "You are Hubble, a helpful technical assistant. Use tools only when they help answer.",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
GUIDELINES = """\
|
|
28
|
+
# How to work
|
|
29
|
+
- Use the tools to inspect the workspace instead of guessing. Search with grep/glob, then read_file the relevant parts.
|
|
30
|
+
- Always read_file a file before editing it. Prefer edit_file for targeted changes; use write_file for new files.
|
|
31
|
+
- In edit_file, old_string must match the file exactly, without the "N | " line-number prefix from read_file.
|
|
32
|
+
- Keep changes minimal and consistent with the surrounding code style. Do not add unrelated refactors.
|
|
33
|
+
- After changing code, verify it: run the tests, a build, or at least a syntax check with the shell tool.
|
|
34
|
+
- For tasks with three or more steps, track progress with todo_write.
|
|
35
|
+
- If you notice yourself repeating a multi-step procedure this user is likely to need again, offer to
|
|
36
|
+
save it with write_skill so it is available as a skill next time (ask first; do not save one-off tasks).
|
|
37
|
+
- Use web_search for anything that may have changed since your training (library versions, APIs, error
|
|
38
|
+
messages), then web_fetch the best result. Cite the URLs you used.
|
|
39
|
+
- If a tool call is denied, do not retry the same call; adjust your approach or ask the user.
|
|
40
|
+
- Never print or exfiltrate secrets (.env files, keys, tokens).
|
|
41
|
+
- Be concise. Lead with the answer or result. Reference code as path:line.
|
|
42
|
+
- When the task is done, reply with a short summary of what changed and how it was verified."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@functools.lru_cache(maxsize=8)
|
|
46
|
+
def _git_summary(root: Path) -> str:
|
|
47
|
+
try:
|
|
48
|
+
branch = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=root, capture_output=True,
|
|
49
|
+
text=True, timeout=5, stdin=subprocess.DEVNULL)
|
|
50
|
+
if branch.returncode != 0:
|
|
51
|
+
return "not a git repository"
|
|
52
|
+
status = subprocess.run(["git", "status", "--short"], cwd=root, capture_output=True, text=True,
|
|
53
|
+
timeout=5, stdin=subprocess.DEVNULL)
|
|
54
|
+
changed = [l for l in status.stdout.splitlines() if l.strip()]
|
|
55
|
+
summary = f"branch {branch.stdout.strip()}, {len(changed)} changed file(s)"
|
|
56
|
+
if changed:
|
|
57
|
+
summary += "\n" + "\n".join(f" {l}" for l in changed[:15])
|
|
58
|
+
return summary
|
|
59
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
60
|
+
return "git unavailable"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@functools.lru_cache(maxsize=8)
|
|
64
|
+
def workspace_overview(root: Path, limit: int = 80) -> str:
|
|
65
|
+
"""Shallow file tree (depth 2) so the model knows what exists before searching."""
|
|
66
|
+
from hubble.tools import IGNORED_DIRS
|
|
67
|
+
lines: List[str] = []
|
|
68
|
+
|
|
69
|
+
def entries(d: Path):
|
|
70
|
+
try:
|
|
71
|
+
return sorted((e for e in d.iterdir() if e.name not in IGNORED_DIRS and not e.name.startswith(".")),
|
|
72
|
+
key=lambda e: (not e.is_dir(), e.name.lower()))
|
|
73
|
+
except OSError:
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
for e in entries(root):
|
|
77
|
+
if len(lines) >= limit:
|
|
78
|
+
lines.append(" ...")
|
|
79
|
+
break
|
|
80
|
+
lines.append(f" {e.name}/" if e.is_dir() else f" {e.name}")
|
|
81
|
+
if e.is_dir():
|
|
82
|
+
children = entries(e)
|
|
83
|
+
for c in children[:12]:
|
|
84
|
+
lines.append(f" {c.name}/" if c.is_dir() else f" {c.name}")
|
|
85
|
+
if len(children) > 12:
|
|
86
|
+
lines.append(f" ... {len(children) - 12} more")
|
|
87
|
+
return "\n".join(lines[: limit + 1]) or " (empty)"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def find_memory_files(root: Path) -> List[Path]:
|
|
91
|
+
"""User-level memory, then files from the filesystem root down to the workspace."""
|
|
92
|
+
found = [p for p in (HOME_DIR / "HUBBLE.md",) if p.is_file()]
|
|
93
|
+
chain = [root, *root.parents]
|
|
94
|
+
stop = next((i for i, d in enumerate(chain) if (d / ".git").exists()), None)
|
|
95
|
+
dirs = chain[: stop + 1] if stop is not None else [root]
|
|
96
|
+
for d in reversed(dirs):
|
|
97
|
+
for name in MEMORY_FILENAMES:
|
|
98
|
+
p = d / name
|
|
99
|
+
if p.is_file():
|
|
100
|
+
found.append(p)
|
|
101
|
+
break # one memory file per directory; HUBBLE.md takes priority
|
|
102
|
+
return found
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_memory(root: Path) -> List[Tuple[Path, str]]:
|
|
106
|
+
out, budget = [], MAX_MEMORY_CHARS
|
|
107
|
+
for p in find_memory_files(root):
|
|
108
|
+
try:
|
|
109
|
+
text = p.read_text(encoding="utf-8", errors="replace").strip()
|
|
110
|
+
except OSError:
|
|
111
|
+
continue
|
|
112
|
+
if text and budget > 0:
|
|
113
|
+
out.append((p, text[:budget]))
|
|
114
|
+
budget -= len(text)
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_system_prompt(root: Path, persona: str, shell: str, model: str,
|
|
119
|
+
pinned: dict, memory: List[Tuple[Path, str]], mode: str,
|
|
120
|
+
skills_block: str = "") -> str:
|
|
121
|
+
parts = [PERSONAS.get(persona, PERSONAS["code"]), GUIDELINES]
|
|
122
|
+
parts.append(
|
|
123
|
+
"# Environment\n"
|
|
124
|
+
f"- Workspace root: {root}\n"
|
|
125
|
+
f"- OS: {platform.system()} {platform.release()}\n"
|
|
126
|
+
f"- Shell used by the shell tool: {shell}"
|
|
127
|
+
+ (" (use PowerShell syntax; `&&` is not supported in 5.1, use `;`)" if "5.1" in shell else "")
|
|
128
|
+
+ f"\n- Date: {time.strftime('%Y-%m-%d')}\n"
|
|
129
|
+
f"- Model: {model}\n"
|
|
130
|
+
f"- Git (at session start): {_git_summary(root)}\n"
|
|
131
|
+
f"- Workspace files (snapshot at session start, depth 2):\n{workspace_overview(root)}"
|
|
132
|
+
)
|
|
133
|
+
if mode == "plan":
|
|
134
|
+
parts.append("# Plan mode\nYou are in read-only plan mode. Explore with read-only tools, then present "
|
|
135
|
+
"a concise step-by-step implementation plan. Do not try to edit files or run commands.")
|
|
136
|
+
for path, text in memory:
|
|
137
|
+
parts.append(f"# Project instructions from {path}\n{text}")
|
|
138
|
+
if skills_block:
|
|
139
|
+
parts.append(skills_block)
|
|
140
|
+
if pinned:
|
|
141
|
+
files = "\n\n".join(f"--- {name} ---\n{text}" for name, text in pinned.items())
|
|
142
|
+
parts.append(f"# Files pinned by the user\n{files}")
|
|
143
|
+
return "\n\n".join(parts)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
INIT_PROMPT = """Analyze this codebase and create an HUBBLE.md file in the workspace root with instructions for future \
|
|
147
|
+
coding sessions. Include: a one-paragraph overview, how to install, build, run and test (exact commands), the \
|
|
148
|
+
high-level architecture and the role of the key files, and code style or conventions you observe. Keep it under \
|
|
149
|
+
80 lines, factual, and skip generic advice. If HUBBLE.md already exists, read it and improve it instead."""
|
|
150
|
+
|
|
151
|
+
COMPACT_PROMPT = """Summarize the conversation so far so the work can continue in a fresh context. Include:
|
|
152
|
+
1. The user's goals and explicit requests, in their words where it matters.
|
|
153
|
+
2. Key decisions and findings.
|
|
154
|
+
3. Files read or modified, with the important details of each change.
|
|
155
|
+
4. Errors hit and how they were fixed.
|
|
156
|
+
5. Pending tasks and the exact next step.
|
|
157
|
+
Be dense and specific; omit pleasantries."""
|
hubble/provider.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""OpenAI-compatible streaming client with native tool calling."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
RETRY_STATUS = {408, 429, 500, 502, 503, 504}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProviderError(Exception):
|
|
17
|
+
def __init__(self, message: str, status: Optional[int] = None):
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status = status
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def transient(self) -> bool:
|
|
23
|
+
"""Rate limits, overload and outages: another model may still work."""
|
|
24
|
+
return self.status in RETRY_STATUS or self.status is None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ToolCall:
|
|
29
|
+
id: str
|
|
30
|
+
name: str
|
|
31
|
+
arguments: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class TurnResult:
|
|
36
|
+
text: str = ""
|
|
37
|
+
reasoning: str = ""
|
|
38
|
+
tool_calls: List[ToolCall] = field(default_factory=list)
|
|
39
|
+
usage: Dict[str, int] = field(default_factory=dict)
|
|
40
|
+
finish_reason: Optional[str] = None
|
|
41
|
+
ttft_ms: int = 0
|
|
42
|
+
duration: float = 0.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def normalize_base_url(url: str) -> str:
|
|
46
|
+
"""Append /v1 only to a bare host; keep explicit paths such as /api/v1 or /v1beta/openai."""
|
|
47
|
+
url = url.strip().rstrip("/")
|
|
48
|
+
if not re.match(r"^https?://", url):
|
|
49
|
+
url = "https://" + url
|
|
50
|
+
if urlparse(url).path in ("", "/"):
|
|
51
|
+
url += "/v1"
|
|
52
|
+
return url
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _short_id(raw: str) -> str:
|
|
56
|
+
# Mistral rejects tool_call ids that are not 9 alphanumeric chars; other backends accept any.
|
|
57
|
+
return hashlib.sha1(raw.encode()).hexdigest()[:9]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def normalize_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
61
|
+
"""Rewrite tool_call ids so a history built with one model stays valid for another."""
|
|
62
|
+
out = []
|
|
63
|
+
for m in messages:
|
|
64
|
+
m = dict(m)
|
|
65
|
+
if m.get("tool_calls"):
|
|
66
|
+
m["tool_calls"] = [{**tc, "id": _short_id(tc["id"])} for tc in m["tool_calls"]]
|
|
67
|
+
if not m.get("content"):
|
|
68
|
+
m["content"] = ""
|
|
69
|
+
if m.get("role") == "tool":
|
|
70
|
+
m["tool_call_id"] = _short_id(m["tool_call_id"])
|
|
71
|
+
out.append(m)
|
|
72
|
+
return out
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _error_message(body: str) -> str:
|
|
76
|
+
"""Short human message. Gateways often wrap the upstream error as JSON inside the message."""
|
|
77
|
+
try:
|
|
78
|
+
err = json.loads(body).get("error", {})
|
|
79
|
+
except (ValueError, AttributeError):
|
|
80
|
+
return body.strip()[:200]
|
|
81
|
+
if not isinstance(err, dict):
|
|
82
|
+
return str(err)[:200]
|
|
83
|
+
msg = err.get("message") or json.dumps(err)
|
|
84
|
+
inner = re.search(r"\{.*\}", msg, re.DOTALL)
|
|
85
|
+
if inner:
|
|
86
|
+
try:
|
|
87
|
+
raw = json.loads(inner.group(0)).get("raw")
|
|
88
|
+
if raw:
|
|
89
|
+
msg = raw
|
|
90
|
+
except (ValueError, AttributeError):
|
|
91
|
+
pass
|
|
92
|
+
msg = re.split(r"(?<=\.)\s", msg.strip(), maxsplit=1)[0] # first sentence is the useful part
|
|
93
|
+
return msg[:200]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _retry_after(resp: httpx.Response) -> Optional[float]:
|
|
97
|
+
try:
|
|
98
|
+
return max(0.0, min(float(resp.headers.get("retry-after", "")), 20.0))
|
|
99
|
+
except ValueError:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class OpenAICompatProvider:
|
|
104
|
+
def __init__(self, base_url: str, api_key: str, client: Optional[httpx.Client] = None,
|
|
105
|
+
max_retries: int = 3):
|
|
106
|
+
self.base_url = normalize_base_url(base_url)
|
|
107
|
+
self.api_key = api_key
|
|
108
|
+
self.max_retries = max_retries
|
|
109
|
+
self.client = client or httpx.Client(
|
|
110
|
+
timeout=httpx.Timeout(connect=15.0, read=180.0, write=30.0, pool=15.0))
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def headers(self) -> Dict[str, str]:
|
|
114
|
+
return {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
115
|
+
|
|
116
|
+
def stream(self, model: str, messages: List[Dict[str, Any]], tools: Optional[List[Dict]] = None,
|
|
117
|
+
temperature: float = 0.3, max_tokens: int = 8192,
|
|
118
|
+
on_text: Optional[Callable[[str], None]] = None,
|
|
119
|
+
on_reasoning: Optional[Callable[[str], None]] = None) -> TurnResult:
|
|
120
|
+
payload: Dict[str, Any] = {
|
|
121
|
+
"model": model,
|
|
122
|
+
"messages": normalize_messages(messages),
|
|
123
|
+
"temperature": temperature,
|
|
124
|
+
"max_tokens": max_tokens,
|
|
125
|
+
"stream": True,
|
|
126
|
+
"stream_options": {"include_usage": True},
|
|
127
|
+
}
|
|
128
|
+
if tools:
|
|
129
|
+
payload["tools"] = tools
|
|
130
|
+
payload["tool_choice"] = "auto"
|
|
131
|
+
|
|
132
|
+
attempt = 0
|
|
133
|
+
while True:
|
|
134
|
+
attempt += 1
|
|
135
|
+
try:
|
|
136
|
+
return self._stream_once(payload, on_text, on_reasoning)
|
|
137
|
+
except _Retryable as e:
|
|
138
|
+
if attempt > self.max_retries:
|
|
139
|
+
raise ProviderError(str(e), e.status) from None
|
|
140
|
+
time.sleep(e.wait if e.wait is not None else min(2 ** (attempt - 1), 8))
|
|
141
|
+
|
|
142
|
+
def _stream_once(self, payload, on_text, on_reasoning) -> TurnResult:
|
|
143
|
+
result = TurnResult()
|
|
144
|
+
text_parts: List[str] = []
|
|
145
|
+
reasoning_parts: List[str] = []
|
|
146
|
+
calls: Dict[int, Dict[str, str]] = {}
|
|
147
|
+
start = time.time()
|
|
148
|
+
got_delta = False
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
with self.client.stream("POST", f"{self.base_url}/chat/completions",
|
|
152
|
+
headers=self.headers, json=payload) as resp:
|
|
153
|
+
if resp.status_code != 200:
|
|
154
|
+
body = resp.read().decode("utf-8", errors="replace")
|
|
155
|
+
msg = f"HTTP {resp.status_code}: {_error_message(body)}"
|
|
156
|
+
if resp.status_code in RETRY_STATUS:
|
|
157
|
+
raise _Retryable(msg, resp.status_code, _retry_after(resp))
|
|
158
|
+
raise ProviderError(msg, resp.status_code)
|
|
159
|
+
|
|
160
|
+
for line in resp.iter_lines():
|
|
161
|
+
if not line.startswith("data:"):
|
|
162
|
+
continue
|
|
163
|
+
data = line[5:].strip()
|
|
164
|
+
if data == "[DONE]":
|
|
165
|
+
break
|
|
166
|
+
try:
|
|
167
|
+
chunk = json.loads(data)
|
|
168
|
+
except ValueError:
|
|
169
|
+
continue
|
|
170
|
+
if chunk.get("error"):
|
|
171
|
+
err = chunk["error"]
|
|
172
|
+
raise ProviderError(err.get("message", str(err)) if isinstance(err, dict) else str(err))
|
|
173
|
+
if chunk.get("usage"):
|
|
174
|
+
result.usage = {k: v for k, v in chunk["usage"].items() if isinstance(v, int)}
|
|
175
|
+
for choice in chunk.get("choices") or []:
|
|
176
|
+
delta = choice.get("delta") or {}
|
|
177
|
+
reasoning = delta.get("reasoning_content") or delta.get("reasoning") or ""
|
|
178
|
+
if reasoning:
|
|
179
|
+
got_delta = True
|
|
180
|
+
reasoning_parts.append(reasoning)
|
|
181
|
+
if on_reasoning:
|
|
182
|
+
on_reasoning(reasoning)
|
|
183
|
+
content = delta.get("content") or ""
|
|
184
|
+
if content:
|
|
185
|
+
if not result.ttft_ms:
|
|
186
|
+
result.ttft_ms = round((time.time() - start) * 1000)
|
|
187
|
+
got_delta = True
|
|
188
|
+
text_parts.append(content)
|
|
189
|
+
if on_text:
|
|
190
|
+
on_text(content)
|
|
191
|
+
for tc in delta.get("tool_calls") or []:
|
|
192
|
+
got_delta = True
|
|
193
|
+
slot = calls.setdefault(self._slot_index(calls, tc),
|
|
194
|
+
{"id": "", "name": "", "arguments": ""})
|
|
195
|
+
if tc.get("id"):
|
|
196
|
+
slot["id"] = tc["id"]
|
|
197
|
+
fn = tc.get("function") or {}
|
|
198
|
+
name = fn.get("name")
|
|
199
|
+
if name and name != slot["name"]: # some backends resend the full name
|
|
200
|
+
slot["name"] += name
|
|
201
|
+
if fn.get("arguments"):
|
|
202
|
+
args = fn["arguments"]
|
|
203
|
+
slot["arguments"] += args if isinstance(args, str) else json.dumps(args)
|
|
204
|
+
if choice.get("finish_reason"):
|
|
205
|
+
result.finish_reason = choice["finish_reason"]
|
|
206
|
+
except (httpx.TimeoutException, httpx.TransportError) as e:
|
|
207
|
+
if got_delta:
|
|
208
|
+
raise ProviderError(f"Connection lost mid-stream: {e}") from None
|
|
209
|
+
raise _Retryable(f"{type(e).__name__}: {e}") from None
|
|
210
|
+
|
|
211
|
+
result.text = "".join(text_parts)
|
|
212
|
+
result.reasoning = "".join(reasoning_parts)
|
|
213
|
+
for idx in sorted(calls):
|
|
214
|
+
slot = calls[idx]
|
|
215
|
+
if slot["name"]:
|
|
216
|
+
result.tool_calls.append(ToolCall(slot["id"] or f"call_{idx}_{int(start)}",
|
|
217
|
+
slot["name"], slot["arguments"] or "{}"))
|
|
218
|
+
result.duration = time.time() - start
|
|
219
|
+
return result
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def _slot_index(calls: Dict[int, Dict[str, str]], tc: Dict[str, Any]) -> int:
|
|
223
|
+
if isinstance(tc.get("index"), int):
|
|
224
|
+
return tc["index"]
|
|
225
|
+
# No index: a new id starts a new call, otherwise the chunk continues the last one.
|
|
226
|
+
cid = tc.get("id")
|
|
227
|
+
for idx, slot in calls.items():
|
|
228
|
+
if cid and slot["id"] == cid:
|
|
229
|
+
return idx
|
|
230
|
+
if cid or not calls:
|
|
231
|
+
return max(calls, default=-1) + 1
|
|
232
|
+
return max(calls)
|
|
233
|
+
|
|
234
|
+
def complete(self, model: str, messages: List[Dict[str, Any]], max_tokens: int = 2048,
|
|
235
|
+
temperature: float = 0.2) -> str:
|
|
236
|
+
"""Non-streaming call used for compaction and summaries."""
|
|
237
|
+
payload = {"model": model, "messages": normalize_messages(messages),
|
|
238
|
+
"max_tokens": max_tokens, "temperature": temperature, "stream": False}
|
|
239
|
+
try:
|
|
240
|
+
resp = self.client.post(f"{self.base_url}/chat/completions", headers=self.headers, json=payload)
|
|
241
|
+
except httpx.HTTPError as e:
|
|
242
|
+
raise ProviderError(f"{type(e).__name__}: {e}") from None
|
|
243
|
+
if resp.status_code != 200:
|
|
244
|
+
raise ProviderError(f"HTTP {resp.status_code}: {_error_message(resp.text)}")
|
|
245
|
+
choices = resp.json().get("choices") or []
|
|
246
|
+
if not choices:
|
|
247
|
+
raise ProviderError("Empty response")
|
|
248
|
+
return (choices[0].get("message") or {}).get("content") or ""
|
|
249
|
+
|
|
250
|
+
def ping(self, model: str) -> Dict[str, Any]:
|
|
251
|
+
start = time.time()
|
|
252
|
+
try:
|
|
253
|
+
text = self.complete(model, [{"role": "user", "content": "Reply OK"}], max_tokens=10)
|
|
254
|
+
return {"ok": True, "latency_ms": round((time.time() - start) * 1000), "msg": text.strip()[:60]}
|
|
255
|
+
except ProviderError as e:
|
|
256
|
+
return {"ok": False, "latency_ms": round((time.time() - start) * 1000), "msg": str(e)[:120]}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class _Retryable(Exception):
|
|
260
|
+
def __init__(self, message: str, status: Optional[int] = None, wait: Optional[float] = None):
|
|
261
|
+
super().__init__(message)
|
|
262
|
+
self.status = status
|
|
263
|
+
self.wait = wait
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def parse_arguments(raw: str) -> Dict[str, Any]:
|
|
267
|
+
"""Parse tool-call JSON, tolerating code fences and trailing junk from weaker models."""
|
|
268
|
+
raw = (raw or "").strip() or "{}"
|
|
269
|
+
candidates = [raw]
|
|
270
|
+
fenced = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw)
|
|
271
|
+
if fenced != raw:
|
|
272
|
+
candidates.append(fenced)
|
|
273
|
+
start, end = raw.find("{"), raw.rfind("}")
|
|
274
|
+
if 0 <= start < end:
|
|
275
|
+
candidates.append(raw[start:end + 1])
|
|
276
|
+
for cand in candidates:
|
|
277
|
+
try:
|
|
278
|
+
val = json.loads(cand)
|
|
279
|
+
except ValueError:
|
|
280
|
+
continue
|
|
281
|
+
if isinstance(val, dict):
|
|
282
|
+
return val
|
|
283
|
+
raise ValueError(f"Tool arguments are not a valid JSON object: {raw[:200]}")
|
hubble/providers.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Multiple OpenAI-compatible providers.
|
|
2
|
+
|
|
3
|
+
The built-in "hubble" provider comes from settings/.env, and connects to the AIHub gateway (or
|
|
4
|
+
another OpenAI-compatible base URL you set) by default. Extra providers added with
|
|
5
|
+
`/provider add` live in ~/.hubble/providers.json (API keys are stored there in plain text,
|
|
6
|
+
like a .env file). Each provider has its own model scan file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from hubble.models import SCAN_FILE, all_models
|
|
18
|
+
from hubble.provider import normalize_base_url
|
|
19
|
+
from hubble.settings import HOME_DIR
|
|
20
|
+
|
|
21
|
+
DEFAULT_PROVIDER = "hubble"
|
|
22
|
+
# Renamed from "aihub" when this CLI was renamed to Hubble; old session files may still record it.
|
|
23
|
+
LEGACY_DEFAULT_PROVIDER = "aihub"
|
|
24
|
+
PROVIDERS_FILE = HOME_DIR / "providers.json"
|
|
25
|
+
NAME_RX = re.compile(r"^[a-z0-9][a-z0-9_-]{0,30}$")
|
|
26
|
+
|
|
27
|
+
KNOWN_EXAMPLES = [
|
|
28
|
+
("openrouter", "https://openrouter.ai/api/v1"),
|
|
29
|
+
("groq", "https://api.groq.com/openai/v1"),
|
|
30
|
+
("openai", "https://api.openai.com/v1"),
|
|
31
|
+
("mistral", "https://api.mistral.ai/v1"),
|
|
32
|
+
("gemini", "https://generativelanguage.googleapis.com/v1beta/openai"),
|
|
33
|
+
("ollama", "http://localhost:11434/v1"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ProviderConfig:
|
|
39
|
+
name: str
|
|
40
|
+
base_url: str
|
|
41
|
+
api_key: str
|
|
42
|
+
check_models: bool = True # probe each model for availability (costs one tiny request per model)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read_file() -> Dict[str, Any]:
|
|
46
|
+
try:
|
|
47
|
+
data = json.loads(PROVIDERS_FILE.read_text(encoding="utf-8"))
|
|
48
|
+
return data if isinstance(data, dict) else {}
|
|
49
|
+
except (OSError, ValueError):
|
|
50
|
+
return {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _write_file(data: Dict[str, Any]):
|
|
54
|
+
HOME_DIR.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
tmp = PROVIDERS_FILE.with_suffix(".json.tmp")
|
|
56
|
+
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
57
|
+
tmp.replace(PROVIDERS_FILE)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_providers(settings: Dict[str, Any]) -> Dict[str, ProviderConfig]:
|
|
61
|
+
out: Dict[str, ProviderConfig] = {}
|
|
62
|
+
if settings.get("api_key"):
|
|
63
|
+
out[DEFAULT_PROVIDER] = ProviderConfig(DEFAULT_PROVIDER, normalize_base_url(settings["base_url"]),
|
|
64
|
+
settings["api_key"], True)
|
|
65
|
+
for name, entry in _read_file().items():
|
|
66
|
+
if isinstance(entry, dict) and entry.get("base_url") and entry.get("api_key"):
|
|
67
|
+
out[name] = ProviderConfig(name, normalize_base_url(entry["base_url"]), entry["api_key"],
|
|
68
|
+
bool(entry.get("check_models", True)))
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_provider(cfg: ProviderConfig):
|
|
73
|
+
data = _read_file()
|
|
74
|
+
data[cfg.name] = {"base_url": cfg.base_url, "api_key": cfg.api_key, "check_models": cfg.check_models}
|
|
75
|
+
_write_file(data)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def remove_provider(name: str) -> bool:
|
|
79
|
+
data = _read_file()
|
|
80
|
+
if name not in data:
|
|
81
|
+
return False
|
|
82
|
+
del data[name]
|
|
83
|
+
_write_file(data)
|
|
84
|
+
path = scan_file(name)
|
|
85
|
+
if path.exists():
|
|
86
|
+
path.unlink()
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def scan_file(name: str) -> Path:
|
|
91
|
+
return SCAN_FILE if name == DEFAULT_PROVIDER else HOME_DIR / "models" / f"{name}.json"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def verify(base_url: str, api_key: str, timeout: float = 20.0) -> Tuple[bool, str, List[str]]:
|
|
95
|
+
"""Check the endpoint and key by listing models. Returns (ok, message, model_ids)."""
|
|
96
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
97
|
+
try:
|
|
98
|
+
resp = httpx.get(f"{base_url}/models", headers=headers, timeout=timeout)
|
|
99
|
+
except httpx.HTTPError as e:
|
|
100
|
+
return False, f"could not connect: {type(e).__name__}: {e}"[:200], []
|
|
101
|
+
if resp.status_code in (401, 403):
|
|
102
|
+
return False, f"the API key was rejected (HTTP {resp.status_code})", []
|
|
103
|
+
if resp.status_code == 404:
|
|
104
|
+
return False, (f"no /models endpoint at {base_url} (HTTP 404). Check the base URL; "
|
|
105
|
+
"it usually ends in /v1"), []
|
|
106
|
+
if resp.status_code != 200:
|
|
107
|
+
body = resp.text.strip()
|
|
108
|
+
detail = "" if body.startswith("<") else f": {body[:150]}"
|
|
109
|
+
return False, f"HTTP {resp.status_code} from {base_url}/models{detail}", []
|
|
110
|
+
try:
|
|
111
|
+
data = resp.json()
|
|
112
|
+
except ValueError:
|
|
113
|
+
return False, f"{base_url}/models did not return JSON; is this an OpenAI-compatible base URL?", []
|
|
114
|
+
entries = data.get("data", data) if isinstance(data, dict) else data
|
|
115
|
+
ids = [m["id"] for m in entries if isinstance(m, dict) and m.get("id")] if isinstance(entries, list) else []
|
|
116
|
+
if not ids:
|
|
117
|
+
return False, "connected, but the provider listed no models", []
|
|
118
|
+
return True, f"connected, {len(ids)} models listed", ids
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def save_listing(name: str, base_url: str, ids: List[str]):
|
|
122
|
+
"""Record the model list without probing, so the picker can show models right away."""
|
|
123
|
+
path = scan_file(name)
|
|
124
|
+
existing: Dict[str, Any] = {}
|
|
125
|
+
try:
|
|
126
|
+
existing = json.loads(path.read_text(encoding="utf-8"))
|
|
127
|
+
except (OSError, ValueError):
|
|
128
|
+
pass
|
|
129
|
+
existing.update({"base_url": base_url, "all_ids": ids})
|
|
130
|
+
existing.setdefault("working_models", [])
|
|
131
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
path.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def provider_models(name: str) -> List[Dict[str, Any]]:
|
|
136
|
+
"""Model entries for one provider: model, category, latency_ms, available (None = not checked)."""
|
|
137
|
+
if name == DEFAULT_PROVIDER:
|
|
138
|
+
return all_models()
|
|
139
|
+
try:
|
|
140
|
+
data = json.loads(scan_file(name).read_text(encoding="utf-8"))
|
|
141
|
+
except (OSError, ValueError):
|
|
142
|
+
return []
|
|
143
|
+
working = {m["model"]: m for m in data.get("working_models", []) if m.get("model")}
|
|
144
|
+
checked = {r["model"] for r in data.get("all_results", []) if r.get("model")}
|
|
145
|
+
out = [{"model": m, "category": "Available", "latency_ms": w.get("latency_ms"), "available": True}
|
|
146
|
+
for m, w in working.items()]
|
|
147
|
+
for m in data.get("all_ids", []):
|
|
148
|
+
if m not in working:
|
|
149
|
+
out.append({"model": m, "category": "Unavailable" if m in checked else "Not checked",
|
|
150
|
+
"latency_ms": None, "available": False if m in checked else None})
|
|
151
|
+
return out
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def resolve_fallback(settings: Dict[str, Any], providers: Dict[str, "ProviderConfig"], provider: str,
|
|
155
|
+
model: str) -> Optional[Tuple[str, str]]:
|
|
156
|
+
"""Pick (provider, model) to retry with when `model` on `provider` is rate limited or down.
|
|
157
|
+
|
|
158
|
+
Order: a fallback set for this provider (/fallback), then fallback_model if this provider
|
|
159
|
+
has it, then the fastest verified model on the same provider, then fallback_model on the
|
|
160
|
+
built-in hubble provider. Returns None when fallback is off or nothing suitable exists.
|
|
161
|
+
"""
|
|
162
|
+
per_provider = (settings.get("fallback_models") or {}).get(provider)
|
|
163
|
+
if per_provider == "off":
|
|
164
|
+
return None
|
|
165
|
+
if per_provider and per_provider != model:
|
|
166
|
+
return provider, per_provider
|
|
167
|
+
default = settings.get("fallback_model")
|
|
168
|
+
if not default and not per_provider:
|
|
169
|
+
return None
|
|
170
|
+
known = provider_models(provider)
|
|
171
|
+
names = {m["model"] for m in known if m.get("available") is not False}
|
|
172
|
+
if default and default != model and default in names:
|
|
173
|
+
return provider, default
|
|
174
|
+
verified = sorted((m for m in known if m.get("available") and m["model"] != model and m.get("latency_ms")),
|
|
175
|
+
key=lambda m: m["latency_ms"])
|
|
176
|
+
if verified:
|
|
177
|
+
return provider, verified[0]["model"]
|
|
178
|
+
if default and provider != DEFAULT_PROVIDER and DEFAULT_PROVIDER in providers:
|
|
179
|
+
if default in {m["model"] for m in provider_models(DEFAULT_PROVIDER) if m.get("available") is not False}:
|
|
180
|
+
return DEFAULT_PROVIDER, default
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def normalize_provider_name(name: Optional[str]) -> str:
|
|
185
|
+
"""Map a possibly-legacy provider slug from an old session file to the current one."""
|
|
186
|
+
return DEFAULT_PROVIDER if not name or name == LEGACY_DEFAULT_PROVIDER else name
|