code-mower 0.5.0b5__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.
Files changed (185) hide show
  1. code_mower/__init__.py +3 -0
  2. code_mower/adapters/__init__.py +39 -0
  3. code_mower/adapters/_base.py +148 -0
  4. code_mower/adapters/cursor_bugbot.py +97 -0
  5. code_mower/adapters/gitar.py +111 -0
  6. code_mower/adapters/greptile.py +191 -0
  7. code_mower/adapters/qodo.py +140 -0
  8. code_mower/antigravity_cli_audit_pr.py +241 -0
  9. code_mower/audit_handoff_log.py +345 -0
  10. code_mower/audit_labeler_lib.py +311 -0
  11. code_mower/audit_progress.py +203 -0
  12. code_mower/blind_review_artifacts.py +562 -0
  13. code_mower/blind_review_coordinator.py +276 -0
  14. code_mower/bootstrap.py +524 -0
  15. code_mower/builder_experiment.py +539 -0
  16. code_mower/calibration/__init__.py +181 -0
  17. code_mower/calibration/arms.py +267 -0
  18. code_mower/calibration/auto_discovery.py +348 -0
  19. code_mower/calibration/commands.py +192 -0
  20. code_mower/calibration/context_inputs.py +199 -0
  21. code_mower/calibration/corpus.py +91 -0
  22. code_mower/calibration/evidence.py +20 -0
  23. code_mower/calibration/evidence_report.py +360 -0
  24. code_mower/calibration/identity.py +27 -0
  25. code_mower/calibration/metrics.py +18 -0
  26. code_mower/calibration/overlap.py +103 -0
  27. code_mower/calibration/planning.py +309 -0
  28. code_mower/calibration/policy.py +207 -0
  29. code_mower/calibration/results.py +315 -0
  30. code_mower/calibration/run_results.py +147 -0
  31. code_mower/calibration/run_status.py +64 -0
  32. code_mower/calibration/runner.py +360 -0
  33. code_mower/calibration/truth.py +188 -0
  34. code_mower/calibration/value_report.py +142 -0
  35. code_mower/checks.py +402 -0
  36. code_mower/claude_audit_pr.py +1126 -0
  37. code_mower/claude_cli_bounce.py +303 -0
  38. code_mower/claude_cli_environment.py +73 -0
  39. code_mower/clear_stale.py +374 -0
  40. code_mower/cli.py +537 -0
  41. code_mower/cloud.py +674 -0
  42. code_mower/cloud_client/__init__.py +173 -0
  43. code_mower/cloud_client/bundle.py +155 -0
  44. code_mower/cloud_client/doctor.py +206 -0
  45. code_mower/cloud_client/dogfood.py +78 -0
  46. code_mower/cloud_client/endpoints.py +114 -0
  47. code_mower/cloud_client/errors.py +7 -0
  48. code_mower/cloud_client/events.py +278 -0
  49. code_mower/cloud_client/export.py +272 -0
  50. code_mower/cloud_client/git_metadata.py +46 -0
  51. code_mower/cloud_client/manifest.py +39 -0
  52. code_mower/cloud_client/operations.py +448 -0
  53. code_mower/cloud_client/reports.py +45 -0
  54. code_mower/cloud_client/setup.py +205 -0
  55. code_mower/cloud_client/upload.py +97 -0
  56. code_mower/code_mower_calibration.py +598 -0
  57. code_mower/code_mower_context_packs.py +591 -0
  58. code_mower/code_mower_merge.py +227 -0
  59. code_mower/code_mower_telemetry.py +561 -0
  60. code_mower/coderabbit_cli_audit_pr.py +526 -0
  61. code_mower/codex_audit_env_preflight.py +220 -0
  62. code_mower/codex_audit_pr.py +1738 -0
  63. code_mower/codex_audit_schema_smoke.py +160 -0
  64. code_mower/codex_audit_verdict.schema.json +44 -0
  65. code_mower/config.py +655 -0
  66. code_mower/doctor.py +161 -0
  67. code_mower/doctor_checks/__init__.py +104 -0
  68. code_mower/doctor_checks/cloud.py +129 -0
  69. code_mower/doctor_checks/common.py +215 -0
  70. code_mower/doctor_checks/github.py +128 -0
  71. code_mower/doctor_checks/github_actions.py +11 -0
  72. code_mower/doctor_checks/github_actions_cost.py +99 -0
  73. code_mower/doctor_checks/github_actions_cost_summary.py +111 -0
  74. code_mower/doctor_checks/github_actions_failure_annotations.py +27 -0
  75. code_mower/doctor_checks/github_actions_failure_models.py +47 -0
  76. code_mower/doctor_checks/github_actions_failure_scan.py +200 -0
  77. code_mower/doctor_checks/github_actions_failure_selection.py +63 -0
  78. code_mower/doctor_checks/github_actions_failures.py +103 -0
  79. code_mower/doctor_checks/github_actions_permissions.py +55 -0
  80. code_mower/doctor_checks/github_api.py +79 -0
  81. code_mower/doctor_checks/github_branch.py +56 -0
  82. code_mower/doctor_checks/github_config.py +25 -0
  83. code_mower/doctor_checks/github_provider.py +61 -0
  84. code_mower/doctor_checks/github_repo.py +120 -0
  85. code_mower/doctor_checks/groups.py +36 -0
  86. code_mower/doctor_checks/models.py +96 -0
  87. code_mower/doctor_checks/output.py +86 -0
  88. code_mower/doctor_checks/presets.py +64 -0
  89. code_mower/doctor_checks/privacy.py +20 -0
  90. code_mower/doctor_checks/provider_api_model.py +138 -0
  91. code_mower/doctor_checks/provider_api_model_openai.py +29 -0
  92. code_mower/doctor_checks/provider_api_model_profiles.py +137 -0
  93. code_mower/doctor_checks/provider_env.py +113 -0
  94. code_mower/doctor_checks/provider_env_required.py +56 -0
  95. code_mower/doctor_checks/provider_env_tokens.py +100 -0
  96. code_mower/doctor_checks/provider_local_cli.py +162 -0
  97. code_mower/doctor_checks/provider_local_cli_commands.py +47 -0
  98. code_mower/doctor_checks/provider_local_cli_probe_config.py +70 -0
  99. code_mower/doctor_checks/provider_probe.py +20 -0
  100. code_mower/doctor_checks/provider_probe_auth.py +52 -0
  101. code_mower/doctor_checks/provider_probe_evaluation.py +109 -0
  102. code_mower/doctor_checks/provider_probe_json.py +45 -0
  103. code_mower/doctor_checks/provider_probe_remediation.py +39 -0
  104. code_mower/doctor_checks/providers.py +159 -0
  105. code_mower/doctor_checks/registry.py +69 -0
  106. code_mower/doctor_checks/runner.py +188 -0
  107. code_mower/doctor_checks/runtime.py +89 -0
  108. code_mower/doctor_checks/runtime_github_auth.py +148 -0
  109. code_mower/gemini_cli_audit_pr.py +897 -0
  110. code_mower/hermes_cli_audit_pr.py +436 -0
  111. code_mower/init.py +888 -0
  112. code_mower/lane_configs/__init__.py +37 -0
  113. code_mower/lane_configs/aider.py +32 -0
  114. code_mower/lane_configs/antigravity_cli.py +35 -0
  115. code_mower/lane_configs/claude.py +35 -0
  116. code_mower/lane_configs/codex.py +32 -0
  117. code_mower/lane_configs/devin.py +33 -0
  118. code_mower/lane_configs/gemini_cli.py +35 -0
  119. code_mower/lane_configs/hermes_cli.py +35 -0
  120. code_mower/lane_configs/local_llm.py +31 -0
  121. code_mower/local_llm_audit_pr.py +1364 -0
  122. code_mower/local_llm_bakeoff.py +458 -0
  123. code_mower/local_llm_calibration.py +441 -0
  124. code_mower/local_llm_profiles.py +66 -0
  125. code_mower/migration.py +508 -0
  126. code_mower/migration_install.py +292 -0
  127. code_mower/migration_mirror.py +392 -0
  128. code_mower/migration_readiness.py +237 -0
  129. code_mower/migration_rehearsal.py +718 -0
  130. code_mower/next_steps.py +441 -0
  131. code_mower/package.py +673 -0
  132. code_mower/package_content.py +444 -0
  133. code_mower/package_manifest.py +452 -0
  134. code_mower/package_paths.py +53 -0
  135. code_mower/package_rendering.py +90 -0
  136. code_mower/package_static.py +585 -0
  137. code_mower/prompts.py +267 -0
  138. code_mower/provider_registry.py +469 -0
  139. code_mower/provider_runners/__init__.py +60 -0
  140. code_mower/provider_runners/comments.py +31 -0
  141. code_mower/provider_runners/git.py +46 -0
  142. code_mower/provider_runners/github_auth.py +61 -0
  143. code_mower/provider_runners/github_pr.py +120 -0
  144. code_mower/provider_runners/process.py +58 -0
  145. code_mower/provider_runners/repo_paths.py +23 -0
  146. code_mower/provider_runners/text_schema.py +41 -0
  147. code_mower/provider_runners/verdict_artifacts.py +103 -0
  148. code_mower/provider_runners/workspace.py +57 -0
  149. code_mower/release_readiness.py +549 -0
  150. code_mower/reviewer_metrics.py +389 -0
  151. code_mower/saas_reviewer_labeler.py +809 -0
  152. code_mower/secrets.py +89 -0
  153. code_mower/templates/builder-experiment.example.json +55 -0
  154. code_mower/templates/calibration-corpus.example.json +129 -0
  155. code_mower/templates/calibration-corpus.json +129 -0
  156. code_mower/templates/code-mower.example.yml +423 -0
  157. code_mower/templates/context-packs.example.json +150 -0
  158. code_mower/templates/lane_prompts/base-audit.md +22 -0
  159. code_mower/templates/lane_prompts/calibration-policy.md +21 -0
  160. code_mower/templates/lane_prompts/context-driven-quality.md +21 -0
  161. code_mower/templates/lane_prompts/docs-design.md +12 -0
  162. code_mower/templates/lane_prompts/generic-programming.md +21 -0
  163. code_mower/templates/lane_prompts/operability.md +22 -0
  164. code_mower/templates/lane_prompts/package-runtime.md +12 -0
  165. code_mower/templates/lane_prompts/security-threat-model.md +22 -0
  166. code_mower/templates/product-support/code_mower +216 -0
  167. code_mower/templates/product-support/code_mower_standalone_pin.env +7 -0
  168. code_mower/templates/product-support/code_mower_standalone_shadow.sh +151 -0
  169. code_mower/templates/product-support/run_claude_audit_pr.sh +32 -0
  170. code_mower/templates/product-support/run_codex_audit_pr.sh +32 -0
  171. code_mower/templates/product-support/safe_gh_comment.py +96 -0
  172. code_mower/templates/providers.yml +454 -0
  173. code_mower/templates/reviewer-spend.example.json +28 -0
  174. code_mower/templates/reviewer-value-report.example.md +20 -0
  175. code_mower/templates/workflows/private-standalone-shadow.yml.j2 +106 -0
  176. code_mower/templates/workflows/review-clear-stale.yml.j2 +83 -0
  177. code_mower/trailer_comment_labeler.py +207 -0
  178. code_mower/versioning.py +32 -0
  179. code_mower-0.5.0b5.dist-info/METADATA +302 -0
  180. code_mower-0.5.0b5.dist-info/RECORD +185 -0
  181. code_mower-0.5.0b5.dist-info/WHEEL +5 -0
  182. code_mower-0.5.0b5.dist-info/entry_points.txt +2 -0
  183. code_mower-0.5.0b5.dist-info/licenses/LICENSE +202 -0
  184. code_mower-0.5.0b5.dist-info/licenses/NOTICE +10 -0
  185. code_mower-0.5.0b5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,86 @@
