@0dai-dev/cli 4.3.6 → 4.3.7

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 (75) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +127 -30
  3. package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
  4. package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
  5. package/lib/ai/registry/mcp-catalog.json +98 -0
  6. package/lib/commands/auth.js +2 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/doctor.js +506 -12
  9. package/lib/commands/experience.js +40 -5
  10. package/lib/commands/feedback.js +157 -15
  11. package/lib/commands/gh.js +26 -0
  12. package/lib/commands/graph.js +9 -4
  13. package/lib/commands/heatmap.js +1 -1
  14. package/lib/commands/init.js +209 -27
  15. package/lib/commands/mcp.js +111 -33
  16. package/lib/commands/models.js +138 -41
  17. package/lib/commands/provider.js +30 -59
  18. package/lib/commands/quota.js +1 -1
  19. package/lib/commands/receipt.js +1 -1
  20. package/lib/commands/run.js +14 -6
  21. package/lib/commands/runner.js +31 -1
  22. package/lib/commands/status.js +38 -10
  23. package/lib/commands/swarm.js +130 -12
  24. package/lib/commands/update.js +184 -38
  25. package/lib/commands/usage.js +1 -1
  26. package/lib/commands/validate.js +32 -3
  27. package/lib/commands/vault.js +43 -8
  28. package/lib/python/__init__.py +0 -0
  29. package/lib/python/agent_quotas.py +525 -0
  30. package/lib/python/anomaly_alert.py +397 -0
  31. package/lib/python/anti_pattern_detector.py +799 -0
  32. package/lib/python/auth.py +443 -0
  33. package/lib/python/capi_profile_guard.py +477 -0
  34. package/lib/python/compliance_report.py +581 -0
  35. package/lib/python/drift_detector.py +388 -0
  36. package/lib/python/experience_pipeline.py +1130 -0
  37. package/lib/python/graph.py +19 -0
  38. package/lib/python/graph_core.py +293 -0
  39. package/lib/python/graph_io.py +179 -0
  40. package/lib/python/graph_legacy.py +2052 -0
  41. package/lib/python/graph_legacy_helpers.py +221 -0
  42. package/lib/python/graph_outcomes_core.py +85 -0
  43. package/lib/python/graph_queries.py +171 -0
  44. package/lib/python/graph_slice.py +198 -0
  45. package/lib/python/graph_slicer.py +576 -0
  46. package/lib/python/graph_slicer_cli.py +60 -0
  47. package/lib/python/graph_validation.py +64 -0
  48. package/lib/python/heatmap.py +934 -0
  49. package/lib/python/json_utils.py +193 -0
  50. package/lib/python/mcp_exposure_check.py +247 -0
  51. package/lib/python/model_router.py +1434 -0
  52. package/lib/python/project_manager.py +621 -0
  53. package/lib/python/provider_profiles.py +1618 -0
  54. package/lib/python/provider_registry.py +1211 -0
  55. package/lib/python/provider_registry_cli.py +125 -0
  56. package/lib/python/receipt_png.py +727 -0
  57. package/lib/python/structural_memory.py +325 -0
  58. package/lib/python/swarm_cost.py +177 -0
  59. package/lib/python/usage_ledger.py +569 -0
  60. package/lib/scripts/mcp_tier_config.py +240 -0
  61. package/lib/shared.js +95 -12
  62. package/lib/tui/index.mjs +35174 -0
  63. package/lib/utils/activation_telemetry.js +1 -4
  64. package/lib/utils/constants.js +7 -1
  65. package/lib/utils/identity.js +184 -0
  66. package/lib/utils/mcp-auth.js +81 -15
  67. package/lib/utils/plan.js +1 -1
  68. package/lib/vault/index.js +19 -3
  69. package/lib/vault/storage.js +21 -2
  70. package/lib/wizard.js +5 -2
  71. package/package.json +9 -3
  72. package/scripts/build-python-bundle.js +106 -0
  73. package/scripts/build-tui.js +14 -1
  74. package/scripts/harvest_experience.py +523 -0
  75. package/scripts/postinstall.js +15 -9
