@qubiqlabs/mobiflow 0.9.0 → 1.0.0

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 (39) hide show
  1. package/README.md +9 -11
  2. package/bin/mobiflow.js +94 -61
  3. package/package.json +8 -3
  4. package/pyproject.toml +59 -0
  5. package/src/mobiflow/__init__.py +9 -0
  6. package/src/mobiflow/__main__.py +6 -0
  7. package/src/mobiflow/baseline.py +228 -0
  8. package/src/mobiflow/casedata.py +159 -0
  9. package/src/mobiflow/cases/__init__.py +715 -0
  10. package/src/mobiflow/cli.py +1423 -0
  11. package/src/mobiflow/cloud/__init__.py +28 -0
  12. package/src/mobiflow/cloud/base.py +272 -0
  13. package/src/mobiflow/cloud/browserstack.py +330 -0
  14. package/src/mobiflow/cloud/maestro_cloud.py +141 -0
  15. package/src/mobiflow/cloud/media.py +269 -0
  16. package/src/mobiflow/cloud/runner.py +156 -0
  17. package/src/mobiflow/cloud/testmu.py +378 -0
  18. package/src/mobiflow/config/__init__.py +538 -0
  19. package/src/mobiflow/deps.py +377 -0
  20. package/src/mobiflow/devices.py +717 -0
  21. package/src/mobiflow/explore.py +623 -0
  22. package/src/mobiflow/incremental.py +198 -0
  23. package/src/mobiflow/init/__init__.py +794 -0
  24. package/src/mobiflow/llm.py +462 -0
  25. package/src/mobiflow/llm_catalog.py +232 -0
  26. package/src/mobiflow/maestro/__init__.py +1506 -0
  27. package/src/mobiflow/maestro/lifecycle.py +279 -0
  28. package/src/mobiflow/pipeline.py +600 -0
  29. package/src/mobiflow/report/__init__.py +617 -0
  30. package/src/mobiflow/report/static/favicon.jpg +0 -0
  31. package/src/mobiflow/report/static/favicon.svg +1 -0
  32. package/src/mobiflow/report/static/icons.svg +24 -0
  33. package/src/mobiflow/report/static/index.html +99 -0
  34. package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
  35. package/src/mobiflow/reporting.py +682 -0
  36. package/src/mobiflow/sample_apps.py +259 -0
  37. package/src/mobiflow/secrets.py +90 -0
  38. package/src/mobiflow/selectors.py +128 -0
  39. package/src/mobiflow/suite.py +263 -0