1
+ """Human-readable doctor output rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict
6
+ from collections.abc import Iterable
7
+
8
+ from .groups import GROUP_LABELS, doctor_check_group_id
9
+ from .models import STATUS_FAIL, STATUS_SKIP, STATUS_WARN, DoctorCheck
10
+ from .models import DoctorReport
11
+
12
+
13
+ def doctor_output_group(check: DoctorCheck) -> str:
14
+ """Return the human-output group for a doctor check."""
15
+
16
+ return doctor_check_group_id(check.name, check.lane)
17
+
18
+
19
+ def _group_checks(checks: Iterable[DoctorCheck]) -> OrderedDict[str, list[DoctorCheck]]:
20
+ grouped: OrderedDict[str, list[DoctorCheck]] = OrderedDict(
21
+ (group_id, []) for group_id in GROUP_LABELS
22
+ )
23
+ for check in checks:
24
+ grouped.setdefault(doctor_output_group(check), []).append(check)
25
+ return grouped
26
+
27
+
28
+ def _format_status_summary(report: DoctorReport) -> str:
29
+ parts = [f"{len(report.checks)} total"]
30
+ if report.failures:
31
+ parts.append(f"{report.failures} failed")
32
+ if report.warnings:
33
+ parts.append(f"{report.warnings} warnings")
34
+ skipped = sum(1 for check in report.checks if check.status == STATUS_SKIP)
35
+ if skipped:
36
+ parts.append(f"{skipped} skipped")
37
+ if len(parts) == 1:
38
+ parts.append("all passing")
39
+ return ", ".join(parts)
40
+
41
+
42
+ def _format_check(check: DoctorCheck) -> list[str]:
43
+ lane = f" [{check.lane}]" if check.lane else ""
44
+ lines = [f"- {check.status.upper()} {check.name}{lane}: {check.message}"]
45
+ if check.remediation:
46
+ lines.append(f" remediation: {check.remediation}")
47
+ return lines
48
+
49
+
50
+ def render_doctor_text(report: DoctorReport) -> str:
51
+ """Render a doctor report for terminal output."""
52
+ lines = [
53
+ "Code Mower doctor",
54
+ f"Status: {report.status}",
55
+ f"Config: {report.config_path}",
56
+ f"Provider templates: {report.provider_templates_path}",
57
+ ]
58
+ if report.profile:
59
+ lines.append(f"Profile: {report.profile}")
60
+ lines.append(f"Checks: {_format_status_summary(report)}")
61
+ lines.append("")
62
+
63
+ if not report.checks:
64
+ lines.append("No checks ran.")
65
+ return "\n".join(lines) + "\n"
66
+
67
+ for group_id, checks in _group_checks(report.checks).items():
68
+ if not checks:
69
+ continue
70
+ failed = sum(1 for check in checks if check.status == STATUS_FAIL)
71
+ warnings = sum(1 for check in checks if check.status == STATUS_WARN)
72
+ summary = []
73
+ if failed:
74
+ summary.append(f"{failed} failed")
75
+ if warnings:
76
+ summary.append(f"{warnings} warnings")
77
+ heading = GROUP_LABELS.get(group_id, group_id.title())
78
+ if summary:
79
+ heading = f"{heading} ({', '.join(summary)})"
80
+ lines.append(heading)
81
+ for check in checks:
82
+ lines.extend(_format_check(check))
83
+ lines.append("")
84
+ if lines[-1] == "":
85
+ lines.pop()
86
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,64 @@
1
+ """First-run doctor presets and package-aware path resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from argparse import Namespace
6
+ from pathlib import Path
7
+
8
+ from code_mower import package as code_mower_package
9
+
10
+
11
+ def resolve_doctor_config_path_for_script(
12
+ config_arg: str,
13
+ *,
14
+ easy: bool = False,
15
+ script_path: Path,
16
+ ) -> Path:
17
+ path = Path(config_arg)
18
+ if path.is_file() or config_arg != "code-mower.yml" or not easy:
19
+ return path
20
+
21
+ script_path = script_path.resolve()
22
+ candidates = [
23
+ script_path.parent / "templates" / "code-mower.example.yml",
24
+ script_path.parent.parent / "templates" / "code-mower.example.yml",
25
+ script_path.parents[1] / "code-mower.example.yml",
26
+ ]
27
+
28
+ for candidate in candidates:
29
+ if candidate.is_file():
30
+ return candidate
31
+ return path
32
+
33
+
34
+ def resolve_doctor_config_path(
35
+ config_arg: str,
36
+ *,
37
+ easy: bool = False,
38
+ script_path: Path | None = None,
39
+ ) -> Path:
40
+ return resolve_doctor_config_path_for_script(
41
+ config_arg,
42
+ easy=easy,
43
+ script_path=script_path or Path(__file__),
44
+ )
45
+
46
+
47
+ def resolve_doctor_provider_templates_path(path_text: str) -> Path:
48
+ path = Path(path_text)
49
+ if path_text == code_mower_package.DEFAULT_PROVIDER_TEMPLATES and not path.is_absolute():
50
+ project_catalog = Path.cwd() / code_mower_package.DEFAULT_PROVIDER_TEMPLATES
51
+ if project_catalog.exists():
52
+ return project_catalog
53
+ return code_mower_package.resolve_provider_templates_path(path_text)
54
+
55
+
56
+ def apply_first_run_defaults(args: Namespace) -> None:
57
+ if not (getattr(args, "v05", False) or getattr(args, "preflight", False)):
58
+ return
59
+ args.easy = True
60
+ if args.profile is None:
61
+ args.profile = "recommended"
62
+ args.probe_runtime = True
63
+ args.github = True
64
+ args.cloud = True
@@ -0,0 +1,20 @@
1
+ """Privacy-preserving doctor output helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def auth_probe_output_detail(output: str) -> dict[str, Any]:
9
+ """Return non-content diagnostics for auth probes.
10
+
11
+ Doctor probes sometimes receive provider auth output containing account
12
+ names, emails, scopes, or token-adjacent diagnostics. Keep only shape
13
+ metadata so reports can explain that output existed without storing it.
14
+ """
15
+
16
+ text = output.strip()
17
+ return {
18
+ "output_redacted": bool(text),
19
+ "output_line_count": len(text.splitlines()) if text else 0,
20
+ }
@@ -0,0 +1,138 @@
1
+ """Provider API-model profile checks and probe orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ from typing import Any, Mapping
8
+
9
+ from .common import (
10
+ DoctorCheck,
11
+ STATUS_FAIL,
12
+ STATUS_PASS,
13
+ STATUS_SKIP,
14
+ STATUS_WARN,
15
+ )
16
+ from .provider_api_model_openai import fetch_openai_compatible_models
17
+ from .provider_api_model_profiles import (
18
+ api_model_key,
19
+ local_llm_lane_profiles,
20
+ runtime_profile,
21
+ )
22
+
23
+
24
+ def check_api_model(
25
+ lane_id: str,
26
+ lane: Mapping[str, Any],
27
+ *,
28
+ probe_runtime: bool,
29
+ http_timeout: int,
30
+ ) -> list[DoctorCheck]:
31
+ checks: list[DoctorCheck] = []
32
+ try:
33
+ profiles = local_llm_lane_profiles(lane)
34
+ except (TypeError, ValueError) as exc:
35
+ checks.append(
36
+ DoctorCheck(
37
+ name="runtime.api_model.profiles",
38
+ status=STATUS_FAIL,
39
+ lane=lane_id,
40
+ message=str(exc),
41
+ remediation=(
42
+ "Fix provider_config.profiles in code-mower.yml or use a "
43
+ "known packaged local LLM profile id."
44
+ ),
45
+ )
46
+ )
47
+ return checks
48
+ if not profiles:
49
+ checks.append(
50
+ DoctorCheck(
51
+ name="runtime.api_model.profiles",
52
+ status=STATUS_WARN,
53
+ lane=lane_id,
54
+ message="no API model profiles configured",
55
+ remediation=(
56
+ "Add provider_config.profiles to this lane or select a "
57
+ "packaged local LLM profile."
58
+ ),
59
+ )
60
+ )
61
+ return checks
62
+
63
+ checks.append(
64
+ DoctorCheck(
65
+ name="runtime.api_model.profiles",
66
+ status=STATUS_PASS,
67
+ lane=lane_id,
68
+ message=f"{len(profiles)} API model profile(s) configured",
69
+ detail={"profiles": [profile.profile_id for profile in profiles]},
70
+ )
71
+ )
72
+ if not probe_runtime:
73
+ checks.append(
74
+ DoctorCheck(
75
+ name="runtime.api_model.probe",
76
+ status=STATUS_SKIP,
77
+ lane=lane_id,
78
+ message="runtime probing skipped; pass --probe-runtime to query model endpoints",
79
+ )
80
+ )
81
+ return checks
82
+
83
+ for profile in profiles:
84
+ runtime = runtime_profile(lane, profile)
85
+ try:
86
+ models = fetch_openai_compatible_models(
87
+ runtime.api_base,
88
+ api_model_key(lane, profile),
89
+ http_timeout,
90
+ )
91
+ except (OSError, ValueError, urllib.error.URLError, json.JSONDecodeError) as exc:
92
+ checks.append(
93
+ DoctorCheck(
94
+ name="runtime.api_model.probe",
95
+ status=STATUS_WARN,
96
+ lane=lane_id,
97
+ message=f"{runtime.profile_id} probe failed: {exc}",
98
+ detail={
99
+ "profile": runtime.profile_id,
100
+ "api_base": runtime.api_base,
101
+ },
102
+ remediation=(
103
+ "Start the local OpenAI-compatible endpoint, fix api_base "
104
+ "or api key overrides, then rerun doctor --probe-runtime."
105
+ ),
106
+ )
107
+ )
108
+ continue
109
+ expected = runtime.model
110
+ status = STATUS_PASS if expected in models else STATUS_WARN
111
+ message = (
112
+ f"{runtime.profile_id} reports expected model {expected}"
113
+ if status == STATUS_PASS
114
+ else f"{runtime.profile_id} did not report expected model {expected}"
115
+ )
116
+ checks.append(
117
+ DoctorCheck(
118
+ name="runtime.api_model.probe",
119
+ status=status,
120
+ lane=lane_id,
121
+ message=message,
122
+ detail={
123
+ "profile": runtime.profile_id,
124
+ "api_base": runtime.api_base,
125
+ "expected_model": expected,
126
+ "models": models,
127
+ },
128
+ remediation=(
129
+ None
130
+ if status == STATUS_PASS
131
+ else (
132
+ "Update the configured local LLM model or start an endpoint "
133
+ "that serves the expected model."
134
+ )
135
+ ),
136
+ )
137
+ )
138
+ return checks
@@ -0,0 +1,29 @@
1
+ """OpenAI-compatible provider API-model probes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.request
7
+ from typing import Mapping
8
+
9
+
10
+ def fetch_openai_compatible_models(
11
+ api_base: str,
12
+ api_key: str,
13
+ timeout: int,
14
+ ) -> list[str]:
15
+ request = urllib.request.Request(
16
+ f"{api_base.rstrip('/')}/models",
17
+ headers={
18
+ "Authorization": f"Bearer {api_key}",
19
+ "Content-Type": "application/json",
20
+ },
21
+ )
22
+ with urllib.request.urlopen(request, timeout=timeout) as response:
23
+ payload = json.loads(response.read().decode("utf-8"))
24
+ data = payload.get("data", []) if isinstance(payload, Mapping) else []
25
+ return [
26
+ str(entry.get("id"))
27
+ for entry in data
28
+ if isinstance(entry, Mapping) and entry.get("id")
29
+ ]
@@ -0,0 +1,137 @@
1
+ """Provider API-model profile expansion and runtime overrides."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Mapping
7
+
8
+ from .common import local_llm_profiles
9
+
10
+
11
+ def profile_from_config(
12
+ profile_id: str, raw: Mapping[str, Any]
13
+ ) -> local_llm_profiles.LocalLlmProfile:
14
+ canonical = local_llm_profiles.LOCAL_LLM_PROFILES.get(profile_id)
15
+
16
+ def profile_int(field: str, default: int) -> int:
17
+ value = raw.get(field, default)
18
+ try:
19
+ return int(value)
20
+ except (TypeError, ValueError) as exc:
21
+ raise ValueError(
22
+ f"API model profile {profile_id!r} field {field!r} must be an integer"
23
+ ) from exc
24
+
25
+ return local_llm_profiles.LocalLlmProfile(
26
+ profile_id=profile_id,
27
+ description=str(
28
+ raw.get(
29
+ "description",
30
+ canonical.description if canonical else f"{profile_id} local LLM profile",
31
+ )
32
+ ),
33
+ api_base=str(raw.get("api_base", canonical.api_base if canonical else "")),
34
+ model=str(raw.get("model", canonical.model if canonical else "")),
35
+ endpoint=str(raw.get("endpoint", canonical.endpoint if canonical else "")),
36
+ api_key=str(raw.get("api_key", canonical.api_key if canonical else "EMPTY")),
37
+ context_window=profile_int(
38
+ "context_window", canonical.context_window if canonical else 128_000
39
+ ),
40
+ max_files=profile_int("max_files", canonical.max_files if canonical else 25),
41
+ max_file_bytes=profile_int(
42
+ "max_file_bytes", canonical.max_file_bytes if canonical else 60_000
43
+ ),
44
+ http_timeout=profile_int("http_timeout", canonical.http_timeout if canonical else 900),
45
+ informational=bool(
46
+ raw.get("informational", canonical.informational if canonical else True)
47
+ ),
48
+ )
49
+
50
+
51
+ def local_llm_lane_profiles(
52
+ lane: Mapping[str, Any],
53
+ ) -> tuple[local_llm_profiles.LocalLlmProfile, ...]:
54
+ provider_config = lane.get("provider_config", {})
55
+ if not isinstance(provider_config, Mapping):
56
+ return ()
57
+ raw_profiles = provider_config.get("profiles", {})
58
+ profile_env = str(provider_config.get("profile_env", ""))
59
+ selected_profile_id = os.environ.get(profile_env) if profile_env else None
60
+ if selected_profile_id:
61
+ if isinstance(raw_profiles, Mapping):
62
+ raw_profile = raw_profiles.get(selected_profile_id)
63
+ if isinstance(raw_profile, Mapping):
64
+ return (profile_from_config(selected_profile_id, raw_profile),)
65
+ try:
66
+ return (local_llm_profiles.get_profile(selected_profile_id),)
67
+ except KeyError:
68
+ return ()
69
+ if isinstance(raw_profiles, Mapping):
70
+ return tuple(
71
+ profile_from_config(str(profile_id), profile)
72
+ for profile_id, profile in sorted(raw_profiles.items())
73
+ if isinstance(profile, Mapping)
74
+ )
75
+ if isinstance(raw_profiles, (list, tuple)):
76
+ profiles: list[local_llm_profiles.LocalLlmProfile] = []
77
+ for profile_id in raw_profiles:
78
+ try:
79
+ profiles.append(local_llm_profiles.get_profile(str(profile_id)))
80
+ except KeyError:
81
+ continue
82
+ return tuple(profiles)
83
+ return ()
84
+
85
+
86
+ def api_model_key(
87
+ lane: Mapping[str, Any], profile: local_llm_profiles.LocalLlmProfile
88
+ ) -> str:
89
+ provider_config = lane.get("provider_config", {})
90
+ api_key_env = ""
91
+ if isinstance(provider_config, Mapping):
92
+ api_key_env = str(provider_config.get("api_key_env", ""))
93
+ token_env = lane.get("token_env", [])
94
+ if not api_key_env and isinstance(token_env, list):
95
+ for item in token_env:
96
+ name = str(item)
97
+ if name.endswith("_API_KEY"):
98
+ api_key_env = name
99
+ break
100
+ if api_key_env:
101
+ value = os.environ.get(api_key_env)
102
+ if value:
103
+ return value
104
+ return profile.api_key
105
+
106
+
107
+ def provider_env_override(
108
+ provider_config: Mapping[str, Any],
109
+ env_key: str,
110
+ fallback: str,
111
+ ) -> str:
112
+ env_name = str(provider_config.get(env_key, ""))
113
+ if not env_name:
114
+ return fallback
115
+ return os.environ.get(env_name) or fallback
116
+
117
+
118
+ def runtime_profile(
119
+ lane: Mapping[str, Any],
120
+ profile: local_llm_profiles.LocalLlmProfile,
121
+ ) -> local_llm_profiles.LocalLlmProfile:
122
+ provider_config = lane.get("provider_config", {})
123
+ if not isinstance(provider_config, Mapping):
124
+ return profile
125
+ return local_llm_profiles.LocalLlmProfile(
126
+ profile_id=profile.profile_id,
127
+ description=profile.description,
128
+ api_base=provider_env_override(provider_config, "api_base_env", profile.api_base),
129
+ model=provider_env_override(provider_config, "model_env", profile.model),
130
+ endpoint=profile.endpoint,
131
+ api_key=profile.api_key,
132
+ context_window=profile.context_window,
133
+ max_files=profile.max_files,
134
+ max_file_bytes=profile.max_file_bytes,
135
+ http_timeout=profile.http_timeout,
136
+ informational=profile.informational,
137
+ )
@@ -0,0 +1,113 @@
1
+ """Provider environment and token checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ from .common import (
8
+ DoctorCheck,
9
+ STATUS_PASS,
10
+ STATUS_SKIP,
11
+ STATUS_WARN,
12
+ token_remediation,
13
+ )
14
+ from .provider_env_required import provider_required_env_status
15
+ from .provider_env_tokens import provider_token_status
16
+
17
+
18
+ def check_token_env(lane_id: str, lane: Mapping[str, Any]) -> list[DoctorCheck]:
19
+ token_status = provider_token_status(lane)
20
+ checks: list[DoctorCheck] = []
21
+ if not token_status.declares_tokens:
22
+ checks.append(
23
+ DoctorCheck(
24
+ name="env.tokens",
25
+ status=STATUS_SKIP,
26
+ lane=lane_id,
27
+ message="lane declares no token env vars",
28
+ )
29
+ )
30
+ return checks
31
+
32
+ missing = list(token_status.missing)
33
+ missing_any = [list(group) for group in token_status.missing_any]
34
+ token_file_env = list(token_status.token_file_env)
35
+ if missing or missing_any:
36
+ messages = []
37
+ if missing:
38
+ messages.append(f"missing token env vars: {', '.join(missing)}")
39
+ for group in missing_any:
40
+ messages.append(f"set one of: {', '.join(group)}")
41
+ checks.append(
42
+ DoctorCheck(
43
+ name="env.tokens",
44
+ status=STATUS_WARN,
45
+ lane=lane_id,
46
+ message="; ".join(messages),
47
+ detail={
48
+ "missing": missing,
49
+ "missing_any": missing_any,
50
+ "token_file_env": token_file_env,
51
+ },
52
+ remediation=token_remediation(missing, missing_any),
53
+ )
54
+ )
55
+ else:
56
+ checks.append(
57
+ DoctorCheck(
58
+ name="env.tokens",
59
+ status=STATUS_PASS,
60
+ lane=lane_id,
61
+ message="token env vars are set",
62
+ detail={
63
+ "token_env": list(token_status.token_env),
64
+ "token_env_any": [list(group) for group in token_status.token_env_any],
65
+ "token_file_env": token_file_env,
66
+ },
67
+ )
68
+ )
69
+ return checks
70
+
71
+
72
+ def check_required_env(lane_id: str, lane: Mapping[str, Any]) -> list[DoctorCheck]:
73
+ env_status = provider_required_env_status(lane)
74
+ if not env_status.declares_required_env:
75
+ return []
76
+ required = list(env_status.required)
77
+ required_truthy = list(env_status.required_truthy)
78
+ missing = list(env_status.missing)
79
+ missing_truthy = list(env_status.missing_truthy)
80
+ if missing or missing_truthy:
81
+ return [
82
+ DoctorCheck(
83
+ name="env.required",
84
+ status=STATUS_WARN,
85
+ lane=lane_id,
86
+ message=(
87
+ "missing required env vars: "
88
+ + ", ".join([*missing, *missing_truthy])
89
+ ),
90
+ detail={
91
+ "missing": missing,
92
+ "missing_truthy": missing_truthy,
93
+ "required_env": required,
94
+ "required_env_truthy": required_truthy,
95
+ },
96
+ remediation=(
97
+ "Set the required env vars only when you accept the lane's "
98
+ "documented runtime trust model."
99
+ ),
100
+ )
101
+ ]
102
+ return [
103
+ DoctorCheck(
104
+ name="env.required",
105
+ status=STATUS_PASS,
106
+ lane=lane_id,
107
+ message="required env vars are set",
108
+ detail={
109
+ "required_env": required,
110
+ "required_env_truthy": required_truthy,
111
+ },
112
+ )
113
+ ]
@@ -0,0 +1,56 @@
1
+ """Provider required environment status helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Any, Mapping
8
+
9
+ from .common import TRUTHY_ENV_VALUES, as_sequence
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ProviderRequiredEnvStatus:
14
+ required: tuple[str, ...]
15
+ required_truthy: tuple[str, ...]
16
+ missing: tuple[str, ...]
17
+ missing_truthy: tuple[str, ...]
18
+
19
+ @property
20
+ def declares_required_env(self) -> bool:
21
+ return bool(self.required or self.required_truthy)
22
+
23
+ @property
24
+ def all_present(self) -> bool:
25
+ return not self.missing and not self.missing_truthy
26
+
27
+
28
+ def provider_required_env_status(lane: Mapping[str, Any]) -> ProviderRequiredEnvStatus:
29
+ provider_config = lane.get("provider_config", {})
30
+ if not isinstance(provider_config, Mapping):
31
+ return ProviderRequiredEnvStatus((), (), (), ())
32
+ required = tuple(
33
+ str(name)
34
+ for name in as_sequence(provider_config.get("required_env", []))
35
+ if str(name).strip()
36
+ )
37
+ required_truthy = tuple(
38
+ str(name)
39
+ for name in as_sequence(provider_config.get("required_env_truthy", []))
40
+ if str(name).strip()
41
+ )
42
+ missing = tuple(name for name in required if not os.environ.get(name))
43
+ missing_truthy = tuple(
44
+ name
45
+ for name in required_truthy
46
+ if os.environ.get(name, "").strip().lower() not in TRUTHY_ENV_VALUES
47
+ )
48
+ return ProviderRequiredEnvStatus(
49
+ required=required,
50
+ required_truthy=required_truthy,
51
+ missing=missing,
52
+ missing_truthy=missing_truthy,
53
+ )
54
+
55
+
56
+ __all__ = ["ProviderRequiredEnvStatus", "provider_required_env_status"]