@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,538 @@
1
+ """Load and validate mobiflow.config.yaml. Secrets come from env vars only.
2
+
3
+ User-facing config:
4
+
5
+ project / llm / stack / run / device
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from enum import Enum
13
+ from pathlib import Path
14
+ from typing import Any, Optional
15
+
16
+ import yaml
17
+ from pydantic import BaseModel, Field, field_validator, model_validator
18
+
19
+ CONFIG_FILENAME = "mobiflow.config.yaml"
20
+
21
+ _ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
22
+
23
+
24
+ def _validate_env_var_name(value: str, field: str) -> str:
25
+ name = value.strip()
26
+ looks_like_secret = (
27
+ not _ENV_NAME_RE.match(name)
28
+ or len(name) > 64
29
+ or (len(name) >= 20 and any(c.islower() for c in name))
30
+ )
31
+ if looks_like_secret:
32
+ raise ValueError(
33
+ f"llm.{field} must be the NAME of an environment variable "
34
+ f"(e.g. AZURE_OPENAI_API_KEY), not a value. Got a {len(name)}-character "
35
+ "string that looks like a secret. Secrets never belong in "
36
+ f"{CONFIG_FILENAME} — export it in your shell instead."
37
+ )
38
+ return name
39
+
40
+
41
+ class _StrictLoader(yaml.SafeLoader):
42
+ """SafeLoader that rejects duplicate mapping keys."""
43
+
44
+
45
+ def _no_duplicate_keys(loader: _StrictLoader, node: yaml.MappingNode, deep: bool = False):
46
+ seen: set[Any] = set()
47
+ for key_node, _ in node.value:
48
+ key = loader.construct_object(key_node, deep=deep)
49
+ if key in seen:
50
+ raise yaml.YAMLError(
51
+ f"Duplicate key {key!r} at line {key_node.start_mark.line + 1} "
52
+ f"of {CONFIG_FILENAME}."
53
+ )
54
+ seen.add(key)
55
+ return yaml.SafeLoader.construct_mapping(loader, node, deep=deep)
56
+
57
+
58
+ _StrictLoader.add_constructor(
59
+ yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _no_duplicate_keys
60
+ )
61
+
62
+
63
+ class ProjectMode(str, Enum):
64
+ EXISTING = "existing"
65
+ LOCAL = "local"
66
+ NEW = "new"
67
+
68
+
69
+ class LlmConfig(BaseModel):
70
+ """Selects catalog profiles for discovery (adaptive explore) and codegen (YAML)."""
71
+
72
+ catalog: str = "llm.json"
73
+ discovery: str | None = None # adaptive plan / device explore
74
+ codegen: str | None = None # Maestro YAML authoring
75
+
76
+ # Legacy inline (used when discovery/codegen unset)
77
+ provider: str = "openai"
78
+ model: str | None = None
79
+ api_key_env: str = "MOBIFLOW_LLM_API_KEY"
80
+ endpoint: str | None = None
81
+ azure_endpoint: str | None = None
82
+ azure_endpoint_env: str = "AZURE_OPENAI_ENDPOINT"
83
+ azure_api_version: str = "2025-03-01-preview"
84
+
85
+ @field_validator("api_key_env")
86
+ @classmethod
87
+ def _check_api_key_env(cls, v: str) -> str:
88
+ return _validate_env_var_name(v, "api_key_env")
89
+
90
+ @field_validator("azure_endpoint_env")
91
+ @classmethod
92
+ def _check_azure_endpoint_env(cls, v: str) -> str:
93
+ return _validate_env_var_name(v, "azure_endpoint_env")
94
+
95
+ @model_validator(mode="after")
96
+ def _normalize(self) -> LlmConfig:
97
+ if self.endpoint and not self.azure_endpoint:
98
+ self.azure_endpoint = self.endpoint
99
+ if self.azure_endpoint and not self.endpoint:
100
+ self.endpoint = self.azure_endpoint
101
+ if self.discovery and not self.codegen:
102
+ self.codegen = self.discovery
103
+ if self.codegen and not self.discovery:
104
+ self.discovery = self.codegen
105
+ return self
106
+
107
+ @property
108
+ def uses_catalog(self) -> bool:
109
+ return bool(self.discovery or self.codegen)
110
+
111
+
112
+ class StackConfig(BaseModel):
113
+ tool: str = "maestro" # maestro
114
+ # yaml = YAML-only flows; yaml+js / javascript = Maestro YAML + JS (evalScript/runScript)
115
+ language: str = "yaml+js"
116
+ runner: str = "maestro" # maestro test
117
+ flow_dir: str = "flows"
118
+ cases_dir: str = "cases"
119
+ scripts_dir: str = "flows/scripts" # companion .js next to / under flows
120
+
121
+ def js_enabled(self) -> bool:
122
+ lang = (self.language or "yaml").strip().lower().replace(" ", "")
123
+ return lang in {"yaml+js", "yamljs", "js", "javascript", "maestro+js"}
124
+
125
+
126
+ class DeviceConfig(BaseModel):
127
+ """Local or cloud device lab settings.
128
+
129
+ ``provider``:
130
+ - ``local`` — adb / Android AVD / iOS Simulator (default)
131
+ - ``browserstack`` — BrowserStack App Automate Maestro REST API
132
+ - ``testmu`` — TestMu AI (formerly LambdaTest) HyperExecute Maestro
133
+ - ``maestro`` — first-party Maestro Cloud (`maestro cloud`)
134
+ """
135
+
136
+ provider: str = "local" # local | browserstack | testmu | maestro
137
+ platform: str = "android" # android | ios
138
+ app_id: str = ""
139
+ device_id: str | None = None # local serial/UDID OR cloud device name
140
+ auto_start: bool = True # local only: start AVD / iOS Simulator if none online
141
+ boot_timeout_s: int = 120 # wait for emulator/simulator boot
142
+ # Prefer `maestro start-device` / `maestro list-devices` before adb/simctl
143
+ use_maestro_cli: bool = True
144
+ # Optional Maestro start-device model/os (e.g. iPhone-16, pixel_7, iOS-18-2)
145
+ device_model: str = ""
146
+ device_os: str = ""
147
+ device_locale: str = ""
148
+
149
+ # Cloud labs (BrowserStack / TestMu / Maestro Cloud)
150
+ app_path: str = "" # local .apk / .ipa / .aab to upload
151
+ app_url: str = "" # already-uploaded bs://… or lt://…
152
+ cloud_project: str = "MobiFlow"
153
+ cloud_build_name: str = ""
154
+ real_mobile: bool = True # TestMu: real device vs virtual
155
+ username_env: str = "" # override default credential env var names
156
+ access_key_env: str = ""
157
+ cloud_timeout_s: int = 1800 # cloud build/job timeout
158
+ poll_interval_s: float = 15.0 # BrowserStack status poll
159
+ browserstack_local: bool = False # enable BS local testing flag
160
+
161
+ @field_validator("provider")
162
+ @classmethod
163
+ def _check_provider(cls, v: str) -> str:
164
+ from mobiflow.cloud.base import normalize_provider
165
+
166
+ return normalize_provider(v).value
167
+
168
+ def is_cloud(self) -> bool:
169
+ from mobiflow.cloud.base import is_cloud_provider
170
+
171
+ return is_cloud_provider(self.provider)
172
+
173
+
174
+ class RunConfig(BaseModel):
175
+ heal: int = 2 # YAML repair attempts after failed run
176
+ adaptive: bool = True # hierarchy-aware heal / explore on live device
177
+ explore: bool = True # explore app with discovery LLM before codegen
178
+ explore_steps: int = 5 # max live explore actions before codegen
179
+ timeout_s: int = 180 # maestro test timeout
180
+ save_artifacts: bool = True
181
+ # Local: run `maestro record --local` after test to capture MP4 (cloud labs use lab video)
182
+ video: bool = True
183
+ # Reporting: junit | html (comma-string or list). Empty / none disables.
184
+ reports: list[str] = Field(default_factory=lambda: ["junit", "html"])
185
+ report_dir: str = ".mobiflow/reports" # relative to project path
186
+ # Completeness foundations (suite / flake / reuse / secrets — wired in later phases)
187
+ retries: int = 0 # re-run same YAML on failure before heal
188
+ reuse_flow: bool = False # prefer flows/<case>.yaml over LLM codegen
189
+ incremental: bool = False # classify guidance; gap-explore appended steps
190
+ extend_explore: bool = False # full explore + extend codegen from prior YAML
191
+ fail_fast: bool = False # suite: stop after first failing case
192
+ jobs: int = 1 # suite parallelism (1 = sequential)
193
+ env: dict[str, str] = Field(default_factory=dict) # Maestro --env KEY=VALUE
194
+ # Lifecycle: install (local adb/simctl) and/or clear (Maestro clearState)
195
+ preflight: list[str] = Field(default_factory=list)
196
+ # Maestro workspace / suite filters (passed to `maestro test`)
197
+ include_tags: list[str] = Field(default_factory=list)
198
+ exclude_tags: list[str] = Field(default_factory=list)
199
+ maestro_config: str = "" # path to Maestro config.yaml (optional)
200
+
201
+ @field_validator("reports", mode="before")
202
+ @classmethod
203
+ def _coerce_reports(cls, v: Any) -> list[str]:
204
+ from mobiflow.reporting import normalize_report_formats
205
+
206
+ return normalize_report_formats(v)
207
+
208
+ @field_validator("preflight", mode="before")
209
+ @classmethod
210
+ def _coerce_preflight(cls, v: Any) -> list[str]:
211
+ from mobiflow.maestro.lifecycle import normalize_preflight
212
+
213
+ return normalize_preflight(v)
214
+
215
+ @field_validator("explore_steps")
216
+ @classmethod
217
+ def _clamp_explore_steps(cls, v: int) -> int:
218
+ try:
219
+ n = int(v)
220
+ except (TypeError, ValueError):
221
+ return 5
222
+ return max(1, min(n, 12))
223
+
224
+ @field_validator("retries")
225
+ @classmethod
226
+ def _clamp_retries(cls, v: int) -> int:
227
+ try:
228
+ n = int(v)
229
+ except (TypeError, ValueError):
230
+ return 0
231
+ return max(0, min(n, 10))
232
+
233
+ @field_validator("jobs")
234
+ @classmethod
235
+ def _clamp_jobs(cls, v: int) -> int:
236
+ try:
237
+ n = int(v)
238
+ except (TypeError, ValueError):
239
+ return 1
240
+ return max(1, min(n, 32))
241
+
242
+ @field_validator("env", mode="before")
243
+ @classmethod
244
+ def _coerce_env(cls, v: Any) -> dict[str, str]:
245
+ if v is None:
246
+ return {}
247
+ if not isinstance(v, dict):
248
+ raise ValueError("run.env must be a mapping of KEY: VALUE")
249
+ out: dict[str, str] = {}
250
+ for key, val in v.items():
251
+ k = str(key).strip()
252
+ if not k:
253
+ continue
254
+ out[k] = "" if val is None else str(val)
255
+ return out
256
+
257
+ @field_validator("include_tags", "exclude_tags", mode="before")
258
+ @classmethod
259
+ def _coerce_tags(cls, v: Any) -> list[str]:
260
+ if v is None or v == "":
261
+ return []
262
+ if isinstance(v, str):
263
+ parts = [p.strip() for p in v.replace(";", ",").split(",")]
264
+ return [p for p in parts if p]
265
+ if isinstance(v, (list, tuple, set)):
266
+ return [str(x).strip() for x in v if str(x).strip()]
267
+ return [str(v).strip()] if str(v).strip() else []
268
+
269
+
270
+ class ProjectConfig(BaseModel):
271
+ mode: ProjectMode = ProjectMode.LOCAL
272
+ path: str = "."
273
+
274
+
275
+ class MobiflowConfig(BaseModel):
276
+ project: ProjectConfig = Field(default_factory=ProjectConfig)
277
+ llm: LlmConfig = Field(default_factory=LlmConfig)
278
+ stack: StackConfig = Field(default_factory=StackConfig)
279
+ device: DeviceConfig = Field(default_factory=DeviceConfig)
280
+ run: RunConfig = Field(default_factory=RunConfig)
281
+
282
+ def repo_path(self) -> Path:
283
+ return Path(self.project.path).expanduser().resolve()
284
+
285
+ def flow_dir_path(self) -> Path:
286
+ return (self.repo_path() / self.stack.flow_dir).resolve()
287
+
288
+ def scripts_dir_path(self) -> Path:
289
+ raw = (self.stack.scripts_dir or "flows/scripts").strip() or "flows/scripts"
290
+ p = Path(raw).expanduser()
291
+ if p.is_absolute():
292
+ return p.resolve()
293
+ return (self.repo_path() / p).resolve()
294
+
295
+ def cases_dir_path(self) -> Path:
296
+ return (self.repo_path() / self.stack.cases_dir).resolve()
297
+
298
+ def artifacts_dir(self) -> Path:
299
+ return (self.repo_path() / ".mobiflow").resolve()
300
+
301
+ def report_dir_path(self) -> Path:
302
+ raw = (self.run.report_dir or ".mobiflow/reports").strip() or ".mobiflow/reports"
303
+ p = Path(raw).expanduser()
304
+ if p.is_absolute():
305
+ return p.resolve()
306
+ return (self.repo_path() / p).resolve()
307
+
308
+ def catalog_file(self) -> Path:
309
+ name = self.llm.catalog or "llm.json"
310
+ return self.repo_path() / name
311
+
312
+ def load_catalog(self):
313
+ from mobiflow.llm_catalog import ModelEntry, load_catalog
314
+
315
+ path = self.catalog_file()
316
+ if path.exists():
317
+ return load_catalog(self.repo_path(), filename=path.name)
318
+ # synthesize one-entry catalog from legacy inline
319
+ from mobiflow.llm_catalog import LlmCatalog
320
+
321
+ entry = ModelEntry(
322
+ provider=self.llm.provider,
323
+ model=self.llm.model or "gpt-4o",
324
+ api_key_env=self.llm.api_key_env,
325
+ endpoint=self.llm.endpoint or self.llm.azure_endpoint,
326
+ endpoint_env=self.llm.azure_endpoint_env,
327
+ api_version=self.llm.azure_api_version,
328
+ )
329
+ return LlmCatalog(models={"default": entry})
330
+
331
+ def discovery_profile(self):
332
+ from mobiflow.llm_catalog import ModelEntry
333
+
334
+ cat = self.load_catalog()
335
+ name = self.llm.discovery
336
+ if name:
337
+ return cat.get(name)
338
+ if "default" in cat.models:
339
+ return cat.get("default")
340
+ if cat.models:
341
+ return cat.get(next(iter(cat.models)))
342
+ return ModelEntry(
343
+ provider=self.llm.provider,
344
+ model=self.llm.model or "gpt-4o",
345
+ api_key_env=self.llm.api_key_env,
346
+ endpoint=self.llm.endpoint or self.llm.azure_endpoint,
347
+ endpoint_env=self.llm.azure_endpoint_env,
348
+ api_version=self.llm.azure_api_version,
349
+ )
350
+
351
+ def codegen_profile(self):
352
+ cat = self.load_catalog()
353
+ name = self.llm.codegen or self.llm.discovery
354
+ if name:
355
+ return cat.get(name)
356
+ return self.discovery_profile()
357
+
358
+
359
+ def config_path(repo: Path | str | None = None) -> Path:
360
+ base = Path(repo).expanduser().resolve() if repo else Path.cwd()
361
+ return base / CONFIG_FILENAME
362
+
363
+
364
+ def load_config(repo: Path | str | None = None) -> MobiflowConfig:
365
+ path = config_path(repo)
366
+ if not path.exists():
367
+ cwd_path = Path.cwd() / CONFIG_FILENAME
368
+ if repo is None and cwd_path.exists():
369
+ path = cwd_path
370
+ else:
371
+ raise FileNotFoundError(
372
+ f"No {CONFIG_FILENAME} found at {path}. Run `mobiflow init` first."
373
+ )
374
+ data = yaml.load(path.read_text(), Loader=_StrictLoader) or {}
375
+ return MobiflowConfig.model_validate(data)
376
+
377
+
378
+ def render_simple_config(config: MobiflowConfig) -> str:
379
+ llm = config.llm
380
+ stack = config.stack
381
+ run = config.run
382
+ device = config.device
383
+ discovery = llm.discovery or "default"
384
+ codegen = llm.codegen or discovery
385
+ empty = '""'
386
+
387
+ lines = [
388
+ "# mobiflow.config.yaml — pick models from llm.json",
389
+ "# Secrets stay in env vars (never paste API keys here).",
390
+ "# Docs: MOBIFLOW.md",
391
+ "",
392
+ "project:",
393
+ f" mode: {config.project.mode.value}",
394
+ f" path: {config.project.path}",
395
+ "",
396
+ "llm:",
397
+ f" catalog: {llm.catalog or 'llm.json'} # model catalog file",
398
+ f" discovery: {discovery} # adaptive explore / plan on device",
399
+ f" codegen: {codegen} # Maestro YAML authoring",
400
+ "",
401
+ "stack:",
402
+ f" tool: {stack.tool}",
403
+ f" language: {stack.language} # yaml | yaml+js (Maestro JS via evalScript/runScript)",
404
+ f" runner: {stack.runner}",
405
+ f" flow_dir: {stack.flow_dir}",
406
+ f" scripts_dir: {stack.scripts_dir} # companion .js files",
407
+ f" cases_dir: {stack.cases_dir}",
408
+ "",
409
+ "device:",
410
+ f" provider: {device.provider} # local | browserstack | testmu | maestro",
411
+ f" platform: {device.platform}",
412
+ f" app_id: {device.app_id or empty} # e.g. org.wikipedia / org.wikimedia.wikipedia",
413
+ f" device_id: {device.device_id or empty} # local serial/UDID OR cloud device (e.g. Google Pixel 7-13.0)",
414
+ f" auto_start: {str(device.auto_start).lower()} # local only: start AVD / Xcode sim if none online",
415
+ f" use_maestro_cli: {str(device.use_maestro_cli).lower()} # prefer maestro start-device",
416
+ f" device_model: {device.device_model or empty}",
417
+ f" device_os: {device.device_os or empty}",
418
+ f" device_locale: {device.device_locale or empty}",
419
+ f" boot_timeout_s: {device.boot_timeout_s}",
420
+ f" app_path: {device.app_path or empty} # cloud: path to .apk / .ipa to upload",
421
+ f" app_url: {device.app_url or empty} # cloud: existing bs://… or lt://… (skip upload)",
422
+ f" cloud_project: {device.cloud_project or 'MobiFlow'}",
423
+ f" cloud_build_name: {device.cloud_build_name or empty}",
424
+ f" real_mobile: {str(device.real_mobile).lower()} # testmu: real device vs emulator/simulator",
425
+ f" username_env: {device.username_env or empty} # optional override (defaults by provider)",
426
+ f" access_key_env: {device.access_key_env or empty}",
427
+ f" cloud_timeout_s: {device.cloud_timeout_s}",
428
+ "",
429
+ "run:",
430
+ f" heal: {run.heal} # YAML repair attempts (0 = off)",
431
+ f" adaptive: {str(run.adaptive).lower()} # hierarchy-aware heal on live device",
432
+ f" explore: {str(run.explore).lower()} # discovery LLM explores app before codegen",
433
+ f" explore_steps: {run.explore_steps} # max live explore actions",
434
+ f" timeout_s: {run.timeout_s}",
435
+ f" save_artifacts: {str(run.save_artifacts).lower()}",
436
+ f" video: {str(run.video).lower()} # local: maestro record --local after test",
437
+ f" reports: [{', '.join(run.reports) if run.reports else ''}] # junit, html — empty disables",
438
+ f" report_dir: {run.report_dir}",
439
+ f" retries: {run.retries} # re-run same YAML before heal (flake control)",
440
+ f" reuse_flow: {str(run.reuse_flow).lower()} # use flows/<case>.yaml instead of LLM",
441
+ f" incremental: {str(run.incremental).lower()} # gap-explore appended numbered steps",
442
+ f" extend_explore: {str(run.extend_explore).lower()} # seed codegen from prior YAML",
443
+ f" fail_fast: {str(run.fail_fast).lower()} # suite: stop on first failure",
444
+ f" jobs: {run.jobs} # suite parallelism (1 = sequential)",
445
+ f" preflight: [{', '.join(run.preflight) if run.preflight else ''}] # install, clear",
446
+ f" include_tags: [{', '.join(run.include_tags) if run.include_tags else ''}]",
447
+ f" exclude_tags: [{', '.join(run.exclude_tags) if run.exclude_tags else ''}]",
448
+ f" maestro_config: {run.maestro_config or empty}",
449
+ "",
450
+ "# Edit llm.json to add Azure / OpenAI / Anthropic / Google models.",
451
+ "# Then change discovery: / codegen: above to the profile ids you want.",
452
+ "",
453
+ ]
454
+ return "\n".join(lines)
455
+
456
+
457
+ def save_config(config: MobiflowConfig, repo: Path | str | None = None) -> Path:
458
+ path = config_path(repo or config.repo_path())
459
+ path.parent.mkdir(parents=True, exist_ok=True)
460
+ path.write_text(render_simple_config(config), encoding="utf-8")
461
+ return path
462
+
463
+
464
+ def find_config(start: Path | None = None) -> Path | None:
465
+ cur = (start or Path.cwd()).resolve()
466
+ for candidate in [cur, *cur.parents]:
467
+ p = candidate / CONFIG_FILENAME
468
+ if p.exists():
469
+ return p
470
+ return None
471
+
472
+
473
+ def config_warnings(config: MobiflowConfig) -> list[str]:
474
+ out: list[str] = []
475
+ try:
476
+ disc = config.discovery_profile()
477
+ if not os.environ.get(disc.api_key_env):
478
+ out.append(
479
+ f"discovery: ${disc.api_key_env} is not set ({disc.display_name})."
480
+ )
481
+ if disc.provider.lower().startswith("azure"):
482
+ if not (disc.endpoint or os.environ.get(disc.endpoint_env)):
483
+ out.append(
484
+ f"discovery Azure endpoint missing "
485
+ f"(llm.json endpoint or ${disc.endpoint_env})."
486
+ )
487
+ except Exception as e: # noqa: BLE001
488
+ out.append(f"discovery LLM: {e}")
489
+
490
+ try:
491
+ code = config.codegen_profile()
492
+ if code.api_key_env != getattr(config.discovery_profile(), "api_key_env", None):
493
+ if not os.environ.get(code.api_key_env):
494
+ out.append(
495
+ f"codegen: ${code.api_key_env} is not set ({code.display_name})."
496
+ )
497
+ except Exception as e: # noqa: BLE001
498
+ out.append(f"codegen LLM: {e}")
499
+
500
+ if not config.catalog_file().exists() and not config.llm.uses_catalog:
501
+ out.append(
502
+ f"No {config.llm.catalog} found — using legacy inline llm settings. "
503
+ "Run mobiflow init or add llm.json for multi-model support."
504
+ )
505
+
506
+ if config.device.is_cloud():
507
+ try:
508
+ from mobiflow.cloud import cloud_readiness
509
+
510
+ ready = cloud_readiness(config.device)
511
+ if not ready.get("ready"):
512
+ out.append(ready.get("message") or "Cloud device lab not ready.")
513
+ except Exception as e: # noqa: BLE001
514
+ out.append(f"cloud device: {e}")
515
+ return out
516
+
517
+
518
+ def effective_config_dict(config: MobiflowConfig) -> dict[str, Any]:
519
+ data: dict[str, Any] = {
520
+ "project": config.project.model_dump(mode="json"),
521
+ "llm": {
522
+ "catalog": config.llm.catalog,
523
+ "discovery": config.llm.discovery,
524
+ "codegen": config.llm.codegen,
525
+ },
526
+ "stack": config.stack.model_dump(mode="json"),
527
+ "device": config.device.model_dump(mode="json"),
528
+ "run": config.run.model_dump(mode="json"),
529
+ }
530
+ try:
531
+ data["llm"]["discovery_profile"] = config.discovery_profile().to_public_dict()
532
+ except Exception as e: # noqa: BLE001
533
+ data["llm"]["discovery_error"] = str(e)
534
+ try:
535
+ data["llm"]["codegen_profile"] = config.codegen_profile().to_public_dict()
536
+ except Exception as e: # noqa: BLE001
537
+ data["llm"]["codegen_error"] = str(e)
538
+ return data