@@ -0,0 +1,1211 @@
1
+ #!/usr/bin/env python3
2
+ """Local BYOK/custom-provider registry and project binding helpers."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import pathlib
9
+ import secrets
10
+ import urllib.parse
11
+ from typing import Any
12
+
13
+ import project_manager as pm
14
+ from json_utils import load_json as _load_json, save_json as _save_json
15
+
16
+
17
+ AUTH_DIR = pathlib.Path.home() / ".0dai"
18
+ DB_DIR = AUTH_DIR / "db"
19
+ KEYS_PATH = DB_DIR / "provider_keys.json"
20
+ SECRETS_DIR = AUTH_DIR / "provider-secrets"
21
+
22
+ SUPPORTED_ENDPOINT_KINDS = {
23
+ "openai_compatible",
24
+ "anthropic_compatible",
25
+ "native_google",
26
+ "custom_adapter",
27
+ }
28
+ SUPPORTED_AUTH_MODES = {
29
+ "bearer",
30
+ "x-api-key",
31
+ "custom_header",
32
+ }
33
+ SUPPORTED_BACKEND_MODES = {
34
+ "",
35
+ "direct",
36
+ "indirect",
37
+ }
38
+
39
+ SUPPORT_MATRIX = {
40
+ "openai_compatible": {
41
+ "noninteractive": "direct",
42
+ "opencode": "direct",
43
+ "claude": "indirect",
44
+ "capi": "indirect",
45
+ "gemini": "indirect",
46
+ "qoder": "indirect",
47
+ "codex": "indirect",
48
+ },
49
+ "anthropic_compatible": {
50
+ "noninteractive": "direct",
51
+ "claude": "direct",
52
+ "capi": "indirect",
53
+ "opencode": "indirect",
54
+ "gemini": "indirect",
55
+ "qoder": "unsupported",
56
+ "codex": "unsupported",
57
+ },
58
+ "native_google": {
59
+ "noninteractive": "direct",
60
+ "gemini": "direct",
61
+ "claude": "indirect",
62
+ "capi": "indirect",
63
+ "opencode": "indirect",
64
+ "qoder": "unsupported",
65
+ "codex": "unsupported",
66
+ },
67
+ "custom_adapter": {
68
+ "noninteractive": "indirect",
69
+ "claude": "indirect",
70
+ "capi": "indirect",
71
+ "gemini": "indirect",
72
+ "opencode": "indirect",
73
+ "qoder": "unsupported",
74
+ "codex": "unsupported",
75
+ },
76
+ }
77
+
78
+
79
+ class ProviderRegistryError(RuntimeError):
80
+ """Raised for expected BYOK/provider registry validation errors."""
81
+
82
+
83
+ def _now_iso() -> str:
84
+ import time
85
+
86
+ return time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())
87
+
88
+
89
+ def _normalize_owner_email(email: str) -> str:
90
+ normalized = str(email or "").strip().lower()
91
+ if not normalized or "@" not in normalized:
92
+ raise ProviderRegistryError("authenticated account email required")
93
+ return normalized
94
+
95
+
96
+ def _normalize_label(label: str) -> str:
97
+ value = str(label or "").strip()
98
+ if not value:
99
+ raise ProviderRegistryError("label required")
100
+ return value[:80]
101
+
102
+
103
+ def _normalize_provider_family(value: str) -> str:
104
+ normalized = "-".join(str(value or "").strip().lower().replace("_", "-").split())
105
+ if not normalized:
106
+ raise ProviderRegistryError("provider family required")
107
+ return normalized
108
+
109
+
110
+ def _normalize_endpoint_kind(value: str) -> str:
111
+ normalized = str(value or "").strip().lower()
112
+ if normalized not in SUPPORTED_ENDPOINT_KINDS:
113
+ raise ProviderRegistryError(
114
+ "endpoint_kind must be one of: " + ", ".join(sorted(SUPPORTED_ENDPOINT_KINDS))
115
+ )
116
+ return normalized
117
+
118
+
119
+ def _normalize_auth_mode(value: str) -> str:
120
+ normalized = str(value or "bearer").strip().lower()
121
+ if normalized not in SUPPORTED_AUTH_MODES:
122
+ raise ProviderRegistryError(
123
+ "auth_mode must be one of: " + ", ".join(sorted(SUPPORTED_AUTH_MODES))
124
+ )
125
+ return normalized
126
+
127
+
128
+ def _normalize_base_url(value: str) -> str:
129
+ raw = str(value or "").strip()
130
+ if not raw:
131
+ raise ProviderRegistryError("base_url required")
132
+ parsed = urllib.parse.urlparse(raw)
133
+ if parsed.scheme not in {"https", "http"} or not parsed.netloc:
134
+ raise ProviderRegistryError("base_url must be a valid http(s) URL")
135
+ if parsed.username or parsed.password:
136
+ raise ProviderRegistryError("base_url must not include embedded credentials")
137
+ if parsed.query or parsed.fragment:
138
+ raise ProviderRegistryError("base_url must not include query or fragment")
139
+ normalized = parsed._replace(path=parsed.path.rstrip("/"), params="", query="", fragment="").geturl()
140
+ return normalized
141
+
142
+
143
+ def _normalize_agents(value: list[str] | tuple[str, ...] | str | None) -> list[str]:
144
+ if value is None:
145
+ return []
146
+ if isinstance(value, (list, tuple)):
147
+ items = [str(item or "").strip().lower() for item in value]
148
+ else:
149
+ items = [part.strip().lower() for part in str(value or "").split(",")]
150
+ seen: list[str] = []
151
+ for item in items:
152
+ if item and item not in seen:
153
+ seen.append(item)
154
+ return seen
155
+
156
+
157
+ def _normalize_backend_mode(value: str) -> str:
158
+ normalized = str(value or "").strip().lower()
159
+ if normalized not in SUPPORTED_BACKEND_MODES:
160
+ raise ProviderRegistryError("preferred_backend_mode must be '', 'direct', or 'indirect'")
161
+ return normalized
162
+
163
+
164
+ def _hash_secret(secret: str) -> str:
165
+ return hashlib.sha256(secret.encode("utf-8")).hexdigest()
166
+
167
+
168
+ def _mask_secret(secret: str) -> str:
169
+ text = str(secret or "")
170
+ if len(text) <= 8:
171
+ return "***"
172
+ return f"{text[:4]}...{text[-4:]}"
173
+
174
+
175
+ def _store_schema() -> dict[str, Any]:
176
+ return {"schema": 1, "updated_at": "", "keys": {}, "defaults": {}}
177
+
178
+
179
+ def _normalize_defaults(defaults: Any, keys: dict[str, Any]) -> dict[str, dict[str, str]]:
180
+ if not isinstance(defaults, dict):
181
+ return {}
182
+ normalized: dict[str, dict[str, str]] = {}
183
+ for bucket, raw in defaults.items():
184
+ if isinstance(raw, dict):
185
+ owner = str(bucket or "").strip().lower()
186
+ if not owner:
187
+ continue
188
+ owner_map: dict[str, str] = {}
189
+ for provider_family, key_id in raw.items():
190
+ provider = str(provider_family or "").strip().lower()
191
+ ref = str(key_id or "").strip()
192
+ if provider and ref:
193
+ owner_map[provider] = ref
194
+ if owner_map:
195
+ normalized[owner] = owner_map
196
+ continue
197
+ provider = str(bucket or "").strip().lower()
198
+ ref = str(raw or "").strip()
199
+ if not provider or not ref:
200
+ continue
201
+ record = keys.get(ref)
202
+ owner = str(record.get("owner_email") or "").strip().lower() if isinstance(record, dict) else ""
203
+ if not owner:
204
+ continue
205
+ normalized.setdefault(owner, {})[provider] = ref
206
+ return normalized
207
+
208
+
209
+ def _load_store() -> dict[str, Any]:
210
+ data = _load_json(KEYS_PATH)
211
+ if not isinstance(data, dict):
212
+ return _store_schema()
213
+ keys = data.get("keys")
214
+ if not isinstance(keys, dict):
215
+ keys = {}
216
+ defaults = _normalize_defaults(data.get("defaults"), keys)
217
+ return {
218
+ "schema": 1,
219
+ "updated_at": str(data.get("updated_at") or ""),
220
+ "keys": keys,
221
+ "defaults": defaults,
222
+ }
223
+
224
+
225
+ def _save_store(store: dict[str, Any]) -> None:
226
+ payload = {
227
+ "schema": 1,
228
+ "updated_at": _now_iso(),
229
+ "keys": store.get("keys", {}) if isinstance(store, dict) else {},
230
+ "defaults": store.get("defaults", {}) if isinstance(store, dict) else {},
231
+ }
232
+ _save_json(KEYS_PATH, payload)
233
+
234
+
235
+ def _secret_path(key_id: str) -> pathlib.Path:
236
+ return SECRETS_DIR / f"{key_id}.secret"
237
+
238
+
239
+ def _write_secret(key_id: str, secret: str) -> pathlib.Path:
240
+ if not secret:
241
+ raise ProviderRegistryError("api key required")
242
+ path = _secret_path(key_id)
243
+ path.parent.mkdir(parents=True, exist_ok=True)
244
+ path.write_text(str(secret).strip() + "\n", encoding="utf-8")
245
+ try:
246
+ path.parent.chmod(0o700)
247
+ except OSError:
248
+ pass
249
+ try:
250
+ path.chmod(0o600)
251
+ except OSError:
252
+ pass
253
+ return path
254
+
255
+
256
+ def load_secret(key_id: str) -> str:
257
+ path = _secret_path(str(key_id or "").strip())
258
+ if not path.is_file():
259
+ return ""
260
+ try:
261
+ return path.read_text(encoding="utf-8").strip()
262
+ except OSError:
263
+ return ""
264
+
265
+
266
+ def _gen_key_id() -> str:
267
+ return f"llmkey_{secrets.token_hex(8)}"
268
+
269
+
270
+ def _public_record(record: dict[str, Any], *, is_default: bool = False) -> dict[str, Any]:
271
+ return {
272
+ "key_id": str(record.get("key_id") or ""),
273
+ "owner_email": str(record.get("owner_email") or ""),
274
+ "label": str(record.get("label") or ""),
275
+ "provider_family": str(record.get("provider_family") or ""),
276
+ "endpoint_kind": str(record.get("endpoint_kind") or ""),
277
+ "base_url": str(record.get("base_url") or ""),
278
+ "auth_mode": str(record.get("auth_mode") or ""),
279
+ "default_model": str(record.get("default_model") or ""),
280
+ "status": str(record.get("status") or "active"),
281
+ "secret_hint": str(record.get("secret_hint") or ""),
282
+ "created_at": str(record.get("created_at") or ""),
283
+ "rotated_at": str(record.get("rotated_at") or ""),
284
+ "disabled_at": str(record.get("disabled_at") or ""),
285
+ "is_default": bool(is_default),
286
+ }
287
+
288
+
289
+ def _default_key_id(store: dict[str, Any], owner_email: str, provider_family: str) -> str:
290
+ defaults = store.get("defaults", {}) if isinstance(store, dict) else {}
291
+ owner_defaults = defaults.get(owner_email, {}) if isinstance(defaults, dict) else {}
292
+ if not isinstance(owner_defaults, dict):
293
+ return ""
294
+ return str(owner_defaults.get(provider_family) or "").strip()
295
+
296
+
297
+ def support_matrix(endpoint_kind: str) -> dict[str, str]:
298
+ normalized = _normalize_endpoint_kind(endpoint_kind)
299
+ return dict(SUPPORT_MATRIX[normalized])
300
+
301
+
302
+ def format_support_matrix(*, as_json: bool = False) -> str:
303
+ """Render the full SUPPORT_MATRIX as an operator-visible table or JSON."""
304
+ if as_json:
305
+ return json.dumps(SUPPORT_MATRIX, indent=2, ensure_ascii=False)
306
+ clis = sorted({cli for m in SUPPORT_MATRIX.values() for cli in m})
307
+ kinds = sorted(SUPPORT_MATRIX)
308
+ header = f" {'ENDPOINT KIND':<24}" + "".join(f" {c:<14}" for c in clis)
309
+ sep = " " + "-" * (24 + 14 * len(clis))
310
+ rows = []
311
+ for kind in kinds:
312
+ cells = []
313
+ for cli in clis:
314
+ mode = SUPPORT_MATRIX[kind].get(cli, "—")
315
+ cells.append(f" {mode:<14}")
316
+ rows.append(f" {kind:<24}" + "".join(cells))
317
+ return "\n".join([header, sep, *rows])
318
+
319
+
320
+ def validate_agent_support(endpoint_kind: str, agents: list[str]) -> list[str]:
321
+ """Return list of agents that are 'unsupported' for the given endpoint kind."""
322
+ matrix = support_matrix(endpoint_kind)
323
+ return [a for a in agents if matrix.get(a) == "unsupported"]
324
+
325
+
326
+ def register_provider_key(
327
+ *,
328
+ owner_email: str,
329
+ label: str,
330
+ provider_family: str,
331
+ endpoint_kind: str,
332
+ base_url: str,
333
+ secret: str,
334
+ default_model: str = "",
335
+ auth_mode: str = "bearer",
336
+ set_default: bool = False,
337
+ ) -> dict[str, Any]:
338
+ owner = _normalize_owner_email(owner_email)
339
+ provider = _normalize_provider_family(provider_family)
340
+ endpoint = _normalize_endpoint_kind(endpoint_kind)
341
+ base = _normalize_base_url(base_url)
342
+ auth = _normalize_auth_mode(auth_mode)
343
+ model = str(default_model or "").strip()
344
+ masked = _mask_secret(secret)
345
+ key_id = _gen_key_id()
346
+ path = _write_secret(key_id, secret)
347
+ record = {
348
+ "key_id": key_id,
349
+ "owner_email": owner,
350
+ "label": _normalize_label(label),
351
+ "provider_family": provider,
352
+ "endpoint_kind": endpoint,
353
+ "base_url": base,
354
+ "auth_mode": auth,
355
+ "default_model": model,
356
+ "status": "active",
357
+ "secret_hash": _hash_secret(secret),
358
+ "secret_hint": masked,
359
+ "secret_ref": str(path),
360
+ "created_at": _now_iso(),
361
+ "rotated_at": "",
362
+ "disabled_at": "",
363
+ }
364
+ store = _load_store()
365
+ store["keys"][key_id] = record
366
+ if set_default or not _default_key_id(store, owner, provider):
367
+ store.setdefault("defaults", {}).setdefault(owner, {})[provider] = key_id
368
+ _save_store(store)
369
+ return _public_record(record, is_default=_default_key_id(store, owner, provider) == key_id)
370
+
371
+
372
+ def list_provider_keys(owner_email: str) -> list[dict[str, Any]]:
373
+ owner = _normalize_owner_email(owner_email)
374
+ store = _load_store()
375
+ items: list[dict[str, Any]] = []
376
+ defaults = store.get("defaults", {}) if isinstance(store, dict) else {}
377
+ for key_id, raw in store.get("keys", {}).items():
378
+ if not isinstance(raw, dict):
379
+ continue
380
+ if str(raw.get("owner_email") or "").strip().lower() != owner:
381
+ continue
382
+ provider = str(raw.get("provider_family") or "")
383
+ owner_defaults = defaults.get(owner, {}) if isinstance(defaults, dict) else {}
384
+ items.append(_public_record(raw, is_default=isinstance(owner_defaults, dict) and owner_defaults.get(provider) == key_id))
385
+ return sorted(items, key=lambda item: (item["provider_family"], item["label"], item["key_id"]))
386
+
387
+
388
+ def get_provider_key(owner_email: str, key_id: str) -> dict[str, Any]:
389
+ owner = _normalize_owner_email(owner_email)
390
+ ref = str(key_id or "").strip()
391
+ store = _load_store()
392
+ record = store.get("keys", {}).get(ref)
393
+ if not isinstance(record, dict) or str(record.get("owner_email") or "").strip().lower() != owner:
394
+ raise ProviderRegistryError("provider key not found")
395
+ provider = str(record.get("provider_family") or "")
396
+ is_default = _default_key_id(store, owner, provider) == ref
397
+ return _public_record(record, is_default=is_default)
398
+
399
+
400
+ def set_default_provider_key(owner_email: str, key_id: str) -> dict[str, Any]:
401
+ owner = _normalize_owner_email(owner_email)
402
+ ref = str(key_id or "").strip()
403
+ store = _load_store()
404
+ record = store.get("keys", {}).get(ref)
405
+ if not isinstance(record, dict) or str(record.get("owner_email") or "").strip().lower() != owner:
406
+ raise ProviderRegistryError("provider key not found")
407
+ if str(record.get("status") or "active") != "active":
408
+ raise ProviderRegistryError("only active keys can become default")
409
+ provider = str(record.get("provider_family") or "")
410
+ store.setdefault("defaults", {}).setdefault(owner, {})[provider] = ref
411
+ _save_store(store)
412
+ return _public_record(record, is_default=True)
413
+
414
+
415
+ def disable_provider_key(owner_email: str, key_id: str) -> dict[str, Any]:
416
+ owner = _normalize_owner_email(owner_email)
417
+ ref = str(key_id or "").strip()
418
+ store = _load_store()
419
+ record = store.get("keys", {}).get(ref)
420
+ if not isinstance(record, dict) or str(record.get("owner_email") or "").strip().lower() != owner:
421
+ raise ProviderRegistryError("provider key not found")
422
+ if str(record.get("status") or "") != "disabled":
423
+ record["status"] = "disabled"
424
+ record["disabled_at"] = _now_iso()
425
+ provider = str(record.get("provider_family") or "")
426
+ owner_defaults = store.get("defaults", {}).get(owner, {})
427
+ if isinstance(owner_defaults, dict) and owner_defaults.get(provider) == ref:
428
+ owner_defaults.pop(provider, None)
429
+ if not owner_defaults:
430
+ store.get("defaults", {}).pop(owner, None)
431
+ _save_store(store)
432
+ return _public_record(record, is_default=False)
433
+
434
+
435
+ def rotate_provider_key(owner_email: str, key_id: str, *, secret: str) -> dict[str, Any]:
436
+ owner = _normalize_owner_email(owner_email)
437
+ ref = str(key_id or "").strip()
438
+ store = _load_store()
439
+ record = store.get("keys", {}).get(ref)
440
+ if not isinstance(record, dict) or str(record.get("owner_email") or "").strip().lower() != owner:
441
+ raise ProviderRegistryError("provider key not found")
442
+ path = _write_secret(ref, secret)
443
+ record["secret_hash"] = _hash_secret(secret)
444
+ record["secret_hint"] = _mask_secret(secret)
445
+ record["secret_ref"] = str(path)
446
+ record["rotated_at"] = _now_iso()
447
+ if str(record.get("status") or "") != "active":
448
+ record["status"] = "active"
449
+ record["disabled_at"] = ""
450
+ _save_store(store)
451
+ provider = str(record.get("provider_family") or "")
452
+ is_default = _default_key_id(store, owner, provider) == ref
453
+ return _public_record(record, is_default=is_default)
454
+
455
+
456
+ def project_bindings_path(target: pathlib.Path) -> pathlib.Path:
457
+ return target / ".0dai" / "provider-bindings.json"
458
+
459
+
460
+ def _load_project_bindings(target: pathlib.Path) -> dict[str, Any]:
461
+ path = project_bindings_path(target)
462
+ data = _load_json(path)
463
+ if not isinstance(data, dict):
464
+ return {"schema": 1, "project_id": "", "bindings": {}, "updated_at": ""}
465
+ bindings = data.get("bindings")
466
+ if not isinstance(bindings, dict):
467
+ bindings = {}
468
+ return {
469
+ "schema": 1,
470
+ "project_id": str(data.get("project_id") or ""),
471
+ "bindings": bindings,
472
+ "updated_at": str(data.get("updated_at") or ""),
473
+ }
474
+
475
+
476
+ def _save_project_bindings(target: pathlib.Path, store: dict[str, Any]) -> None:
477
+ payload = {
478
+ "schema": 1,
479
+ "project_id": str(store.get("project_id") or ""),
480
+ "updated_at": _now_iso(),
481
+ "bindings": store.get("bindings", {}) if isinstance(store, dict) else {},
482
+ }
483
+ _save_json(project_bindings_path(target), payload)
484
+
485
+
486
+ def _project_identity(target: pathlib.Path) -> dict[str, Any]:
487
+ try:
488
+ return pm._identity(target.resolve())
489
+ except Exception: # noqa: BLE001 — project identity lookup failed
490
+ return {"project_id": "", "project_name": target.resolve().name}
491
+
492
+
493
+ def bind_project_provider(
494
+ target: pathlib.Path,
495
+ *,
496
+ owner_email: str,
497
+ provider_family: str,
498
+ key_id: str,
499
+ default_model: str = "",
500
+ preferred_backend_mode: str = "",
501
+ enabled_agents: list[str] | tuple[str, ...] | str | None = None,
502
+ ) -> dict[str, Any]:
503
+ project = target.resolve()
504
+ owner = _normalize_owner_email(owner_email)
505
+ provider = _normalize_provider_family(provider_family)
506
+ key = get_provider_key(owner, key_id)
507
+ if key["provider_family"] != provider:
508
+ raise ProviderRegistryError("provider_family must match the selected key")
509
+ store = _load_project_bindings(project)
510
+ identity = _project_identity(project)
511
+ store["project_id"] = str(identity.get("project_id") or store.get("project_id") or "")
512
+ binding = {
513
+ "provider_family": provider,
514
+ "provider_key_id": key["key_id"],
515
+ "default_model": str(default_model or key.get("default_model") or "").strip(),
516
+ "preferred_backend_mode": _normalize_backend_mode(preferred_backend_mode),
517
+ "enabled_agents": _normalize_agents(enabled_agents),
518
+ "updated_at": _now_iso(),
519
+ }
520
+ store.setdefault("bindings", {})[provider] = binding
521
+ _save_project_bindings(project, store)
522
+ return dict(binding, project_id=store["project_id"])
523
+
524
+
525
+ def list_project_bindings(target: pathlib.Path) -> list[dict[str, Any]]:
526
+ project = target.resolve()
527
+ store = _load_project_bindings(project)
528
+ items: list[dict[str, Any]] = []
529
+ for provider, raw in store.get("bindings", {}).items():
530
+ if not isinstance(raw, dict):
531
+ continue
532
+ item = dict(raw)
533
+ item["provider_family"] = provider
534
+ item["project_id"] = store.get("project_id") or ""
535
+ items.append(item)
536
+ return sorted(items, key=lambda item: item["provider_family"])
537
+
538
+
539
+ def resolve_provider_binding(
540
+ target: pathlib.Path,
541
+ *,
542
+ owner_email: str,
543
+ provider_family: str,
544
+ ) -> dict[str, Any]:
545
+ project = target.resolve()
546
+ owner = _normalize_owner_email(owner_email)
547
+ provider = _normalize_provider_family(provider_family)
548
+ store = _load_store()
549
+ bindings = _load_project_bindings(project)
550
+ binding = bindings.get("bindings", {}).get(provider)
551
+ source = ""
552
+ key_id = ""
553
+ if isinstance(binding, dict):
554
+ key_id = str(binding.get("provider_key_id") or "").strip()
555
+ source = "project_override"
556
+ if not key_id:
557
+ default_key = _default_key_id(store, owner, provider)
558
+ if default_key:
559
+ key_id = default_key
560
+ source = "account_default"
561
+ if not key_id:
562
+ raise ProviderRegistryError(f"no active provider binding for '{provider}'")
563
+ key = get_provider_key(owner, key_id)
564
+ if key["status"] != "active":
565
+ raise ProviderRegistryError(f"provider key '{key_id}' is not active")
566
+ support = support_matrix(key["endpoint_kind"])
567
+ effective_model = ""
568
+ preferred_backend_mode = ""
569
+ enabled_agents: list[str] = []
570
+ if isinstance(binding, dict):
571
+ effective_model = str(binding.get("default_model") or "").strip()
572
+ preferred_backend_mode = str(binding.get("preferred_backend_mode") or "").strip()
573
+ enabled_agents = _normalize_agents(binding.get("enabled_agents"))
574
+ if not effective_model:
575
+ effective_model = key["default_model"]
576
+ unsupported = validate_agent_support(key["endpoint_kind"], enabled_agents)
577
+ if unsupported:
578
+ raise ProviderRegistryError(
579
+ f"endpoint kind '{key['endpoint_kind']}' does not support direct execution "
580
+ f"via: {', '.join(unsupported)}. Use an indirect adapter or remove these agents "
581
+ f"from the binding."
582
+ )
583
+ return {
584
+ "project_id": str(bindings.get("project_id") or _project_identity(project).get("project_id") or ""),
585
+ "target": str(project),
586
+ "provider_family": provider,
587
+ "resolution_source": source,
588
+ "binding": dict(binding) if isinstance(binding, dict) else {},
589
+ "key": key,
590
+ "support_matrix": support,
591
+ "effective_model": effective_model,
592
+ "preferred_backend_mode": preferred_backend_mode,
593
+ "enabled_agents": enabled_agents,
594
+ }
595
+
596
+
597
+ def render_snippet(
598
+ resolved: dict[str, Any],
599
+ *,
600
+ cli_name: str,
601
+ include_secret: bool = False,
602
+ ) -> dict[str, Any]:
603
+ key = dict(resolved.get("key") or {})
604
+ endpoint_kind = str(key.get("endpoint_kind") or "")
605
+ base_url = str(key.get("base_url") or "")
606
+ label = str(key.get("label") or key.get("provider_family") or "Custom Provider")
607
+ provider_family = str(key.get("provider_family") or "")
608
+ model = str(resolved.get("effective_model") or key.get("default_model") or "").strip()
609
+ secret_value = load_secret(str(key.get("key_id") or ""))
610
+ secret = secret_value if include_secret else "<stored secret>"
611
+ cli = str(cli_name or "").strip().lower()
612
+ support = dict(resolved.get("support_matrix") or {})
613
+ mode = support.get(cli, "unsupported")
614
+
615
+ if cli == "opencode":
616
+ if mode != "direct" or endpoint_kind != "openai_compatible":
617
+ raise ProviderRegistryError("OpenCode direct snippet is supported only for openai_compatible endpoints")
618
+ payload = {
619
+ "$schema": "https://opencode.ai/config.json",
620
+ "model": model,
621
+ "provider": {
622
+ label: {
623
+ "npm": "@ai-sdk/openai-compatible",
624
+ "name": label,
625
+ "options": {
626
+ "baseURL": base_url,
627
+ "apiKey": secret,
628
+ },
629
+ }
630
+ },
631
+ }
632
+ return {
633
+ "cli": cli,
634
+ "format": "json",
635
+ "direct_mode": mode,
636
+ "snippet": json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
637
+ }
638
+
639
+ if cli in {"claude", "capi"}:
640
+ if mode != "direct" or endpoint_kind != "anthropic_compatible":
641
+ raise ProviderRegistryError("Claude direct snippet is supported only for anthropic_compatible endpoints")
642
+ lines = [
643
+ f'export ANTHROPIC_BASE_URL="{base_url}"',
644
+ f'export ANTHROPIC_API_KEY="{secret}"',
645
+ ]
646
+ if model:
647
+ lines.append(f'export ANTHROPIC_MODEL="{model}"')
648
+ lines.append(f'export ANTHROPIC_SMALL_FAST_MODEL="{model}"')
649
+ lines.append('export DISABLE_NON_ESSENTIAL_MODEL_CALLS="1"')
650
+ lines.append('export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1"')
651
+ return {
652
+ "cli": cli,
653
+ "format": "shell",
654
+ "direct_mode": mode,
655
+ "snippet": "\n".join(lines) + "\n",
656
+ }
657
+
658
+ if cli == "noninteractive":
659
+ if mode not in {"direct", "indirect"}:
660
+ raise ProviderRegistryError("noninteractive snippet is unsupported for this endpoint kind")
661
+ lines = [
662
+ f'export ODAI_CUSTOM_PROVIDER_FAMILY="{provider_family}"',
663
+ f'export ODAI_CUSTOM_ENDPOINT_KIND="{endpoint_kind}"',
664
+ f'export ODAI_CUSTOM_BASE_URL="{base_url}"',
665
+ f'export ODAI_CUSTOM_API_KEY="{secret}"',
666
+ ]
667
+ if model:
668
+ lines.append(f'export ODAI_CUSTOM_MODEL="{model}"')
669
+ return {
670
+ "cli": cli,
671
+ "format": "shell",
672
+ "direct_mode": mode,
673
+ "snippet": "\n".join(lines) + "\n",
674
+ }
675
+
676
+ raise ProviderRegistryError(f"snippet generation is not implemented for cli '{cli}'")
677
+
678
+
679
+ # ──────────────────────────────────────────────────────────────────────────────
680
+ # Per-Repo Provider Profiles (M19 — Team-tier $49/seat differentiator)
681
+ # ──────────────────────────────────────────────────────────────────────────────
682
+
683
+ GLOBAL_PROVIDERS_PATH = AUTH_DIR / "providers" / "global.json"
684
+ AUDIT_LOG_PATH = AUTH_DIR / "audit" / "provider-changes.jsonl"
685
+
686
+ # Built-in provider families (extensible)
687
+ KNOWN_PROVIDERS = {
688
+ "anthropic": {
689
+ "endpoint_kind": "anthropic_compatible",
690
+ "env_prefix": "ANTHROPIC",
691
+ "base_url_env": "ANTHROPIC_BASE_URL",
692
+ "api_key_env": "ANTHROPIC_API_KEY",
693
+ "default_base_url": "https://api.anthropic.com",
694
+ },
695
+ "openai": {
696
+ "endpoint_kind": "openai_compatible",
697
+ "env_prefix": "OPENAI",
698
+ "base_url_env": "OPENAI_BASE_URL",
699
+ "api_key_env": "OPENAI_API_KEY",
700
+ "default_base_url": "https://api.openai.com/v1",
701
+ },
702
+ "openrouter": {
703
+ "endpoint_kind": "openai_compatible",
704
+ "env_prefix": "OPENROUTER",
705
+ "base_url_env": "OPENROUTER_BASE_URL",
706
+ "api_key_env": "OPENROUTER_API_KEY",
707
+ "default_base_url": "https://openrouter.ai/api/v1",
708
+ },
709
+ "ruapi": {
710
+ "endpoint_kind": "openai_compatible",
711
+ "env_prefix": "RUAPI",
712
+ "base_url_env": "RUAPI_BASE_URL",
713
+ "api_key_env": "RUAPI_API_KEY",
714
+ "default_base_url": "https://api.stepanovikov.uno/v1",
715
+ },
716
+ "deepseek": {
717
+ "endpoint_kind": "openai_compatible",
718
+ "env_prefix": "DEEPSEEK",
719
+ "base_url_env": "DEEPSEEK_BASE_URL",
720
+ "api_key_env": "DEEPSEEK_API_KEY",
721
+ "default_base_url": "https://api.deepseek.com/v1",
722
+ },
723
+ "ollama": {
724
+ "endpoint_kind": "openai_compatible",
725
+ "env_prefix": "OLLAMA",
726
+ "base_url_env": "OLLAMA_BASE_URL",
727
+ "api_key_env": "OLLAMA_API_KEY",
728
+ "default_base_url": "http://localhost:11434/v1",
729
+ },
730
+ "google": {
731
+ "endpoint_kind": "native_google",
732
+ "env_prefix": "GOOGLE",
733
+ "base_url_env": "GOOGLE_BASE_URL",
734
+ "api_key_env": "GOOGLE_API_KEY",
735
+ "default_base_url": "https://generativelanguage.googleapis.com",
736
+ },
737
+ }
738
+
739
+
740
+ def _repo_id(target: pathlib.Path) -> str:
741
+ """Generate stable repo ID from target path (for profile storage)."""
742
+ resolved = target.resolve()
743
+ # Try git remote first for stable cross-machine identity
744
+ try:
745
+ import subprocess
746
+ result = subprocess.run(
747
+ ["git", "remote", "get-url", "origin"],
748
+ cwd=str(resolved),
749
+ capture_output=True,
750
+ text=True,
751
+ timeout=5,
752
+ )
753
+ if result.returncode == 0 and result.stdout.strip():
754
+ remote = result.stdout.strip()
755
+ # Normalize: strip .git suffix, take repo slug
756
+ import re
757
+ match = re.search(r"[/:]([^/]+/[^/]+?)(?:\.git)?$", remote)
758
+ if match:
759
+ return match.group(1).replace("/", "_").replace(":", "_")
760
+ except Exception: # noqa: BLE001 — git remote get-url failed
761
+ pass
762
+ # Fallback: hash of resolved path
763
+ return hashlib.sha256(str(resolved).encode()).hexdigest()[:16]
764
+
765
+
766
+ def _per_repo_profile_path(target: pathlib.Path) -> pathlib.Path:
767
+ """Return path to per-repo provider profile: ai/providers/<repo-id>/profile.json"""
768
+ repo_id = _repo_id(target)
769
+ return target.resolve() / "ai" / "providers" / repo_id / "profile.json"
770
+
771
+
772
+ def _load_global_providers() -> dict[str, Any]:
773
+ """Load global provider defaults from ~/.0dai/providers/global.json"""
774
+ try:
775
+ return _load_json(GLOBAL_PROVIDERS_PATH) or {}
776
+ except (json.JSONDecodeError, OSError):
777
+ return {}
778
+
779
+
780
+ def _save_global_providers(data: dict[str, Any]) -> None:
781
+ """Save global provider defaults to ~/.0dai/providers/global.json"""
782
+ GLOBAL_PROVIDERS_PATH.parent.mkdir(parents=True, exist_ok=True)
783
+ _save_json(GLOBAL_PROVIDERS_PATH, data)
784
+
785
+
786
+ def _load_repo_profile(target: pathlib.Path) -> dict[str, Any]:
787
+ """Load per-repo provider profile from ai/providers/<repo-id>/profile.json"""
788
+ path = _per_repo_profile_path(target)
789
+ try:
790
+ return _load_json(path) or {}
791
+ except (json.JSONDecodeError, OSError):
792
+ return {}
793
+
794
+
795
+ def _save_repo_profile(target: pathlib.Path, data: dict[str, Any]) -> pathlib.Path:
796
+ """Save per-repo provider profile to ai/providers/<repo-id>/profile.json"""
797
+ path = _per_repo_profile_path(target)
798
+ path.parent.mkdir(parents=True, exist_ok=True)
799
+ _save_json(path, data)
800
+ return path
801
+
802
+
803
+ def _get_operator_id() -> str:
804
+ """Get operator ID from env or git config."""
805
+ # Try env first
806
+ op_id = os.environ.get("ODAI_OPERATOR_ID") or os.environ.get("USER") or os.environ.get("USERNAME")
807
+ if op_id:
808
+ return op_id
809
+ # Try git config
810
+ try:
811
+ import subprocess
812
+ result = subprocess.run(
813
+ ["git", "config", "user.email"],
814
+ capture_output=True,
815
+ text=True,
816
+ timeout=5,
817
+ )
818
+ if result.returncode == 0 and result.stdout.strip():
819
+ return result.stdout.strip()
820
+ except Exception: # noqa: BLE001 — git config user.email failed
821
+ pass
822
+ return "unknown"
823
+
824
+
825
+ def write_audit_event(
826
+ event_type: str,
827
+ target: pathlib.Path | str,
828
+ old: dict[str, Any] | None,
829
+ new: dict[str, Any] | None,
830
+ operator_id: str = "",
831
+ ) -> pathlib.Path:
832
+ """Append audit event to ~/.0dai/audit/provider-changes.jsonl
833
+
834
+ Args:
835
+ event_type: Type of event (e.g., "switch", "set_default", "clear")
836
+ target: Target path (repo root)
837
+ old: Previous state (or None)
838
+ new: New state (or None)
839
+ operator_id: Operator identifier (auto-detected if empty)
840
+
841
+ Returns:
842
+ Path to audit log file
843
+ """
844
+ import fcntl
845
+
846
+ if not operator_id:
847
+ operator_id = _get_operator_id()
848
+
849
+ entry = {
850
+ "timestamp": _now_iso(),
851
+ "event_type": event_type,
852
+ "target_path": str(pathlib.Path(target).resolve()) if target else "",
853
+ "old": old,
854
+ "new": new,
855
+ "operator_id": operator_id,
856
+ }
857
+
858
+ AUDIT_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
859
+
860
+ # Thread/process-safe append with fcntl
861
+ with open(AUDIT_LOG_PATH, "a", encoding="utf-8") as f:
862
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
863
+ try:
864
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
865
+ finally:
866
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
867
+
868
+ return AUDIT_LOG_PATH
869
+
870
+
871
+ def resolve_for_repo(target: pathlib.Path) -> dict[str, Any]:
872
+ """Resolve provider configuration for a repo, merging global + per-repo.
873
+
874
+ Resolution order:
875
+ 1. Per-repo profile (ai/providers/<repo-id>/profile.json) — highest priority
876
+ 2. Global defaults (~/.0dai/providers/global.json)
877
+
878
+ Returns:
879
+ {
880
+ "provider_name": str, # e.g., "anthropic", "ruapi", "openai"
881
+ "base_url": str, # API base URL
882
+ "api_key_ref": str, # Env var name or secret ref
883
+ "api_key_value": str, # Actual key value (from env or stored)
884
+ "endpoint_kind": str, # e.g., "anthropic_compatible"
885
+ "source": str, # "repo_profile" | "global_default" | "env_fallback"
886
+ "audit_trail": dict, # Resolution metadata
887
+ }
888
+
889
+ Raises:
890
+ ProviderRegistryError: If no valid provider config found and no env fallback
891
+ """
892
+ resolved = target.resolve()
893
+ repo_id = _repo_id(resolved)
894
+
895
+ audit_trail = {
896
+ "repo_id": repo_id,
897
+ "target": str(resolved),
898
+ "resolved_at": _now_iso(),
899
+ "checked_sources": [],
900
+ }
901
+
902
+ # 1. Check per-repo profile
903
+ repo_profile = _load_repo_profile(resolved)
904
+ audit_trail["checked_sources"].append("repo_profile")
905
+
906
+ if repo_profile.get("provider_name"):
907
+ provider_name = repo_profile["provider_name"]
908
+ provider_info = KNOWN_PROVIDERS.get(provider_name, {})
909
+
910
+ base_url = repo_profile.get("base_url") or provider_info.get("default_base_url", "")
911
+ api_key_ref = repo_profile.get("api_key_ref") or provider_info.get("api_key_env", "")
912
+
913
+ # Resolve API key value
914
+ api_key_value = ""
915
+ if api_key_ref.startswith("llmkey_"):
916
+ # Stored secret reference
917
+ api_key_value = load_secret(api_key_ref)
918
+ else:
919
+ # Environment variable
920
+ api_key_value = os.environ.get(api_key_ref, "")
921
+
922
+ if not api_key_value:
923
+ # Try standard env var for this provider
924
+ env_var = provider_info.get("api_key_env", "")
925
+ if env_var:
926
+ api_key_value = os.environ.get(env_var, "")
927
+ if api_key_value:
928
+ api_key_ref = env_var
929
+
930
+ if not api_key_value:
931
+ raise ProviderRegistryError(
932
+ f"Provider '{provider_name}' configured for {resolved.name} but no API key found. "
933
+ f"Set {api_key_ref} or export {provider_info.get('api_key_env', 'API_KEY')}=..."
934
+ )
935
+
936
+ audit_trail["resolution_source"] = "repo_profile"
937
+ return {
938
+ "provider_name": provider_name,
939
+ "base_url": base_url,
940
+ "api_key_ref": api_key_ref,
941
+ "api_key_value": api_key_value,
942
+ "endpoint_kind": provider_info.get("endpoint_kind", "openai_compatible"),
943
+ "model": repo_profile.get("model", ""),
944
+ "source": "repo_profile",
945
+ "audit_trail": audit_trail,
946
+ }
947
+
948
+ # 2. Check global defaults
949
+ global_config = _load_global_providers()
950
+ audit_trail["checked_sources"].append("global_default")
951
+
952
+ if global_config.get("default_provider"):
953
+ provider_name = global_config["default_provider"]
954
+ provider_info = KNOWN_PROVIDERS.get(provider_name, {})
955
+
956
+ base_url = global_config.get("base_url") or provider_info.get("default_base_url", "")
957
+ api_key_ref = global_config.get("api_key_ref") or provider_info.get("api_key_env", "")
958
+
959
+ api_key_value = ""
960
+ if api_key_ref.startswith("llmkey_"):
961
+ api_key_value = load_secret(api_key_ref)
962
+ else:
963
+ api_key_value = os.environ.get(api_key_ref, "")
964
+
965
+ if not api_key_value:
966
+ env_var = provider_info.get("api_key_env", "")
967
+ if env_var:
968
+ api_key_value = os.environ.get(env_var, "")
969
+ if api_key_value:
970
+ api_key_ref = env_var
971
+
972
+ if not api_key_value:
973
+ raise ProviderRegistryError(
974
+ f"Global provider '{provider_name}' configured but no API key found. "
975
+ f"Export {provider_info.get('api_key_env', 'API_KEY')}=..."
976
+ )
977
+
978
+ audit_trail["resolution_source"] = "global_default"
979
+ return {
980
+ "provider_name": provider_name,
981
+ "base_url": base_url,
982
+ "api_key_ref": api_key_ref,
983
+ "api_key_value": api_key_value,
984
+ "endpoint_kind": provider_info.get("endpoint_kind", "openai_compatible"),
985
+ "model": global_config.get("model", ""),
986
+ "source": "global_default",
987
+ "audit_trail": audit_trail,
988
+ }
989
+
990
+ # 3. Environment fallback: detect from well-known env vars
991
+ audit_trail["checked_sources"].append("env_fallback")
992
+
993
+ for provider_name, provider_info in KNOWN_PROVIDERS.items():
994
+ env_var = provider_info.get("api_key_env", "")
995
+ if env_var and os.environ.get(env_var):
996
+ audit_trail["resolution_source"] = "env_fallback"
997
+ audit_trail["detected_provider"] = provider_name
998
+ return {
999
+ "provider_name": provider_name,
1000
+ "base_url": os.environ.get(provider_info.get("base_url_env", ""), provider_info.get("default_base_url", "")),
1001
+ "api_key_ref": env_var,
1002
+ "api_key_value": os.environ.get(env_var, ""),
1003
+ "endpoint_kind": provider_info.get("endpoint_kind", "openai_compatible"),
1004
+ "model": "",
1005
+ "source": "env_fallback",
1006
+ "audit_trail": audit_trail,
1007
+ }
1008
+
1009
+ # No provider found
1010
+ raise ProviderRegistryError(
1011
+ f"No provider configured for {resolved.name}. "
1012
+ f"Use '0dai provider switch --target . --provider NAME' or export ANTHROPIC_API_KEY/OPENAI_API_KEY."
1013
+ )
1014
+
1015
+
1016
+ def switch_provider(
1017
+ target: pathlib.Path,
1018
+ provider_name: str,
1019
+ *,
1020
+ base_url: str = "",
1021
+ api_key_ref: str = "",
1022
+ model: str = "",
1023
+ ) -> dict[str, Any]:
1024
+ """Switch a repo to a specific provider.
1025
+
1026
+ Args:
1027
+ target: Repo root path
1028
+ provider_name: Provider name (anthropic, openai, ruapi, etc.)
1029
+ base_url: Optional custom base URL (uses provider default if empty)
1030
+ api_key_ref: Optional API key ref (env var or llmkey_xxx)
1031
+ model: Optional default model
1032
+
1033
+ Returns:
1034
+ New profile dict
1035
+
1036
+ Raises:
1037
+ ProviderRegistryError: If provider unknown or credentials missing
1038
+ """
1039
+ normalized = provider_name.lower().strip()
1040
+ if normalized not in KNOWN_PROVIDERS:
1041
+ raise ProviderRegistryError(
1042
+ f"Unknown provider '{provider_name}'. "
1043
+ f"Supported: {', '.join(sorted(KNOWN_PROVIDERS.keys()))}"
1044
+ )
1045
+
1046
+ provider_info = KNOWN_PROVIDERS[normalized]
1047
+
1048
+ # Determine base URL
1049
+ if not base_url:
1050
+ base_url = provider_info.get("default_base_url", "")
1051
+
1052
+ # Determine API key reference
1053
+ if not api_key_ref:
1054
+ api_key_ref = provider_info.get("api_key_env", "")
1055
+
1056
+ # Verify credentials exist
1057
+ api_key_value = ""
1058
+ if api_key_ref.startswith("llmkey_"):
1059
+ api_key_value = load_secret(api_key_ref)
1060
+ else:
1061
+ api_key_value = os.environ.get(api_key_ref, "")
1062
+
1063
+ if not api_key_value:
1064
+ # Try standard env var
1065
+ env_var = provider_info.get("api_key_env", "")
1066
+ if env_var:
1067
+ api_key_value = os.environ.get(env_var, "")
1068
+ if api_key_value:
1069
+ api_key_ref = env_var
1070
+
1071
+ if not api_key_value:
1072
+ raise ProviderRegistryError(
1073
+ f"Cannot switch to '{normalized}': no API key found. "
1074
+ f"Export {provider_info.get('api_key_env', 'API_KEY')}=... first."
1075
+ )
1076
+
1077
+ resolved = target.resolve()
1078
+ old_profile = _load_repo_profile(resolved)
1079
+
1080
+ new_profile = {
1081
+ "schema": 1,
1082
+ "provider_name": normalized,
1083
+ "base_url": base_url,
1084
+ "api_key_ref": api_key_ref,
1085
+ "model": model,
1086
+ "updated_at": _now_iso(),
1087
+ }
1088
+
1089
+ _save_repo_profile(resolved, new_profile)
1090
+
1091
+ # Audit log
1092
+ write_audit_event(
1093
+ event_type="switch",
1094
+ target=resolved,
1095
+ old=old_profile if old_profile else None,
1096
+ new=new_profile,
1097
+ )
1098
+
1099
+ return new_profile
1100
+
1101
+
1102
+ def list_providers_for_repo(target: pathlib.Path) -> list[dict[str, Any]]:
1103
+ """List all providers and their status for a repo.
1104
+
1105
+ Returns list of provider dicts with 'source' indicator.
1106
+ """
1107
+ resolved = target.resolve()
1108
+ repo_profile = _load_repo_profile(resolved)
1109
+ global_config = _load_global_providers()
1110
+
1111
+ result = []
1112
+ active_provider = repo_profile.get("provider_name") or global_config.get("default_provider") or ""
1113
+
1114
+ for name, info in sorted(KNOWN_PROVIDERS.items()):
1115
+ env_var = info.get("api_key_env", "")
1116
+ has_key = bool(os.environ.get(env_var)) if env_var else False
1117
+
1118
+ # Determine source
1119
+ source = ""
1120
+ if repo_profile.get("provider_name") == name:
1121
+ source = "repo"
1122
+ elif global_config.get("default_provider") == name:
1123
+ source = "global"
1124
+ elif has_key:
1125
+ source = "env"
1126
+
1127
+ result.append({
1128
+ "name": name,
1129
+ "endpoint_kind": info.get("endpoint_kind", ""),
1130
+ "base_url": repo_profile.get("base_url") if source == "repo" else info.get("default_base_url", ""),
1131
+ "has_credentials": has_key or bool(load_secret(repo_profile.get("api_key_ref", ""))),
1132
+ "source": source,
1133
+ "active": name == active_provider,
1134
+ })
1135
+
1136
+ return result
1137
+
1138
+
1139
+ def build_provider_env(provider_config: dict[str, Any]) -> dict[str, str]:
1140
+ """Build environment variables for agent subprocess from provider config.
1141
+
1142
+ Args:
1143
+ provider_config: Output from resolve_for_repo()
1144
+
1145
+ Returns:
1146
+ Dict of env vars to inject into agent subprocess
1147
+ """
1148
+ provider_name = provider_config.get("provider_name", "")
1149
+ provider_info = KNOWN_PROVIDERS.get(provider_name, {})
1150
+
1151
+ env = {}
1152
+
1153
+ base_url = provider_config.get("base_url", "")
1154
+ api_key = provider_config.get("api_key_value", "")
1155
+ endpoint_kind = provider_config.get("endpoint_kind", "")
1156
+
1157
+ if endpoint_kind == "anthropic_compatible":
1158
+ if base_url:
1159
+ env["ANTHROPIC_BASE_URL"] = base_url
1160
+ if api_key:
1161
+ env["ANTHROPIC_API_KEY"] = api_key
1162
+ elif endpoint_kind == "openai_compatible":
1163
+ if base_url:
1164
+ env["OPENAI_BASE_URL"] = base_url
1165
+ if api_key:
1166
+ env["OPENAI_API_KEY"] = api_key
1167
+ # Also set provider-specific vars
1168
+ env_prefix = provider_info.get("env_prefix", "")
1169
+ if env_prefix and env_prefix not in ("OPENAI", "ANTHROPIC"):
1170
+ base_url_env = provider_info.get("base_url_env", "")
1171
+ api_key_env = provider_info.get("api_key_env", "")
1172
+ if base_url_env and base_url:
1173
+ env[base_url_env] = base_url
1174
+ if api_key_env and api_key:
1175
+ env[api_key_env] = api_key
1176
+ elif endpoint_kind == "native_google":
1177
+ if base_url:
1178
+ env["GOOGLE_BASE_URL"] = base_url
1179
+ if api_key:
1180
+ env["GOOGLE_API_KEY"] = api_key
1181
+
1182
+ # Generic ODAI vars for custom adapters
1183
+ env["ODAI_PROVIDER_NAME"] = provider_name
1184
+ env["ODAI_PROVIDER_SOURCE"] = provider_config.get("source", "")
1185
+
1186
+ return env
1187
+
1188
+
1189
+ def clear_repo_profile(target: pathlib.Path) -> bool:
1190
+ """Clear per-repo provider profile, falling back to global default.
1191
+
1192
+ Returns True if profile was cleared, False if no profile existed.
1193
+ """
1194
+ resolved = target.resolve()
1195
+ old_profile = _load_repo_profile(resolved)
1196
+
1197
+ if not old_profile:
1198
+ return False
1199
+
1200
+ path = _per_repo_profile_path(resolved)
1201
+ if path.is_file():
1202
+ path.unlink()
1203
+
1204
+ write_audit_event(
1205
+ event_type="clear",
1206
+ target=resolved,
1207
+ old=old_profile,
1208
+ new=None,
1209
+ )
1210
+
1211
+ return True