@@ -0,0 +1,462 @@
1
+ """Build LLM chat completions from llm.json profiles (OpenAI / Azure / Anthropic / Google)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from dataclasses import asdict, dataclass, field
8
+ from typing import Any
9
+
10
+ from mobiflow.llm_catalog import ModelEntry
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ @dataclass
16
+ class ChatUsage:
17
+ """Token/cost accounting for one or more chat completions."""
18
+
19
+ prompt_tokens: int = 0
20
+ completion_tokens: int = 0
21
+ total_tokens: int = 0
22
+ cost: float = 0.0
23
+ model: str = ""
24
+ calls: int = 0
25
+
26
+ def merged(self, other: ChatUsage | None) -> ChatUsage:
27
+ if other is None:
28
+ return ChatUsage(
29
+ prompt_tokens=self.prompt_tokens,
30
+ completion_tokens=self.completion_tokens,
31
+ total_tokens=self.total_tokens,
32
+ cost=self.cost,
33
+ model=self.model or "",
34
+ calls=self.calls,
35
+ )
36
+ return ChatUsage(
37
+ prompt_tokens=self.prompt_tokens + other.prompt_tokens,
38
+ completion_tokens=self.completion_tokens + other.completion_tokens,
39
+ total_tokens=self.total_tokens + other.total_tokens,
40
+ cost=round(self.cost + other.cost, 6),
41
+ model=other.model or self.model,
42
+ calls=self.calls + other.calls,
43
+ )
44
+
45
+ def to_dict(self) -> dict[str, Any]:
46
+ return asdict(self)
47
+
48
+
49
+ # Approx USD per 1M tokens (input, output). Keep conservative; reports are estimates.
50
+ _MODEL_RATES_PER_M: dict[str, tuple[float, float]] = {
51
+ "gpt-4o": (2.5, 10.0),
52
+ "gpt-4o-mini": (0.15, 0.6),
53
+ "gpt-4.1": (2.0, 8.0),
54
+ "gpt-5": (5.0, 15.0),
55
+ "gpt-5.4": (5.0, 15.0),
56
+ "o1": (15.0, 60.0),
57
+ "o3": (10.0, 40.0),
58
+ "claude-sonnet": (3.0, 15.0),
59
+ "claude-opus": (15.0, 75.0),
60
+ "gemini-2.0-flash": (0.1, 0.4),
61
+ "gemini-1.5-flash": (0.075, 0.3),
62
+ }
63
+
64
+
65
+ def estimate_chat_cost(model: str, prompt_tokens: int, completion_tokens: int) -> float:
66
+ name = (model or "").lower().replace("_", "-")
67
+ in_rate, out_rate = 2.5, 10.0 # default ≈ gpt-4o
68
+ for key, rates in _MODEL_RATES_PER_M.items():
69
+ if key in name:
70
+ in_rate, out_rate = rates
71
+ break
72
+ return round(
73
+ (max(0, prompt_tokens) / 1_000_000.0) * in_rate
74
+ + (max(0, completion_tokens) / 1_000_000.0) * out_rate,
75
+ 6,
76
+ )
77
+
78
+
79
+ def _usage_from_openai_response(resp: Any, model: str) -> ChatUsage:
80
+ usage = getattr(resp, "usage", None)
81
+ prompt = int(getattr(usage, "prompt_tokens", 0) or 0) if usage else 0
82
+ completion = int(getattr(usage, "completion_tokens", 0) or 0) if usage else 0
83
+ total = int(getattr(usage, "total_tokens", 0) or 0) if usage else (prompt + completion)
84
+ if total <= 0:
85
+ total = prompt + completion
86
+ return ChatUsage(
87
+ prompt_tokens=prompt,
88
+ completion_tokens=completion,
89
+ total_tokens=total,
90
+ cost=estimate_chat_cost(model, prompt, completion),
91
+ model=model,
92
+ calls=1,
93
+ )
94
+
95
+
96
+ def profile_to_llm_config(profile: ModelEntry) -> dict[str, Any]:
97
+ """Shape expected by invoke_chat_text (provider, model, keys, Azure fields)."""
98
+ provider = profile.provider.lower()
99
+ cfg: dict[str, Any] = {
100
+ "provider": "azure" if provider.startswith("azure") else provider,
101
+ "apiKey": profile.resolve_api_key(),
102
+ "temperature": 0.2,
103
+ }
104
+ if provider.startswith("azure"):
105
+ cfg["apiEndpoint"] = profile.resolve_endpoint()
106
+ cfg["deploymentName"] = profile.deployment_name()
107
+ cfg["apiVersion"] = profile.api_version
108
+ cfg["modelName"] = profile.short_model()
109
+ else:
110
+ cfg["modelName"] = profile.short_model()
111
+ return cfg
112
+
113
+
114
+ def invoke_chat_text(
115
+ system: str,
116
+ user: str,
117
+ llm_config: dict[str, Any],
118
+ *,
119
+ max_tokens: int = 4096,
120
+ temperature: float | None = None,
121
+ log_prefix: str = "MobiFlow",
122
+ usage_out: list[ChatUsage] | None = None,
123
+ ) -> str:
124
+ """Synchronous chat completion → plain text.
125
+
126
+ When ``usage_out`` is provided, appends a :class:`ChatUsage` for this call.
127
+ """
128
+ text, usage = invoke_chat(
129
+ system,
130
+ user,
131
+ llm_config,
132
+ max_tokens=max_tokens,
133
+ temperature=temperature,
134
+ log_prefix=log_prefix,
135
+ )
136
+ if usage_out is not None:
137
+ usage_out.append(usage)
138
+ return text
139
+
140
+
141
+ def invoke_chat(
142
+ system: str,
143
+ user: str,
144
+ llm_config: dict[str, Any],
145
+ *,
146
+ max_tokens: int = 4096,
147
+ temperature: float | None = None,
148
+ log_prefix: str = "MobiFlow",
149
+ ) -> tuple[str, ChatUsage]:
150
+ """Synchronous chat completion → (text, usage)."""
151
+ provider = (llm_config.get("provider") or "openai").lower()
152
+ temp = temperature if temperature is not None else float(llm_config.get("temperature") or 0.2)
153
+ api_key = llm_config.get("apiKey") or ""
154
+ if not api_key:
155
+ raise ValueError(f"{log_prefix}: llm_config missing apiKey")
156
+
157
+ messages = [
158
+ {"role": "system", "content": system},
159
+ {"role": "user", "content": user},
160
+ ]
161
+
162
+ if provider in ("anthropic", "claude"):
163
+ return _anthropic_chat(messages, llm_config, api_key, max_tokens, temp, log_prefix)
164
+
165
+ if provider in ("google", "gemini", "google-genai"):
166
+ return _google_chat(messages, llm_config, api_key, max_tokens, temp, log_prefix)
167
+
168
+ if provider.startswith("azure"):
169
+ return _openai_compatible_chat(
170
+ messages,
171
+ llm_config,
172
+ api_key,
173
+ max_tokens,
174
+ temp,
175
+ log_prefix,
176
+ azure=True,
177
+ )
178
+
179
+ return _openai_compatible_chat(
180
+ messages,
181
+ llm_config,
182
+ api_key,
183
+ max_tokens,
184
+ temp,
185
+ log_prefix,
186
+ azure=False,
187
+ )
188
+
189
+
190
+ def merge_usage_list(items: list[ChatUsage] | None) -> ChatUsage:
191
+ out = ChatUsage()
192
+ for item in items or []:
193
+ out = out.merged(item)
194
+ return out
195
+
196
+
197
+ def _openai_compatible_chat(
198
+ messages: list[dict[str, str]],
199
+ llm_config: dict[str, Any],
200
+ api_key: str,
201
+ max_tokens: int,
202
+ temperature: float,
203
+ log_prefix: str,
204
+ *,
205
+ azure: bool,
206
+ ) -> tuple[str, ChatUsage]:
207
+ from openai import AzureOpenAI, OpenAI
208
+
209
+ if azure:
210
+ endpoint = (llm_config.get("apiEndpoint") or "").rstrip("/")
211
+ deployment = llm_config.get("deploymentName") or llm_config.get("modelName") or "gpt-4o"
212
+ api_version = llm_config.get("apiVersion") or "2025-03-01-preview"
213
+ client = AzureOpenAI(
214
+ api_key=api_key,
215
+ azure_endpoint=endpoint,
216
+ api_version=api_version,
217
+ )
218
+ model = deployment
219
+ else:
220
+ client = OpenAI(api_key=api_key)
221
+ model = llm_config.get("modelName") or "gpt-4o"
222
+
223
+ # Prefer max_completion_tokens for GPT-5 / o-series; fall back on 400.
224
+ use_completion = _prefers_max_completion_tokens(model)
225
+ kwargs: dict[str, Any] = {
226
+ "model": model,
227
+ "messages": messages,
228
+ }
229
+ if not _drop_temperature(model):
230
+ kwargs["temperature"] = temperature
231
+ if use_completion:
232
+ kwargs["max_completion_tokens"] = max_tokens
233
+ else:
234
+ kwargs["max_tokens"] = max_tokens
235
+
236
+ try:
237
+ resp = client.chat.completions.create(**kwargs)
238
+ except Exception as first: # noqa: BLE001
239
+ msg = str(first).lower()
240
+ if "max_tokens" in msg and "max_completion_tokens" in msg:
241
+ kwargs.pop("max_tokens", None)
242
+ kwargs["max_completion_tokens"] = max_tokens
243
+ resp = client.chat.completions.create(**kwargs)
244
+ elif "temperature" in msg and "unsupported" in msg:
245
+ kwargs.pop("temperature", None)
246
+ resp = client.chat.completions.create(**kwargs)
247
+ else:
248
+ logger.error("%s LLM call failed: %s", log_prefix, first)
249
+ raise
250
+
251
+ usage = _usage_from_openai_response(resp, str(model))
252
+ choice = (resp.choices or [None])[0]
253
+ if not choice or not choice.message:
254
+ return "", usage
255
+ return (choice.message.content or "").strip(), usage
256
+
257
+
258
+ def _anthropic_chat(
259
+ messages: list[dict[str, str]],
260
+ llm_config: dict[str, Any],
261
+ api_key: str,
262
+ max_tokens: int,
263
+ temperature: float,
264
+ log_prefix: str,
265
+ ) -> tuple[str, ChatUsage]:
266
+ try:
267
+ import anthropic
268
+ except ImportError as e:
269
+ raise RuntimeError(
270
+ "Anthropic support needs: pip install 'mobiflow[anthropic]' or pip install anthropic"
271
+ ) from e
272
+
273
+ system = next((m["content"] for m in messages if m["role"] == "system"), "")
274
+ user_msgs = [m for m in messages if m["role"] != "system"]
275
+ client = anthropic.Anthropic(api_key=api_key)
276
+ model = llm_config.get("modelName") or "claude-sonnet-4-6"
277
+ resp = client.messages.create(
278
+ model=model,
279
+ max_tokens=max_tokens,
280
+ temperature=temperature,
281
+ system=system,
282
+ messages=[{"role": m["role"], "content": m["content"]} for m in user_msgs],
283
+ )
284
+ parts = []
285
+ for block in resp.content or []:
286
+ text = getattr(block, "text", None)
287
+ if text:
288
+ parts.append(text)
289
+ prompt = int(getattr(resp, "usage", None) and getattr(resp.usage, "input_tokens", 0) or 0)
290
+ completion = int(
291
+ getattr(resp, "usage", None) and getattr(resp.usage, "output_tokens", 0) or 0
292
+ )
293
+ usage = ChatUsage(
294
+ prompt_tokens=prompt,
295
+ completion_tokens=completion,
296
+ total_tokens=prompt + completion,
297
+ cost=estimate_chat_cost(str(model), prompt, completion),
298
+ model=str(model),
299
+ calls=1,
300
+ )
301
+ return "\n".join(parts).strip(), usage
302
+
303
+
304
+ def _google_chat(
305
+ messages: list[dict[str, str]],
306
+ llm_config: dict[str, Any],
307
+ api_key: str,
308
+ max_tokens: int,
309
+ temperature: float,
310
+ log_prefix: str,
311
+ ) -> tuple[str, ChatUsage]:
312
+ """Google Gemini via REST (no heavy SDK dep)."""
313
+ import httpx
314
+
315
+ model = llm_config.get("modelName") or "gemini-2.0-flash"
316
+ url = (
317
+ f"https://generativelanguage.googleapis.com/v1beta/models/"
318
+ f"{model}:generateContent?key={api_key}"
319
+ )
320
+ system = next((m["content"] for m in messages if m["role"] == "system"), "")
321
+ user = "\n\n".join(m["content"] for m in messages if m["role"] == "user")
322
+ body: dict[str, Any] = {
323
+ "contents": [{"role": "user", "parts": [{"text": user}]}],
324
+ "generationConfig": {
325
+ "maxOutputTokens": max_tokens,
326
+ "temperature": temperature,
327
+ },
328
+ }
329
+ if system:
330
+ body["systemInstruction"] = {"parts": [{"text": system}]}
331
+
332
+ with httpx.Client(timeout=120.0) as client:
333
+ r = client.post(url, json=body)
334
+ r.raise_for_status()
335
+ data = r.json()
336
+ usage_meta = data.get("usageMetadata") or {}
337
+ prompt = int(usage_meta.get("promptTokenCount") or 0)
338
+ completion = int(usage_meta.get("candidatesTokenCount") or 0)
339
+ total = int(usage_meta.get("totalTokenCount") or (prompt + completion))
340
+ usage = ChatUsage(
341
+ prompt_tokens=prompt,
342
+ completion_tokens=completion,
343
+ total_tokens=total,
344
+ cost=estimate_chat_cost(str(model), prompt, completion),
345
+ model=str(model),
346
+ calls=1,
347
+ )
348
+ try:
349
+ text = data["candidates"][0]["content"]["parts"][0]["text"].strip()
350
+ except (KeyError, IndexError, TypeError) as e:
351
+ logger.error("%s Gemini parse failed: %s — %s", log_prefix, e, data)
352
+ raise RuntimeError(f"{log_prefix}: unexpected Gemini response") from e
353
+ return text, usage
354
+
355
+
356
+ def _prefers_max_completion_tokens(model: str) -> bool:
357
+ name = (model or "").lower().replace("_", "-")
358
+ return (
359
+ name.startswith("o1")
360
+ or name.startswith("o3")
361
+ or name.startswith("o4")
362
+ or "gpt-5" in name
363
+ or "gpt5" in name
364
+ )
365
+
366
+
367
+ def _drop_temperature(model: str) -> bool:
368
+ return _prefers_max_completion_tokens(model)
369
+
370
+
371
+ def extract_yaml_fence(text: str) -> str:
372
+ """Pull YAML from a ```yaml fence or return stripped text."""
373
+ files = extract_fenced_files(text)
374
+ for name, body in files:
375
+ if name.endswith((".yaml", ".yml")) or name == "flow.yaml":
376
+ return body
377
+ # Prefer explicitly tagged yaml fences
378
+ if not text:
379
+ return ""
380
+ raw = text.strip()
381
+ m = re.search(r"```(?:yaml|yml)\s*\n(.*?)```", raw, re.DOTALL | re.IGNORECASE)
382
+ if m:
383
+ return m.group(1).strip()
384
+ m = re.search(r"```\s*\n(.*?)```", raw, re.DOTALL)
385
+ if m and looks_like_yaml_body(m.group(1)):
386
+ return m.group(1).strip()
387
+ if raw.startswith("```"):
388
+ raw = re.sub(r"^```(?:yaml|yml)?\s*", "", raw, flags=re.I)
389
+ raw = re.sub(r"\s*```$", "", raw)
390
+ return raw.strip()
391
+
392
+
393
+ def looks_like_yaml_body(text: str) -> bool:
394
+ t = (text or "").strip()
395
+ return bool(
396
+ re.search(r"(?m)^appId:\s*\S+", t)
397
+ or ("---" in t and re.search(r"(?m)^\s*-\s*\w+", t))
398
+ )
399
+
400
+
401
+ _FENCE_RE = re.compile(
402
+ r"```(?P<lang>[^\n`]*)\n(?P<body>.*?)```",
403
+ re.DOTALL,
404
+ )
405
+
406
+
407
+ def extract_fenced_files(text: str) -> list[tuple[str, str]]:
408
+ """Extract fenced blocks as (filename, body).
409
+
410
+ Supported headers::
411
+
412
+ ```yaml
413
+ ```yaml flow.yaml
414
+ ```javascript helpers.js
415
+ ```js file=helpers.js
416
+ """
417
+ if not text:
418
+ return []
419
+ out: list[tuple[str, str]] = []
420
+ for m in _FENCE_RE.finditer(text):
421
+ header = (m.group("lang") or "").strip()
422
+ body = (m.group("body") or "").strip()
423
+ if not body:
424
+ continue
425
+ name = _filename_from_fence_header(header, body)
426
+ out.append((name, body))
427
+ return out
428
+
429
+
430
+ def _filename_from_fence_header(header: str, body: str) -> str:
431
+ h = header.strip()
432
+ if not h:
433
+ return "flow.yaml" if looks_like_yaml_body(body) else "script.js"
434
+
435
+ # file=name or path in header
436
+ file_m = re.search(r"(?:file|filename|name)\s*[=:]\s*(\S+)", h, re.I)
437
+ if file_m:
438
+ return file_m.group(1).strip().strip("\"'")
439
+
440
+ parts = h.split()
441
+ lang = parts[0].lower() if parts else ""
442
+ # ```yaml flow.yaml or ```javascript helpers.js
443
+ if len(parts) >= 2 and ("." in parts[1] or parts[1].endswith((".js", ".yaml", ".yml"))):
444
+ return parts[1]
445
+
446
+ # first line of body: // file: helpers.js or # file: flow.yaml
447
+ first = body.splitlines()[0].strip() if body else ""
448
+ file_line = re.match(
449
+ r"^(?://|#)\s*file:\s*(\S+)",
450
+ first,
451
+ re.I,
452
+ )
453
+ if file_line:
454
+ return file_line.group(1).strip()
455
+
456
+ if lang in ("yaml", "yml"):
457
+ return "flow.yaml"
458
+ if lang in ("js", "javascript", "ecmascript"):
459
+ return "helpers.js"
460
+ if lang.endswith((".js", ".yaml", ".yml")):
461
+ return lang
462
+ return "flow.yaml" if looks_like_yaml_body(body) else "script.js"
@@ -0,0 +1,232 @@
1
+ """Multi-provider LLM catalog (`llm.json`).
2
+
3
+ mobiflow.config.yaml only picks which catalog entries to use:
4
+
5
+ llm:
6
+ catalog: llm.json
7
+ discovery: azure-gpt4o # adaptive explore / plan on device
8
+ codegen: anthropic-sonnet # Maestro YAML authoring
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Any, Optional
18
+
19
+ from pydantic import BaseModel, Field, field_validator, model_validator
20
+
21
+ LLM_CATALOG_FILENAME = "llm.json"
22
+
23
+ _ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
24
+
25
+
26
+ def _check_env_name(value: str, field: str) -> str:
27
+ name = value.strip()
28
+ looks_like_secret = (
29
+ not _ENV_NAME_RE.match(name)
30
+ or len(name) > 64
31
+ or (len(name) >= 20 and any(c.islower() for c in name))
32
+ )
33
+ if looks_like_secret:
34
+ raise ValueError(
35
+ f"{field} must be an environment variable NAME, not a secret value."
36
+ )
37
+ return name
38
+
39
+
40
+ class ModelEntry(BaseModel):
41
+ """One named LLM profile in llm.json."""
42
+
43
+ provider: str # openai | azure | anthropic | google
44
+ model: Optional[str] = None
45
+ deployment: Optional[str] = None
46
+ api_key_env: str = "MOBIFLOW_LLM_API_KEY"
47
+ label: Optional[str] = None
48
+ endpoint: Optional[str] = None
49
+ endpoint_env: str = "AZURE_OPENAI_ENDPOINT"
50
+ api_version: str = "2025-03-01-preview"
51
+
52
+ @field_validator("api_key_env")
53
+ @classmethod
54
+ def _api_key_env(cls, v: str) -> str:
55
+ return _check_env_name(v, "api_key_env")
56
+
57
+ @field_validator("endpoint_env")
58
+ @classmethod
59
+ def _endpoint_env(cls, v: str) -> str:
60
+ return _check_env_name(v, "endpoint_env")
61
+
62
+ @model_validator(mode="after")
63
+ def _normalize_azure(self) -> ModelEntry:
64
+ provider = (self.provider or "").lower()
65
+ if not self.model and self.deployment:
66
+ self.model = self.deployment
67
+ if not self.deployment and self.model and provider.startswith("azure"):
68
+ self.deployment = self.short_model()
69
+ if provider.startswith("azure") and not (self.deployment or self.model):
70
+ raise ValueError(
71
+ "Azure profiles need deployment (Azure deployment name) "
72
+ "and usually model (base model id, e.g. gpt-4o)."
73
+ )
74
+ if not self.model and not self.deployment:
75
+ raise ValueError("Each llm.json profile needs model and/or deployment.")
76
+ return self
77
+
78
+ @property
79
+ def display_name(self) -> str:
80
+ if self.label:
81
+ return self.label
82
+ provider = self.provider.lower()
83
+ if provider.startswith("azure"):
84
+ return f"azure/{self.deployment_name()}"
85
+ return f"{self.provider}/{self.short_model()}"
86
+
87
+ def resolve_api_key(self) -> str:
88
+ key = os.environ.get(self.api_key_env)
89
+ if not key:
90
+ raise ValueError(
91
+ f"Set {self.api_key_env} in the environment for model "
92
+ f"'{self.display_name}'. Keys are never stored in llm.json."
93
+ )
94
+ return key
95
+
96
+ def resolve_endpoint(self) -> str:
97
+ endpoint = self.endpoint or os.environ.get(self.endpoint_env)
98
+ if not endpoint:
99
+ raise ValueError(
100
+ f"Azure/custom endpoint missing for '{self.display_name}'. "
101
+ f"Set endpoint in llm.json or export {self.endpoint_env}."
102
+ )
103
+ return endpoint.rstrip("/")
104
+
105
+ def short_model(self) -> str:
106
+ m = self.model or self.deployment or ""
107
+ return m.split("/", 1)[-1] if "/" in m else m
108
+
109
+ def deployment_name(self) -> str:
110
+ dep = self.deployment or self.model or ""
111
+ return dep.split("/", 1)[-1] if "/" in dep else dep
112
+
113
+ def to_public_dict(self) -> dict[str, Any]:
114
+ data = self.model_dump(exclude_none=True)
115
+ data["api_key_set"] = bool(os.environ.get(self.api_key_env))
116
+ if self.provider.lower().startswith("azure") or self.endpoint:
117
+ data["endpoint_set"] = bool(
118
+ self.endpoint or os.environ.get(self.endpoint_env)
119
+ )
120
+ data["deployment"] = self.deployment_name()
121
+ return data
122
+
123
+
124
+ class LlmCatalog(BaseModel):
125
+ version: int = 1
126
+ models: dict[str, ModelEntry] = Field(default_factory=dict)
127
+
128
+ def get(self, name: str) -> ModelEntry:
129
+ if name not in self.models:
130
+ known = ", ".join(sorted(self.models)) or "(empty)"
131
+ raise KeyError(
132
+ f"Unknown LLM profile {name!r} in {LLM_CATALOG_FILENAME}. "
133
+ f"Known: {known}"
134
+ )
135
+ return self.models[name]
136
+
137
+ def names(self) -> list[str]:
138
+ return sorted(self.models.keys())
139
+
140
+
141
+ def catalog_path(
142
+ repo: Path | str | None = None, *, filename: str = LLM_CATALOG_FILENAME
143
+ ) -> Path:
144
+ base = Path(repo).expanduser().resolve() if repo else Path.cwd()
145
+ return base / filename
146
+
147
+
148
+ def load_catalog(
149
+ repo: Path | str | None = None,
150
+ *,
151
+ filename: str = LLM_CATALOG_FILENAME,
152
+ ) -> LlmCatalog:
153
+ path = catalog_path(repo, filename=filename)
154
+ if not path.exists():
155
+ raise FileNotFoundError(
156
+ f"No {filename} at {path}. Run `mobiflow init` or copy llm.json.example."
157
+ )
158
+ raw = json.loads(path.read_text(encoding="utf-8"))
159
+ if isinstance(raw, list):
160
+ models = {}
161
+ for item in raw:
162
+ mid = item.pop("id", None) or item.pop("name", None)
163
+ if not mid:
164
+ raise ValueError("Each llm.json list entry needs an id/name")
165
+ models[mid] = item
166
+ raw = {"version": 1, "models": models}
167
+ return LlmCatalog.model_validate(raw)
168
+
169
+
170
+ def save_catalog(catalog: LlmCatalog, repo: Path | str | None = None) -> Path:
171
+ path = catalog_path(repo)
172
+ path.parent.mkdir(parents=True, exist_ok=True)
173
+ payload = {
174
+ "version": catalog.version,
175
+ "models": {
176
+ name: entry.model_dump(mode="json", exclude_none=True)
177
+ for name, entry in catalog.models.items()
178
+ },
179
+ }
180
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
181
+ return path
182
+
183
+
184
+ def default_catalog_seed() -> LlmCatalog:
185
+ """Starter profiles — edit endpoints/models for your org."""
186
+ return LlmCatalog(
187
+ version=1,
188
+ models={
189
+ "openai-gpt4o": ModelEntry(
190
+ provider="openai",
191
+ model="gpt-4o",
192
+ api_key_env="OPENAI_API_KEY",
193
+ label="OpenAI GPT-4o",
194
+ ),
195
+ "azure-gpt4o": ModelEntry(
196
+ provider="azure",
197
+ model="gpt-4o",
198
+ deployment="gpt-4o",
199
+ api_key_env="AZURE_OPENAI_API_KEY",
200
+ endpoint_env="AZURE_OPENAI_ENDPOINT",
201
+ label="Azure OpenAI GPT-4o",
202
+ ),
203
+ "anthropic-sonnet": ModelEntry(
204
+ provider="anthropic",
205
+ model="claude-sonnet-4-6",
206
+ api_key_env="ANTHROPIC_API_KEY",
207
+ label="Anthropic Claude Sonnet",
208
+ ),
209
+ "google-gemini-flash": ModelEntry(
210
+ provider="google",
211
+ model="gemini-2.0-flash",
212
+ api_key_env="GOOGLE_API_KEY",
213
+ label="Google Gemini 2.0 Flash",
214
+ ),
215
+ },
216
+ )
217
+
218
+
219
+ def render_example_catalog() -> str:
220
+ return (
221
+ json.dumps(
222
+ {
223
+ "version": 1,
224
+ "models": {
225
+ name: e.model_dump(mode="json", exclude_none=True)
226
+ for name, e in default_catalog_seed().models.items()
227
+ },
228
+ },
229
+ indent=2,
230
+ )
231
+ + "\n"
232
+ )