@0dai-dev/cli 4.3.6 → 4.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -11
- package/bin/0dai.js +133 -33
- package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
- package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
- package/lib/ai/registry/mcp-catalog.json +98 -0
- package/lib/commands/auth.js +2 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/doctor.js +707 -12
- package/lib/commands/experience.js +40 -5
- package/lib/commands/feedback.js +157 -15
- package/lib/commands/gh.js +26 -0
- package/lib/commands/graph.js +9 -4
- package/lib/commands/heatmap.js +1 -1
- package/lib/commands/init.js +298 -27
- package/lib/commands/mcp.js +111 -33
- package/lib/commands/models.js +138 -41
- package/lib/commands/play.js +20 -4
- package/lib/commands/provider.js +30 -59
- package/lib/commands/quota.js +1 -1
- package/lib/commands/receipt.js +1 -1
- package/lib/commands/run.js +14 -6
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +176 -11
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/trust.js +1 -1
- package/lib/commands/update.js +184 -38
- package/lib/commands/usage.js +1 -1
- package/lib/commands/validate.js +32 -3
- package/lib/commands/vault.js +43 -8
- package/lib/python/__init__.py +0 -0
- package/lib/python/agent_quotas.py +525 -0
- package/lib/python/anomaly_alert.py +397 -0
- package/lib/python/anti_pattern_detector.py +799 -0
- package/lib/python/auth.py +443 -0
- package/lib/python/capi_profile_guard.py +477 -0
- package/lib/python/compliance_report.py +581 -0
- package/lib/python/drift_detector.py +388 -0
- package/lib/python/experience_pipeline.py +1130 -0
- package/lib/python/graph.py +19 -0
- package/lib/python/graph_core.py +293 -0
- package/lib/python/graph_io.py +179 -0
- package/lib/python/graph_legacy.py +2052 -0
- package/lib/python/graph_legacy_helpers.py +221 -0
- package/lib/python/graph_outcomes_core.py +85 -0
- package/lib/python/graph_queries.py +171 -0
- package/lib/python/graph_slice.py +198 -0
- package/lib/python/graph_slicer.py +576 -0
- package/lib/python/graph_slicer_cli.py +60 -0
- package/lib/python/graph_validation.py +64 -0
- package/lib/python/heatmap.py +943 -0
- package/lib/python/json_utils.py +193 -0
- package/lib/python/mcp_exposure_check.py +247 -0
- package/lib/python/model_router.py +1434 -0
- package/lib/python/project_manager.py +621 -0
- package/lib/python/provider_profiles.py +1618 -0
- package/lib/python/provider_registry.py +1211 -0
- package/lib/python/provider_registry_cli.py +125 -0
- package/lib/python/receipt_png.py +727 -0
- package/lib/python/structural_memory.py +325 -0
- package/lib/python/swarm_cost.py +177 -0
- package/lib/python/usage_ledger.py +569 -0
- package/lib/scripts/mcp_tier_config.py +240 -0
- package/lib/shared.js +96 -12
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +1 -4
- package/lib/utils/constants.js +7 -1
- package/lib/utils/identity.js +184 -0
- package/lib/utils/mcp-auth.js +81 -15
- package/lib/utils/plan.js +1 -1
- package/lib/vault/index.js +19 -3
- package/lib/vault/storage.js +21 -2
- package/lib/wizard.js +5 -2
- package/package.json +9 -3
- package/scripts/build-python-bundle.js +106 -0
- package/scripts/build-tui.js +14 -1
- package/scripts/harvest_experience.py +523 -0
- 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
|