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/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