@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.
- package/README.md +9 -11
- package/bin/mobiflow.js +94 -61
- package/package.json +8 -3
- package/pyproject.toml +59 -0
- package/src/mobiflow/__init__.py +9 -0
- package/src/mobiflow/__main__.py +6 -0
- package/src/mobiflow/baseline.py +228 -0
- package/src/mobiflow/casedata.py +159 -0
- package/src/mobiflow/cases/__init__.py +715 -0
- package/src/mobiflow/cli.py +1423 -0
- package/src/mobiflow/cloud/__init__.py +28 -0
- package/src/mobiflow/cloud/base.py +272 -0
- package/src/mobiflow/cloud/browserstack.py +330 -0
- package/src/mobiflow/cloud/maestro_cloud.py +141 -0
- package/src/mobiflow/cloud/media.py +269 -0
- package/src/mobiflow/cloud/runner.py +156 -0
- package/src/mobiflow/cloud/testmu.py +378 -0
- package/src/mobiflow/config/__init__.py +538 -0
- package/src/mobiflow/deps.py +377 -0
- package/src/mobiflow/devices.py +717 -0
- package/src/mobiflow/explore.py +623 -0
- package/src/mobiflow/incremental.py +198 -0
- package/src/mobiflow/init/__init__.py +794 -0
- package/src/mobiflow/llm.py +462 -0
- package/src/mobiflow/llm_catalog.py +232 -0
- package/src/mobiflow/maestro/__init__.py +1506 -0
- package/src/mobiflow/maestro/lifecycle.py +279 -0
- package/src/mobiflow/pipeline.py +600 -0
- package/src/mobiflow/report/__init__.py +617 -0
- package/src/mobiflow/report/static/favicon.jpg +0 -0
- package/src/mobiflow/report/static/favicon.svg +1 -0
- package/src/mobiflow/report/static/icons.svg +24 -0
- package/src/mobiflow/report/static/index.html +99 -0
- package/src/mobiflow/report/static/mobiflow-mark.jpg +0 -0
- package/src/mobiflow/reporting.py +682 -0
- package/src/mobiflow/sample_apps.py +259 -0
- package/src/mobiflow/secrets.py +90 -0
- package/src/mobiflow/selectors.py +128 -0
- 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
|
+
)
|