@0dai-dev/cli 4.3.5 → 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.
- package/README.md +12 -11
- package/bin/0dai.js +214 -40
- 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 +55 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/detect.js +10 -4
- package/lib/commands/doctor.js +545 -26
- package/lib/commands/experience.js +40 -5
- package/lib/commands/export.js +73 -0
- 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 +222 -30
- package/lib/commands/mcp.js +129 -21
- package/lib/commands/models.js +138 -41
- 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 +18 -7
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +44 -11
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/trust.js +286 -0
- 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 +46 -9
- 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 +934 -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 +97 -14
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +230 -11
- package/lib/utils/constants.js +7 -1
- package/lib/utils/export-bundler.js +285 -0
- package/lib/utils/identity.js +198 -1
- 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,1618 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local-first provider profiles for external model subscriptions and API keys.
|
|
3
|
+
|
|
4
|
+
`native_subscription` is the Claude OAuth lane; API-key and custom-endpoint
|
|
5
|
+
profiles back the RuAPI/opencode and other provider-backed lanes.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import getpass
|
|
11
|
+
import ipaddress
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import pathlib
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import socket
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ROOT = pathlib.Path.home() / ".0dai" / "providers"
|
|
26
|
+
PROFILES_PATH = ROOT / "profiles.json"
|
|
27
|
+
BINDINGS_PATH = ROOT / "bindings.json"
|
|
28
|
+
RUNTIME_ROOT = ROOT / "runtime"
|
|
29
|
+
SECRETS_ROOT = pathlib.Path.home() / ".config" / "secrets" / "providers"
|
|
30
|
+
LAUNCHER_ROOT = pathlib.Path.home() / ".local" / "bin"
|
|
31
|
+
SCRIPT_PATH = pathlib.Path(__file__).resolve()
|
|
32
|
+
SCHEMA_URL = "https://opencode.ai/config.json"
|
|
33
|
+
|
|
34
|
+
PROVIDER_KINDS = {
|
|
35
|
+
"anthropic_compatible",
|
|
36
|
+
"google_ai",
|
|
37
|
+
"openai_compatible",
|
|
38
|
+
}
|
|
39
|
+
AUTH_MODES = {
|
|
40
|
+
"native_subscription",
|
|
41
|
+
"api_key",
|
|
42
|
+
"api_key_custom_endpoint",
|
|
43
|
+
}
|
|
44
|
+
SUPPORTED_AGENTS = {"claude", "capi", "gemini", "opencode"}
|
|
45
|
+
WRAPPED_AGENTS = {"claude", "capi", "gemini", "opencode"}
|
|
46
|
+
CAPI_AGENT = "capi"
|
|
47
|
+
CAPI_AGENT_ALIASES = {"capi", "claude-api", "claude_api_custom", "claude-api-custom"}
|
|
48
|
+
REQUEST_TIMEOUT_SECONDS = 45
|
|
49
|
+
DEFAULT_TEST_PROMPT = "Reply with exactly OK"
|
|
50
|
+
DEFAULT_HEADERS = {
|
|
51
|
+
"User-Agent": "0dai-provider-profiles/1",
|
|
52
|
+
}
|
|
53
|
+
GOOGLE_API_CLIENT = "0dai-provider-profiles/1"
|
|
54
|
+
GEMINI_DEFAULT_APPROVAL_MODE = "auto_edit"
|
|
55
|
+
|
|
56
|
+
PROVIDER_SPECS: dict[str, dict[str, Any]] = {
|
|
57
|
+
"anthropic_compatible": {
|
|
58
|
+
"secret_env_key": "ANTHROPIC_API_KEY",
|
|
59
|
+
"default_base_url": "https://api.anthropic.com",
|
|
60
|
+
"default_model": "claude-sonnet-4-20250514",
|
|
61
|
+
"opencode_package": "@ai-sdk/anthropic",
|
|
62
|
+
"default_cli_targets": ["capi", "opencode"],
|
|
63
|
+
},
|
|
64
|
+
"google_ai": {
|
|
65
|
+
"secret_env_key": "GEMINI_API_KEY",
|
|
66
|
+
"default_base_url": "https://generativelanguage.googleapis.com/v1beta",
|
|
67
|
+
"default_model": "gemini-2.5-pro",
|
|
68
|
+
"default_cli_targets": ["gemini"],
|
|
69
|
+
},
|
|
70
|
+
"openai_compatible": {
|
|
71
|
+
"secret_env_key": "OPENAI_API_KEY",
|
|
72
|
+
"default_base_url": "https://api.openai.com/v1",
|
|
73
|
+
"default_model": "gpt-5.4-mini",
|
|
74
|
+
"opencode_package": "@ai-sdk/openai",
|
|
75
|
+
"default_cli_targets": ["capi", "opencode"],
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
AGENT_LAUNCHERS = {
|
|
80
|
+
"claude": LAUNCHER_ROOT / "0dai-claude-managed",
|
|
81
|
+
"gemini": LAUNCHER_ROOT / "0dai-gemini-managed",
|
|
82
|
+
"opencode": LAUNCHER_ROOT / "0dai-opencode-managed",
|
|
83
|
+
CAPI_AGENT: LAUNCHER_ROOT / "0dai-capi-managed",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
UNDERLYING_BINARIES = {
|
|
87
|
+
"claude": "claude",
|
|
88
|
+
"gemini": "gemini",
|
|
89
|
+
"opencode": "opencode",
|
|
90
|
+
CAPI_AGENT: "opencode",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
ENV_KEY_ALIASES = {
|
|
94
|
+
"GEMINI_API_KEY": ("GOOGLE_API_KEY",),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
CLAUDE_AUTH_ENV_KEYS = (
|
|
98
|
+
"CLAUDE_CONFIG_DIR",
|
|
99
|
+
"XDG_CONFIG_HOME",
|
|
100
|
+
"XDG_CACHE_HOME",
|
|
101
|
+
"XDG_STATE_HOME",
|
|
102
|
+
"XDG_DATA_HOME",
|
|
103
|
+
"XDG_RUNTIME_DIR",
|
|
104
|
+
"USER",
|
|
105
|
+
"LOGNAME",
|
|
106
|
+
"SHELL",
|
|
107
|
+
)
|
|
108
|
+
CLAUDE_AUTH_STRIP_KEYS = (
|
|
109
|
+
"ANTHROPIC_API_KEY",
|
|
110
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
111
|
+
"ANTHROPIC_BASE_URL",
|
|
112
|
+
"ANTHROPIC_MODEL",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _now() -> str:
|
|
117
|
+
return time.strftime("%Y-%m-%dT%H:%M:%S+00:00", time.gmtime())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _canonical_agent_name(agent: str) -> str:
|
|
121
|
+
normalized = str(agent or "").strip().lower()
|
|
122
|
+
if normalized in CAPI_AGENT_ALIASES:
|
|
123
|
+
return CAPI_AGENT
|
|
124
|
+
return normalized
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def setup_claude_auth_env(
|
|
128
|
+
env: dict[str, str] | None = None,
|
|
129
|
+
*,
|
|
130
|
+
agent: str = "claude",
|
|
131
|
+
) -> dict[str, str]:
|
|
132
|
+
"""Preserve Claude login state and strip provider auth overrides.
|
|
133
|
+
|
|
134
|
+
This is intentionally Claude-only. Subscription auth depends on the
|
|
135
|
+
interactive ~/.claude login surface, so callers must not reuse it for
|
|
136
|
+
capi or other provider-backed modes.
|
|
137
|
+
"""
|
|
138
|
+
canonical = _canonical_agent_name(agent)
|
|
139
|
+
assert canonical == "claude", f"claude auth setup only supports claude, got {agent}"
|
|
140
|
+
merged = dict(env or {})
|
|
141
|
+
for key in CLAUDE_AUTH_STRIP_KEYS:
|
|
142
|
+
merged.pop(key, None)
|
|
143
|
+
for key in CLAUDE_AUTH_ENV_KEYS:
|
|
144
|
+
value = os.environ.get(key, "").strip()
|
|
145
|
+
if value:
|
|
146
|
+
merged[key] = value
|
|
147
|
+
return merged
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _normalize_path(path: pathlib.Path | str) -> str:
|
|
151
|
+
return str(pathlib.Path(path).resolve())
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _default_profiles_payload() -> dict[str, Any]:
|
|
155
|
+
return {"profiles": []}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _default_bindings_payload() -> dict[str, Any]:
|
|
159
|
+
return {"defaults": {}, "projects": {}}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _ensure_json_parent(path: pathlib.Path) -> None:
|
|
163
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _load_json(path: pathlib.Path, default: dict[str, Any]) -> dict[str, Any]:
|
|
167
|
+
if not path.is_file():
|
|
168
|
+
return json.loads(json.dumps(default))
|
|
169
|
+
try:
|
|
170
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
171
|
+
except (json.JSONDecodeError, OSError):
|
|
172
|
+
return json.loads(json.dumps(default))
|
|
173
|
+
if not isinstance(data, dict):
|
|
174
|
+
return json.loads(json.dumps(default))
|
|
175
|
+
return data
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _write_json(path: pathlib.Path, payload: dict[str, Any]) -> None:
|
|
179
|
+
_ensure_json_parent(path)
|
|
180
|
+
path.write_text(
|
|
181
|
+
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
|
|
182
|
+
encoding="utf-8",
|
|
183
|
+
)
|
|
184
|
+
try:
|
|
185
|
+
os.chmod(path, 0o600)
|
|
186
|
+
except OSError:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _safe_slug(text: str) -> str:
|
|
191
|
+
lowered = re.sub(r"[^a-z0-9]+", "-", str(text or "").strip().lower()).strip("-")
|
|
192
|
+
return lowered or "profile"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _profile_secret_path(profile_id: str) -> pathlib.Path:
|
|
196
|
+
return SECRETS_ROOT / f"{profile_id}.env"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _profile_runtime_root(agent: str, profile_id: str) -> pathlib.Path:
|
|
200
|
+
return RUNTIME_ROOT / _canonical_agent_name(agent) / profile_id
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _runtime_home(agent: str, profile_id: str) -> pathlib.Path:
|
|
204
|
+
return _profile_runtime_root(agent, profile_id) / "home"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _runtime_opencode_config(agent: str, profile_id: str) -> pathlib.Path:
|
|
208
|
+
return _profile_runtime_root(agent, profile_id) / "opencode.json"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _load_profiles() -> dict[str, Any]:
|
|
212
|
+
return _load_json(PROFILES_PATH, _default_profiles_payload())
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _save_profiles(payload: dict[str, Any]) -> None:
|
|
216
|
+
_write_json(PROFILES_PATH, payload)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _load_bindings() -> dict[str, Any]:
|
|
220
|
+
return _load_json(BINDINGS_PATH, _default_bindings_payload())
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _save_bindings(payload: dict[str, Any]) -> None:
|
|
224
|
+
_write_json(BINDINGS_PATH, payload)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _iter_profiles() -> list[dict[str, Any]]:
|
|
228
|
+
payload = _load_profiles()
|
|
229
|
+
profiles = payload.get("profiles")
|
|
230
|
+
if not isinstance(profiles, list):
|
|
231
|
+
return []
|
|
232
|
+
out: list[dict[str, Any]] = []
|
|
233
|
+
for item in profiles:
|
|
234
|
+
if isinstance(item, dict):
|
|
235
|
+
out.append(item)
|
|
236
|
+
return out
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _find_profile(profile_id: str) -> dict[str, Any] | None:
|
|
240
|
+
for profile in _iter_profiles():
|
|
241
|
+
if str(profile.get("id") or "") == profile_id:
|
|
242
|
+
return profile
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _normalize_headers(pairs: list[str]) -> dict[str, str]:
|
|
247
|
+
headers: dict[str, str] = {}
|
|
248
|
+
for raw in pairs:
|
|
249
|
+
if "=" not in raw:
|
|
250
|
+
raise ValueError(f"invalid header '{raw}' — use Name=Value")
|
|
251
|
+
key, value = raw.split("=", 1)
|
|
252
|
+
key = key.strip()
|
|
253
|
+
value = value.strip()
|
|
254
|
+
if not key:
|
|
255
|
+
raise ValueError(f"invalid header '{raw}' — header name required")
|
|
256
|
+
headers[key] = value
|
|
257
|
+
return headers
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _normalize_models(values: list[str]) -> list[str]:
|
|
261
|
+
models: list[str] = []
|
|
262
|
+
for raw in values:
|
|
263
|
+
for part in str(raw or "").split(","):
|
|
264
|
+
model = part.strip()
|
|
265
|
+
if model:
|
|
266
|
+
models.append(model)
|
|
267
|
+
return sorted(set(models))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _parse_json_object(raw: str, field_name: str) -> dict[str, Any]:
|
|
271
|
+
text = str(raw or "").strip()
|
|
272
|
+
if not text:
|
|
273
|
+
return {}
|
|
274
|
+
try:
|
|
275
|
+
payload = json.loads(text)
|
|
276
|
+
except json.JSONDecodeError as exc:
|
|
277
|
+
raise ValueError(f"{field_name} must be valid JSON: {exc}") from exc
|
|
278
|
+
if not isinstance(payload, dict):
|
|
279
|
+
raise ValueError(f"{field_name} must be a JSON object")
|
|
280
|
+
return payload
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _profile_models(profile: dict[str, Any]) -> list[str]:
|
|
284
|
+
default_model = str(profile.get("default_model") or "").strip()
|
|
285
|
+
models = []
|
|
286
|
+
raw_models = profile.get("available_models")
|
|
287
|
+
if isinstance(raw_models, list):
|
|
288
|
+
models.extend(str(item).strip() for item in raw_models if str(item).strip())
|
|
289
|
+
if default_model:
|
|
290
|
+
models.append(default_model)
|
|
291
|
+
return sorted(set(models))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _quota_summary(quota: dict[str, Any]) -> dict[str, Any]:
|
|
295
|
+
if not isinstance(quota, dict):
|
|
296
|
+
return {}
|
|
297
|
+
keys = (
|
|
298
|
+
"label",
|
|
299
|
+
"limit",
|
|
300
|
+
"limit_reset",
|
|
301
|
+
"limit_remaining",
|
|
302
|
+
"include_byok_in_limit",
|
|
303
|
+
"usage",
|
|
304
|
+
"usage_daily",
|
|
305
|
+
"usage_weekly",
|
|
306
|
+
"usage_monthly",
|
|
307
|
+
"byok_usage",
|
|
308
|
+
"byok_usage_daily",
|
|
309
|
+
"byok_usage_weekly",
|
|
310
|
+
"byok_usage_monthly",
|
|
311
|
+
"is_free_tier",
|
|
312
|
+
"is_management_key",
|
|
313
|
+
"is_provisioning_key",
|
|
314
|
+
"expires_at",
|
|
315
|
+
"rate_limit",
|
|
316
|
+
)
|
|
317
|
+
return {key: quota[key] for key in keys if key in quota}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _normalize_cli_targets(
|
|
321
|
+
provider_kind: str,
|
|
322
|
+
auth_mode: str,
|
|
323
|
+
cli_targets: list[str],
|
|
324
|
+
) -> list[str]:
|
|
325
|
+
normalized = [_canonical_agent_name(item) for item in cli_targets if item]
|
|
326
|
+
normalized = [item for item in normalized if item in SUPPORTED_AGENTS]
|
|
327
|
+
if normalized:
|
|
328
|
+
return sorted(set(normalized))
|
|
329
|
+
if auth_mode == "native_subscription":
|
|
330
|
+
return ["claude"]
|
|
331
|
+
spec = PROVIDER_SPECS[provider_kind]
|
|
332
|
+
return list(spec.get("default_cli_targets") or [])
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _normalize_base_url(provider_kind: str, auth_mode: str, base_url: str) -> str:
|
|
336
|
+
normalized = str(base_url or "").strip().rstrip("/")
|
|
337
|
+
if auth_mode == "native_subscription":
|
|
338
|
+
return ""
|
|
339
|
+
if normalized:
|
|
340
|
+
return normalized
|
|
341
|
+
return str(PROVIDER_SPECS[provider_kind]["default_base_url"])
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _invoke_base_url(provider_kind: str, base_url: str) -> str:
|
|
345
|
+
normalized = str(base_url or "").strip().rstrip("/")
|
|
346
|
+
if not normalized:
|
|
347
|
+
return ""
|
|
348
|
+
if provider_kind in {"anthropic_compatible", "openai_compatible"}:
|
|
349
|
+
return normalized[: -len("/v1")] if normalized.endswith("/v1") else normalized
|
|
350
|
+
if provider_kind == "google_ai":
|
|
351
|
+
return normalized[: -len("/v1beta")] if normalized.endswith("/v1beta") else normalized
|
|
352
|
+
return normalized
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# Extra reject net not covered uniformly by ipaddress.is_private across Python
|
|
356
|
+
# versions: RFC 6598 carrier-grade NAT shared address space.
|
|
357
|
+
_CGNAT_NET = ipaddress.ip_network("100.64.0.0/10")
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _ip_is_blocked(ip: ipaddress._BaseAddress) -> bool:
|
|
361
|
+
"""True if `ip` points at a non-public destination we must not fetch.
|
|
362
|
+
|
|
363
|
+
Collapses IPv4-mapped IPv6 (`::ffff:a.b.c.d`) first so the IPv4 rules apply
|
|
364
|
+
— without this, is_private/is_loopback return False for the mapped form and
|
|
365
|
+
the guard is trivially bypassable.
|
|
366
|
+
"""
|
|
367
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
368
|
+
ip = ip.ipv4_mapped
|
|
369
|
+
if (
|
|
370
|
+
ip.is_private
|
|
371
|
+
or ip.is_loopback
|
|
372
|
+
or ip.is_link_local # includes 169.254.0.0/16 → cloud metadata 169.254.169.254
|
|
373
|
+
or ip.is_reserved
|
|
374
|
+
or ip.is_multicast
|
|
375
|
+
or ip.is_unspecified
|
|
376
|
+
):
|
|
377
|
+
return True
|
|
378
|
+
if isinstance(ip, ipaddress.IPv4Address) and ip in _CGNAT_NET:
|
|
379
|
+
return True
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _assert_public_base_url(url: str) -> None:
|
|
384
|
+
"""Reject `url` if its host resolves to any non-public address (SSRF guard).
|
|
385
|
+
|
|
386
|
+
Requires http/https, resolves the host via DNS, and blocks the request if
|
|
387
|
+
ANY resolved IP is private/loopback/link-local/reserved/multicast/
|
|
388
|
+
unspecified or in the CGNAT range. Defends provider profiles whose
|
|
389
|
+
user-supplied base_url could otherwise reach internal targets
|
|
390
|
+
(link-local metadata 169.254.169.254, localhost, redis, etc.) from a
|
|
391
|
+
free-tier-authenticated request.
|
|
392
|
+
|
|
393
|
+
HTTP redirects are covered: _request_json routes through an opener whose
|
|
394
|
+
redirect handler re-runs this check on every hop, so a public host cannot
|
|
395
|
+
302 to an internal target.
|
|
396
|
+
|
|
397
|
+
SECURITY CAVEAT — this is still a TOCTOU check, not a socket pin.
|
|
398
|
+
`getaddrinfo` runs here at check time, while the actual socket connect
|
|
399
|
+
happens later inside urllib, so a DNS-rebind attacker who answers
|
|
400
|
+
differently for the two lookups can still slip through. Fully closing that
|
|
401
|
+
hole (pinning the resolved IP into the connect) is out of scope here; this
|
|
402
|
+
guard raises the bar against the direct internal-URL and redirect-to-
|
|
403
|
+
internal cases, which are the reported vulnerability. Errors never echo the
|
|
404
|
+
resolved internal IP back to the caller.
|
|
405
|
+
"""
|
|
406
|
+
parsed = urllib.parse.urlsplit(str(url or "").strip())
|
|
407
|
+
scheme = (parsed.scheme or "").lower()
|
|
408
|
+
if scheme not in {"http", "https"}:
|
|
409
|
+
raise ValueError(f"base_url scheme must be http or https, got: {scheme or '(none)'}")
|
|
410
|
+
host = parsed.hostname # already lowercased, brackets stripped for [::1]
|
|
411
|
+
if not host:
|
|
412
|
+
raise ValueError("base_url is missing a host")
|
|
413
|
+
# Strip any IPv6 zone id (e.g. fe80::1%eth0) before parsing.
|
|
414
|
+
host_for_ip = host.split("%", 1)[0]
|
|
415
|
+
|
|
416
|
+
candidates: list[ipaddress._BaseAddress] = []
|
|
417
|
+
# If host is already a literal IP, skip DNS entirely.
|
|
418
|
+
try:
|
|
419
|
+
candidates.append(ipaddress.ip_address(host_for_ip))
|
|
420
|
+
except ValueError:
|
|
421
|
+
try:
|
|
422
|
+
infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
|
|
423
|
+
except socket.gaierror as exc:
|
|
424
|
+
raise ValueError(f"base_url host could not be resolved: {host}") from exc
|
|
425
|
+
for info in infos:
|
|
426
|
+
sockaddr = info[4]
|
|
427
|
+
raw_ip = str(sockaddr[0]).split("%", 1)[0]
|
|
428
|
+
try:
|
|
429
|
+
candidates.append(ipaddress.ip_address(raw_ip))
|
|
430
|
+
except ValueError:
|
|
431
|
+
# Unparseable address from the resolver — refuse rather than
|
|
432
|
+
# fall through to an outbound request we cannot vet.
|
|
433
|
+
raise ValueError(f"base_url host resolved to an unusable address: {host}") from None
|
|
434
|
+
if not candidates:
|
|
435
|
+
raise ValueError(f"base_url host could not be resolved: {host}")
|
|
436
|
+
for ip in candidates:
|
|
437
|
+
if _ip_is_blocked(ip):
|
|
438
|
+
# Deliberately do NOT include the resolved IP — avoids leaking the
|
|
439
|
+
# internal address back to an attacker probing the network.
|
|
440
|
+
raise ValueError(f"base_url host {host} resolves to a non-public address; refusing request")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
class _SSRFGuardedRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
444
|
+
"""Re-run the SSRF guard on every redirect target.
|
|
445
|
+
|
|
446
|
+
urllib follows 3xx redirects by default, so without this a public host
|
|
447
|
+
could 302 to an internal address (e.g. the cloud metadata endpoint) and
|
|
448
|
+
bypass the one-shot pre-request check. Validating each hop closes that
|
|
449
|
+
bypass while still permitting legitimate public->public redirects.
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
453
|
+
# Overrides stdlib HTTPRedirectHandler.redirect_request signature.
|
|
454
|
+
_assert_public_base_url(newurl)
|
|
455
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# Opener built once; both the SSRF redirect guard and default handlers apply.
|
|
459
|
+
_SSRF_OPENER = urllib.request.build_opener(_SSRFGuardedRedirectHandler())
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _default_model(provider_kind: str, requested: str) -> str:
|
|
463
|
+
normalized = str(requested or "").strip()
|
|
464
|
+
if normalized:
|
|
465
|
+
return normalized
|
|
466
|
+
return str(PROVIDER_SPECS[provider_kind]["default_model"])
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _validate_profile_shape(profile: dict[str, Any]) -> None:
|
|
470
|
+
provider_kind = str(profile.get("provider_kind") or "")
|
|
471
|
+
auth_mode = str(profile.get("auth_mode") or "")
|
|
472
|
+
if provider_kind not in PROVIDER_KINDS:
|
|
473
|
+
raise ValueError(f"unsupported provider_kind: {provider_kind}")
|
|
474
|
+
if auth_mode not in AUTH_MODES:
|
|
475
|
+
raise ValueError(f"unsupported auth_mode: {auth_mode}")
|
|
476
|
+
if auth_mode == "native_subscription":
|
|
477
|
+
if provider_kind != "anthropic_compatible":
|
|
478
|
+
raise ValueError("native_subscription is currently supported only for anthropic_compatible")
|
|
479
|
+
elif not str(profile.get("secret_ref") or "").strip():
|
|
480
|
+
raise ValueError("secret_ref required for API-key profiles")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _validate_profile_for_agent(profile: dict[str, Any], agent: str) -> None:
|
|
484
|
+
agent = _canonical_agent_name(agent)
|
|
485
|
+
provider_kind = str(profile.get("provider_kind") or "")
|
|
486
|
+
auth_mode = str(profile.get("auth_mode") or "")
|
|
487
|
+
cli_targets = {str(item) for item in profile.get("cli_targets") or []}
|
|
488
|
+
if agent not in cli_targets:
|
|
489
|
+
raise ValueError(f"profile {profile.get('id')} is not enabled for agent {agent}")
|
|
490
|
+
if agent == "claude":
|
|
491
|
+
if auth_mode != "native_subscription":
|
|
492
|
+
raise ValueError("claude managed wrapper currently supports only native_subscription profiles")
|
|
493
|
+
if provider_kind != "anthropic_compatible":
|
|
494
|
+
raise ValueError("claude managed wrapper requires anthropic_compatible provider_kind")
|
|
495
|
+
return
|
|
496
|
+
if agent == CAPI_AGENT:
|
|
497
|
+
if provider_kind not in {"anthropic_compatible", "openai_compatible"}:
|
|
498
|
+
raise ValueError("Claude API Custom requires OpenAI-compatible or Anthropic-compatible profile")
|
|
499
|
+
if auth_mode not in {"api_key", "api_key_custom_endpoint"}:
|
|
500
|
+
raise ValueError("Claude API Custom requires API-key profile")
|
|
501
|
+
return
|
|
502
|
+
if agent == "gemini":
|
|
503
|
+
if provider_kind != "google_ai":
|
|
504
|
+
raise ValueError("gemini wrapper requires google_ai profile")
|
|
505
|
+
if auth_mode not in {"api_key", "api_key_custom_endpoint"}:
|
|
506
|
+
raise ValueError("gemini wrapper requires API-key profile")
|
|
507
|
+
return
|
|
508
|
+
if agent == "opencode":
|
|
509
|
+
if provider_kind not in {"anthropic_compatible", "openai_compatible"}:
|
|
510
|
+
raise ValueError("opencode wrapper supports anthropic_compatible and openai_compatible profiles only")
|
|
511
|
+
if auth_mode not in {"api_key", "api_key_custom_endpoint"}:
|
|
512
|
+
raise ValueError("opencode wrapper requires API-key profile")
|
|
513
|
+
return
|
|
514
|
+
raise ValueError(f"unsupported agent: {agent}")
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _secret_assignments(path: pathlib.Path) -> dict[str, str]:
|
|
518
|
+
if not path.is_file():
|
|
519
|
+
return {}
|
|
520
|
+
assignments: dict[str, str] = {}
|
|
521
|
+
try:
|
|
522
|
+
text = path.read_text(encoding="utf-8")
|
|
523
|
+
except OSError:
|
|
524
|
+
return {}
|
|
525
|
+
for raw_line in text.splitlines():
|
|
526
|
+
line = raw_line.strip()
|
|
527
|
+
if not line or line.startswith("#"):
|
|
528
|
+
continue
|
|
529
|
+
if line.startswith("export "):
|
|
530
|
+
line = line[len("export ") :].strip()
|
|
531
|
+
if "=" not in line:
|
|
532
|
+
continue
|
|
533
|
+
key, value = line.split("=", 1)
|
|
534
|
+
key = key.strip()
|
|
535
|
+
value = value.strip().strip('"').strip("'")
|
|
536
|
+
if key:
|
|
537
|
+
assignments[key] = value
|
|
538
|
+
return assignments
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _write_secret_file(path: pathlib.Path, key: str, value: str) -> None:
|
|
542
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
543
|
+
path.write_text(f'{key}="{value}"\n', encoding="utf-8")
|
|
544
|
+
try:
|
|
545
|
+
os.chmod(path, 0o600)
|
|
546
|
+
except OSError:
|
|
547
|
+
pass
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _secret_value_from_args(args: argparse.Namespace, secret_env_key: str) -> str:
|
|
551
|
+
env_name = str(getattr(args, "api_key_env", "") or "").strip()
|
|
552
|
+
file_name = str(getattr(args, "api_key_file", "") or "").strip()
|
|
553
|
+
if env_name:
|
|
554
|
+
value = str(os.environ.get(env_name) or "").strip()
|
|
555
|
+
if not value:
|
|
556
|
+
raise ValueError(f"environment variable {env_name} is empty")
|
|
557
|
+
return value
|
|
558
|
+
if file_name:
|
|
559
|
+
try:
|
|
560
|
+
value = pathlib.Path(file_name).read_text(encoding="utf-8").strip()
|
|
561
|
+
except OSError as exc:
|
|
562
|
+
raise ValueError(f"failed to read {file_name}: {exc}") from exc
|
|
563
|
+
if not value:
|
|
564
|
+
raise ValueError(f"file {file_name} is empty")
|
|
565
|
+
return value
|
|
566
|
+
prompt = f"Enter {secret_env_key}: "
|
|
567
|
+
value = getpass.getpass(prompt).strip()
|
|
568
|
+
if not value:
|
|
569
|
+
raise ValueError(f"{secret_env_key} is required")
|
|
570
|
+
return value
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _project_bindings_for(target: pathlib.Path) -> dict[str, str]:
|
|
574
|
+
bindings = _load_bindings()
|
|
575
|
+
projects = bindings.get("projects")
|
|
576
|
+
if not isinstance(projects, dict):
|
|
577
|
+
return {}
|
|
578
|
+
value = projects.get(_normalize_path(target))
|
|
579
|
+
return value if isinstance(value, dict) else {}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def resolve_bound_profile_id(target: pathlib.Path | None, agent: str) -> str:
|
|
583
|
+
agent = _canonical_agent_name(agent)
|
|
584
|
+
bindings = _load_bindings()
|
|
585
|
+
if target is not None:
|
|
586
|
+
project_overrides = _project_bindings_for(target)
|
|
587
|
+
if agent in project_overrides:
|
|
588
|
+
return str(project_overrides.get(agent) or "")
|
|
589
|
+
defaults = bindings.get("defaults")
|
|
590
|
+
if isinstance(defaults, dict):
|
|
591
|
+
return str(defaults.get(agent) or "")
|
|
592
|
+
return ""
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _legacy_capi_profile() -> dict[str, Any] | None:
|
|
596
|
+
try:
|
|
597
|
+
import capi_profile_guard
|
|
598
|
+
except ImportError:
|
|
599
|
+
return None
|
|
600
|
+
env_path = capi_profile_guard.ENV_FILE
|
|
601
|
+
assignments = _secret_assignments(env_path)
|
|
602
|
+
secret_key = str(assignments.get("CAPI_SECRET_ENV_KEY") or "").strip()
|
|
603
|
+
if not secret_key:
|
|
604
|
+
secret_key = "OPENAI_API_KEY" if assignments.get("OPENAI_API_KEY") else "ANTHROPIC_API_KEY"
|
|
605
|
+
provider_kind = str(assignments.get("CAPI_PROVIDER_KIND") or "").strip()
|
|
606
|
+
if provider_kind not in PROVIDER_KINDS:
|
|
607
|
+
provider_kind = (
|
|
608
|
+
"openai_compatible"
|
|
609
|
+
if secret_key.startswith("OPENAI") or secret_key.startswith("OPENROUTER")
|
|
610
|
+
else "anthropic_compatible"
|
|
611
|
+
)
|
|
612
|
+
api_key = str(assignments.get(secret_key) or "").strip()
|
|
613
|
+
if not api_key:
|
|
614
|
+
return None
|
|
615
|
+
upstream = str(assignments.get(capi_profile_guard.UPSTREAM_ENV_KEY) or "").strip().rstrip("/")
|
|
616
|
+
spec = PROVIDER_SPECS[provider_kind]
|
|
617
|
+
return {
|
|
618
|
+
"id": "__legacy_capi__",
|
|
619
|
+
"name": "Legacy Custom API",
|
|
620
|
+
"provider_kind": provider_kind,
|
|
621
|
+
"auth_mode": "api_key_custom_endpoint",
|
|
622
|
+
"base_url": upstream or str(spec["default_base_url"]),
|
|
623
|
+
"default_model": str(spec["default_model"]),
|
|
624
|
+
"headers": {},
|
|
625
|
+
"cli_targets": [CAPI_AGENT],
|
|
626
|
+
"secret_env_key": secret_key,
|
|
627
|
+
"secret_ref": "",
|
|
628
|
+
"_secret_value": api_key,
|
|
629
|
+
"status": "legacy-fallback",
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def resolve_profile(target: pathlib.Path | None, agent: str) -> dict[str, Any] | None:
|
|
634
|
+
agent = _canonical_agent_name(agent)
|
|
635
|
+
profile_id = resolve_bound_profile_id(target, agent)
|
|
636
|
+
if profile_id:
|
|
637
|
+
profile = _find_profile(profile_id)
|
|
638
|
+
if not profile:
|
|
639
|
+
raise ValueError(f"bound profile not found: {profile_id}")
|
|
640
|
+
_validate_profile_shape(profile)
|
|
641
|
+
_validate_profile_for_agent(profile, agent)
|
|
642
|
+
return dict(profile)
|
|
643
|
+
if agent == CAPI_AGENT:
|
|
644
|
+
return _legacy_capi_profile()
|
|
645
|
+
return None
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _secret_value(profile: dict[str, Any]) -> str:
|
|
649
|
+
inline = str(profile.get("_secret_value") or "").strip()
|
|
650
|
+
if inline:
|
|
651
|
+
return inline
|
|
652
|
+
secret_ref = str(profile.get("secret_ref") or "").strip()
|
|
653
|
+
if not secret_ref:
|
|
654
|
+
return ""
|
|
655
|
+
path = _profile_secret_path(str(profile.get("id") or ""))
|
|
656
|
+
return str(_secret_assignments(path).get(str(profile.get("secret_env_key") or "")) or "").strip()
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _runtime_dirs_for(agent: str, profile_id: str) -> dict[str, pathlib.Path]:
|
|
660
|
+
runtime_root = _profile_runtime_root(agent, profile_id)
|
|
661
|
+
home = runtime_root / "home"
|
|
662
|
+
return {
|
|
663
|
+
"root": runtime_root,
|
|
664
|
+
"home": home,
|
|
665
|
+
"config": home / ".config",
|
|
666
|
+
"cache": home / ".cache",
|
|
667
|
+
"state": home / ".local" / "state",
|
|
668
|
+
"data": home / ".local" / "share",
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _normalize_opencode_base_url(profile: dict[str, Any]) -> str:
|
|
673
|
+
base_url = str(profile.get("base_url") or "").strip().rstrip("/")
|
|
674
|
+
if not base_url:
|
|
675
|
+
return ""
|
|
676
|
+
if base_url.endswith("/v1") or base_url.endswith("/v1beta"):
|
|
677
|
+
return base_url
|
|
678
|
+
if str(profile.get("provider_kind") or "") == "google_ai":
|
|
679
|
+
return f"{base_url}/v1beta"
|
|
680
|
+
return f"{base_url}/v1"
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _opencode_model_id(profile: dict[str, Any], agent: str) -> str:
|
|
684
|
+
canonical_agent = _canonical_agent_name(agent)
|
|
685
|
+
provider_id = "capi" if canonical_agent == CAPI_AGENT else str(profile.get("id") or "provider")
|
|
686
|
+
model = str(profile.get("default_model") or "").strip() or str(PROVIDER_SPECS[str(profile.get("provider_kind") or "")]["default_model"])
|
|
687
|
+
if canonical_agent == CAPI_AGENT:
|
|
688
|
+
return model if model.startswith(f"{provider_id}/") else f"{provider_id}/{model}"
|
|
689
|
+
if "/" in model:
|
|
690
|
+
return model
|
|
691
|
+
return f"{provider_id}/{model}"
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _opencode_provider_block(profile: dict[str, Any], agent: str) -> tuple[str, dict[str, Any]]:
|
|
695
|
+
provider_kind = str(profile.get("provider_kind") or "")
|
|
696
|
+
spec = PROVIDER_SPECS[provider_kind]
|
|
697
|
+
provider_id = "capi" if _canonical_agent_name(agent) == CAPI_AGENT else str(profile.get("id") or "provider")
|
|
698
|
+
options: dict[str, Any] = {
|
|
699
|
+
"apiKey": "{env:%s}" % str(profile.get("secret_env_key") or spec["secret_env_key"]),
|
|
700
|
+
"timeout": 120000,
|
|
701
|
+
"chunkTimeout": 30000,
|
|
702
|
+
}
|
|
703
|
+
base_url = _normalize_opencode_base_url(profile)
|
|
704
|
+
if base_url:
|
|
705
|
+
options["baseURL"] = base_url
|
|
706
|
+
headers = profile.get("headers") if isinstance(profile.get("headers"), dict) else {}
|
|
707
|
+
if headers:
|
|
708
|
+
options["headers"] = headers
|
|
709
|
+
package = spec.get("opencode_package")
|
|
710
|
+
if not package:
|
|
711
|
+
raise ValueError(f"provider kind {provider_kind} is not supported for OpenCode-backed runtime")
|
|
712
|
+
model_names = _profile_models(profile)
|
|
713
|
+
block = {
|
|
714
|
+
"npm": package,
|
|
715
|
+
"name": str(profile.get("name") or provider_id),
|
|
716
|
+
"options": options,
|
|
717
|
+
"models": {model: {"name": model} for model in model_names},
|
|
718
|
+
}
|
|
719
|
+
return provider_id, block
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _write_opencode_config(path: pathlib.Path, payload: dict[str, Any]) -> None:
|
|
723
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
724
|
+
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _prepare_opencode_runtime(profile: dict[str, Any], agent: str) -> dict[str, Any]:
|
|
728
|
+
profile_id = str(profile.get("id") or "provider")
|
|
729
|
+
dirs = _runtime_dirs_for(agent, profile_id)
|
|
730
|
+
for key in ("config", "cache", "state", "data"):
|
|
731
|
+
dirs[key].mkdir(parents=True, exist_ok=True)
|
|
732
|
+
config_path = _runtime_opencode_config(agent, profile_id)
|
|
733
|
+
provider_id, provider_block = _opencode_provider_block(profile, agent)
|
|
734
|
+
config_payload = {
|
|
735
|
+
"$schema": SCHEMA_URL,
|
|
736
|
+
"autoupdate": False,
|
|
737
|
+
"enabled_providers": [provider_id],
|
|
738
|
+
"model": _opencode_model_id(profile, agent),
|
|
739
|
+
"provider": {provider_id: provider_block},
|
|
740
|
+
}
|
|
741
|
+
_write_opencode_config(config_path, config_payload)
|
|
742
|
+
return {
|
|
743
|
+
"config_path": str(config_path),
|
|
744
|
+
"provider_id": provider_id,
|
|
745
|
+
"home": dirs["home"],
|
|
746
|
+
"config": dirs["config"],
|
|
747
|
+
"cache": dirs["cache"],
|
|
748
|
+
"state": dirs["state"],
|
|
749
|
+
"data": dirs["data"],
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _prepare_gemini_runtime(profile: dict[str, Any], agent: str) -> dict[str, Any]:
|
|
754
|
+
profile_id = str(profile.get("id") or "provider")
|
|
755
|
+
dirs = _runtime_dirs_for(agent, profile_id)
|
|
756
|
+
for key in ("config", "cache", "state", "data"):
|
|
757
|
+
dirs[key].mkdir(parents=True, exist_ok=True)
|
|
758
|
+
gemini_dir = dirs["home"] / ".gemini"
|
|
759
|
+
gemini_env = gemini_dir / ".env"
|
|
760
|
+
settings_path = gemini_dir / "settings.json"
|
|
761
|
+
gemini_dir.mkdir(parents=True, exist_ok=True)
|
|
762
|
+
model_name = str(profile.get("default_model") or PROVIDER_SPECS["google_ai"]["default_model"])
|
|
763
|
+
_write_json(
|
|
764
|
+
settings_path,
|
|
765
|
+
{
|
|
766
|
+
"managed": True,
|
|
767
|
+
"model": {"name": model_name},
|
|
768
|
+
"general": {"defaultApprovalMode": GEMINI_DEFAULT_APPROVAL_MODE},
|
|
769
|
+
},
|
|
770
|
+
)
|
|
771
|
+
secret_key = str(profile.get("secret_env_key") or PROVIDER_SPECS["google_ai"]["secret_env_key"])
|
|
772
|
+
secret_value = _secret_value(profile)
|
|
773
|
+
if secret_value:
|
|
774
|
+
gemini_env.write_text(
|
|
775
|
+
f'{secret_key}="{secret_value}"\nGOOGLE_API_KEY="{secret_value}"\n',
|
|
776
|
+
encoding="utf-8",
|
|
777
|
+
)
|
|
778
|
+
return {
|
|
779
|
+
"home": dirs["home"],
|
|
780
|
+
"config": dirs["config"],
|
|
781
|
+
"cache": dirs["cache"],
|
|
782
|
+
"state": dirs["state"],
|
|
783
|
+
"data": dirs["data"],
|
|
784
|
+
"gemini_env": str(gemini_env),
|
|
785
|
+
"settings_path": str(settings_path),
|
|
786
|
+
"default_approval_mode": GEMINI_DEFAULT_APPROVAL_MODE,
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def resolve_runtime(target: pathlib.Path, agent: str) -> dict[str, Any]:
|
|
791
|
+
agent = _canonical_agent_name(agent)
|
|
792
|
+
binary = UNDERLYING_BINARIES.get(agent)
|
|
793
|
+
if not binary:
|
|
794
|
+
raise ValueError(f"unsupported agent: {agent}")
|
|
795
|
+
resolved = shutil.which(binary)
|
|
796
|
+
if not resolved:
|
|
797
|
+
raise ValueError(f"{binary} binary not found on PATH")
|
|
798
|
+
profile = resolve_profile(target, agent)
|
|
799
|
+
env = dict(os.environ)
|
|
800
|
+
metadata: dict[str, Any] = {
|
|
801
|
+
"agent": agent,
|
|
802
|
+
"binary": resolved,
|
|
803
|
+
"profile_id": str(profile.get("id") or "") if profile else "",
|
|
804
|
+
"provider_kind": str(profile.get("provider_kind") or "") if profile else "",
|
|
805
|
+
"auth_mode": str(profile.get("auth_mode") or "") if profile else "",
|
|
806
|
+
"runtime_backend": "",
|
|
807
|
+
"provider_model": "",
|
|
808
|
+
"secret_env_key": str(profile.get("secret_env_key") or "") if profile else "",
|
|
809
|
+
"available_models": _profile_models(profile) if profile else [],
|
|
810
|
+
"key_quota": profile.get("key_quota") if profile else {},
|
|
811
|
+
}
|
|
812
|
+
if agent == "claude":
|
|
813
|
+
env = setup_claude_auth_env(env, agent=agent)
|
|
814
|
+
if not profile:
|
|
815
|
+
return {"binary": resolved, "env": env, "argv_prefix": [], "metadata": metadata}
|
|
816
|
+
|
|
817
|
+
_validate_profile_for_agent(profile, agent)
|
|
818
|
+
metadata["profile_name"] = str(profile.get("name") or "")
|
|
819
|
+
metadata["runtime_backend"] = "provider-profile"
|
|
820
|
+
metadata["provider_model"] = str(profile.get("default_model") or "")
|
|
821
|
+
if agent == "claude":
|
|
822
|
+
# Native claude sessions stay on /root/.claude/.credentials.json
|
|
823
|
+
# subscription OAuth. Strip poisoned ANTHROPIC_* vars so a RuAPI-
|
|
824
|
+
# backed parent shell cannot override the login path.
|
|
825
|
+
for k in ("ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL"):
|
|
826
|
+
env.pop(k, None)
|
|
827
|
+
return {"binary": resolved, "env": env, "argv_prefix": [], "metadata": metadata}
|
|
828
|
+
|
|
829
|
+
secret_value = _secret_value(profile)
|
|
830
|
+
secret_env_key = str(profile.get("secret_env_key") or "")
|
|
831
|
+
if not secret_value:
|
|
832
|
+
raise ValueError(f"profile {profile.get('id')} is missing {secret_env_key}")
|
|
833
|
+
env[secret_env_key] = secret_value
|
|
834
|
+
for alias in ENV_KEY_ALIASES.get(secret_env_key, ()):
|
|
835
|
+
env[alias] = secret_value
|
|
836
|
+
|
|
837
|
+
if agent == "gemini":
|
|
838
|
+
runtime = _prepare_gemini_runtime(profile, agent)
|
|
839
|
+
env.update(
|
|
840
|
+
{
|
|
841
|
+
"HOME": str(runtime["home"]),
|
|
842
|
+
"XDG_CONFIG_HOME": str(runtime["config"]),
|
|
843
|
+
"XDG_CACHE_HOME": str(runtime["cache"]),
|
|
844
|
+
"XDG_STATE_HOME": str(runtime["state"]),
|
|
845
|
+
"XDG_DATA_HOME": str(runtime["data"]),
|
|
846
|
+
}
|
|
847
|
+
)
|
|
848
|
+
metadata.update(runtime)
|
|
849
|
+
return {"binary": resolved, "env": env, "argv_prefix": [], "metadata": metadata}
|
|
850
|
+
|
|
851
|
+
runtime = _prepare_opencode_runtime(profile, agent)
|
|
852
|
+
env.update(
|
|
853
|
+
{
|
|
854
|
+
"HOME": str(runtime["home"]),
|
|
855
|
+
"XDG_CONFIG_HOME": str(runtime["config"]),
|
|
856
|
+
"XDG_CACHE_HOME": str(runtime["cache"]),
|
|
857
|
+
"XDG_STATE_HOME": str(runtime["state"]),
|
|
858
|
+
"XDG_DATA_HOME": str(runtime["data"]),
|
|
859
|
+
"OPENCODE_CONFIG": str(runtime["config_path"]),
|
|
860
|
+
}
|
|
861
|
+
)
|
|
862
|
+
metadata.update(runtime)
|
|
863
|
+
return {"binary": resolved, "env": env, "argv_prefix": [], "metadata": metadata}
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def runtime_metadata_for_target(target: pathlib.Path | None, agent: str) -> dict[str, Any]:
|
|
867
|
+
if target is None:
|
|
868
|
+
return {}
|
|
869
|
+
try:
|
|
870
|
+
runtime = resolve_runtime(pathlib.Path(target).resolve(), agent)
|
|
871
|
+
except Exception: # noqa: BLE001 — resolve_runtime failed
|
|
872
|
+
return {}
|
|
873
|
+
metadata = runtime.get("metadata")
|
|
874
|
+
return dict(metadata) if isinstance(metadata, dict) else {}
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def wrapper_path(agent: str) -> pathlib.Path:
|
|
878
|
+
agent = _canonical_agent_name(agent)
|
|
879
|
+
path = AGENT_LAUNCHERS.get(agent)
|
|
880
|
+
if not path:
|
|
881
|
+
raise ValueError(f"unsupported agent: {agent}")
|
|
882
|
+
return path
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def ensure_agent_wrapper(agent: str) -> pathlib.Path:
|
|
886
|
+
agent = _canonical_agent_name(agent)
|
|
887
|
+
path = wrapper_path(agent)
|
|
888
|
+
script = (
|
|
889
|
+
"#!/usr/bin/env bash\n"
|
|
890
|
+
"set -euo pipefail\n"
|
|
891
|
+
f'exec python3 "{SCRIPT_PATH}" exec --agent "{agent}" --cwd "${{ODAI_PROVIDER_CWD:-$PWD}}" -- "$@"\n'
|
|
892
|
+
)
|
|
893
|
+
current = ""
|
|
894
|
+
try:
|
|
895
|
+
current = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
896
|
+
except OSError:
|
|
897
|
+
current = ""
|
|
898
|
+
if current != script:
|
|
899
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
900
|
+
path.write_text(script, encoding="utf-8")
|
|
901
|
+
path.chmod(0o755)
|
|
902
|
+
return path
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def ensure_wrappers() -> list[pathlib.Path]:
|
|
906
|
+
return [ensure_agent_wrapper(agent) for agent in AGENT_LAUNCHERS]
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def cmd_exec(args: argparse.Namespace) -> int:
|
|
910
|
+
agent = _canonical_agent_name(args.agent)
|
|
911
|
+
target = pathlib.Path(args.cwd or os.getcwd()).resolve()
|
|
912
|
+
runtime = resolve_runtime(target, agent)
|
|
913
|
+
argv = list(args.argv or [])
|
|
914
|
+
if argv and argv[0] == "--":
|
|
915
|
+
argv = argv[1:]
|
|
916
|
+
binary = str(runtime["binary"])
|
|
917
|
+
os.execvpe(binary, [binary, *argv], runtime["env"])
|
|
918
|
+
return 0
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def _masked_path(path: pathlib.Path) -> str:
|
|
922
|
+
return str(path).replace(str(pathlib.Path.home()), "~")
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def cmd_profile_add(args: argparse.Namespace) -> int:
|
|
926
|
+
provider_kind = str(args.provider_kind or "").strip()
|
|
927
|
+
auth_mode = str(args.auth_mode or "").strip()
|
|
928
|
+
if provider_kind not in PROVIDER_KINDS:
|
|
929
|
+
raise SystemExit(f"unsupported provider-kind: {provider_kind}")
|
|
930
|
+
if auth_mode not in AUTH_MODES:
|
|
931
|
+
raise SystemExit(f"unsupported auth-mode: {auth_mode}")
|
|
932
|
+
if auth_mode == "native_subscription" and provider_kind != "anthropic_compatible":
|
|
933
|
+
raise SystemExit("native_subscription is supported only for anthropic_compatible in v1")
|
|
934
|
+
|
|
935
|
+
profiles_payload = _load_profiles()
|
|
936
|
+
profiles = [item for item in profiles_payload.get("profiles", []) if isinstance(item, dict)]
|
|
937
|
+
base_id = _safe_slug(args.name)
|
|
938
|
+
profile_id = base_id
|
|
939
|
+
existing_ids = {str(item.get("id") or "") for item in profiles}
|
|
940
|
+
counter = 2
|
|
941
|
+
while profile_id in existing_ids:
|
|
942
|
+
profile_id = f"{base_id}-{counter}"
|
|
943
|
+
counter += 1
|
|
944
|
+
|
|
945
|
+
spec = PROVIDER_SPECS[provider_kind]
|
|
946
|
+
cli_targets = _normalize_cli_targets(provider_kind, auth_mode, args.cli_target)
|
|
947
|
+
secret_env_key = str(args.secret_env_key or spec["secret_env_key"]).strip()
|
|
948
|
+
available_models = _normalize_models(args.available_model)
|
|
949
|
+
key_quota = _parse_json_object(args.quota_json, "--quota-json")
|
|
950
|
+
profile = {
|
|
951
|
+
"id": profile_id,
|
|
952
|
+
"name": str(args.name or profile_id),
|
|
953
|
+
"provider_kind": provider_kind,
|
|
954
|
+
"auth_mode": auth_mode,
|
|
955
|
+
"base_url": _normalize_base_url(provider_kind, auth_mode, args.base_url),
|
|
956
|
+
"default_model": _default_model(provider_kind, args.model),
|
|
957
|
+
"headers": _normalize_headers(args.header),
|
|
958
|
+
"cli_targets": cli_targets,
|
|
959
|
+
"secret_env_key": secret_env_key,
|
|
960
|
+
"secret_ref": f"{profile_id}.env" if auth_mode != "native_subscription" else "",
|
|
961
|
+
"status": "active",
|
|
962
|
+
"created_at": _now(),
|
|
963
|
+
"updated_at": _now(),
|
|
964
|
+
}
|
|
965
|
+
if available_models:
|
|
966
|
+
profile["available_models"] = available_models
|
|
967
|
+
profile["model_catalog"] = {
|
|
968
|
+
"source": "manual",
|
|
969
|
+
"updated_at": _now(),
|
|
970
|
+
"count": len(available_models),
|
|
971
|
+
}
|
|
972
|
+
if key_quota:
|
|
973
|
+
profile["quota"] = _quota_summary(key_quota)
|
|
974
|
+
profile["key_quota"] = {
|
|
975
|
+
"source": "manual",
|
|
976
|
+
"updated_at": _now(),
|
|
977
|
+
"data": _quota_summary(key_quota),
|
|
978
|
+
}
|
|
979
|
+
_validate_profile_shape(profile)
|
|
980
|
+
if auth_mode != "native_subscription":
|
|
981
|
+
secret_value = _secret_value_from_args(args, secret_env_key)
|
|
982
|
+
_write_secret_file(_profile_secret_path(profile_id), secret_env_key, secret_value)
|
|
983
|
+
profiles.append(profile)
|
|
984
|
+
profiles_payload["profiles"] = sorted(profiles, key=lambda item: str(item.get("name") or item.get("id") or ""))
|
|
985
|
+
_save_profiles(profiles_payload)
|
|
986
|
+
ensure_wrappers()
|
|
987
|
+
print(f"created profile: {profile_id}")
|
|
988
|
+
print(f" provider: {provider_kind}")
|
|
989
|
+
print(f" auth: {auth_mode}")
|
|
990
|
+
print(f" targets: {', '.join(cli_targets) if cli_targets else 'none'}")
|
|
991
|
+
if profile["secret_ref"]:
|
|
992
|
+
print(f" secret: {_masked_path(_profile_secret_path(profile_id))}")
|
|
993
|
+
return 0
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def cmd_profile_list(args: argparse.Namespace) -> int:
|
|
997
|
+
profiles = _iter_profiles()
|
|
998
|
+
if args.json:
|
|
999
|
+
print(json.dumps({"profiles": profiles}, indent=2, ensure_ascii=False))
|
|
1000
|
+
return 0
|
|
1001
|
+
if not profiles:
|
|
1002
|
+
print("No provider profiles yet. Add one with: 0dai provider profile add --name my-profile ...")
|
|
1003
|
+
return 0
|
|
1004
|
+
print(f"Provider profiles ({len(profiles)})")
|
|
1005
|
+
print("ID Name Kind Auth Models Targets")
|
|
1006
|
+
print("-" * 126)
|
|
1007
|
+
for profile in profiles:
|
|
1008
|
+
model_count = len(_profile_models(profile))
|
|
1009
|
+
print(
|
|
1010
|
+
f"{str(profile.get('id') or '')[:22].ljust(22)} "
|
|
1011
|
+
f"{str(profile.get('name') or '')[:23].ljust(23)} "
|
|
1012
|
+
f"{str(profile.get('provider_kind') or '')[:22].ljust(22)} "
|
|
1013
|
+
f"{str(profile.get('auth_mode') or '')[:24].ljust(24)} "
|
|
1014
|
+
f"{str(model_count).rjust(6)} "
|
|
1015
|
+
f"{','.join(profile.get('cli_targets') or [])}"
|
|
1016
|
+
)
|
|
1017
|
+
return 0
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def cmd_profile_show(args: argparse.Namespace) -> int:
|
|
1021
|
+
profile = _find_profile(args.profile_id)
|
|
1022
|
+
if not profile:
|
|
1023
|
+
raise SystemExit(f"profile not found: {args.profile_id}")
|
|
1024
|
+
payload = dict(profile)
|
|
1025
|
+
if payload.get("secret_ref"):
|
|
1026
|
+
payload["secret_path"] = str(_profile_secret_path(str(profile.get("id") or "")))
|
|
1027
|
+
if args.json:
|
|
1028
|
+
print(json.dumps({"profile": payload}, indent=2, ensure_ascii=False))
|
|
1029
|
+
return 0
|
|
1030
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1031
|
+
return 0
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def _extract_text(provider_kind: str, payload: dict[str, Any]) -> str:
|
|
1035
|
+
if provider_kind == "anthropic_compatible":
|
|
1036
|
+
content = payload.get("content")
|
|
1037
|
+
if isinstance(content, list):
|
|
1038
|
+
for item in content:
|
|
1039
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
1040
|
+
return str(item.get("text") or "")
|
|
1041
|
+
return ""
|
|
1042
|
+
if provider_kind == "openai_compatible":
|
|
1043
|
+
choices = payload.get("choices")
|
|
1044
|
+
if isinstance(choices, list) and choices:
|
|
1045
|
+
first = choices[0]
|
|
1046
|
+
if isinstance(first, dict):
|
|
1047
|
+
message = first.get("message")
|
|
1048
|
+
if isinstance(message, dict):
|
|
1049
|
+
return str(message.get("content") or "")
|
|
1050
|
+
return ""
|
|
1051
|
+
if provider_kind == "google_ai":
|
|
1052
|
+
candidates = payload.get("candidates")
|
|
1053
|
+
if isinstance(candidates, list) and candidates:
|
|
1054
|
+
first = candidates[0]
|
|
1055
|
+
if isinstance(first, dict):
|
|
1056
|
+
content = first.get("content")
|
|
1057
|
+
if isinstance(content, dict):
|
|
1058
|
+
parts = content.get("parts")
|
|
1059
|
+
if isinstance(parts, list):
|
|
1060
|
+
for part in parts:
|
|
1061
|
+
if isinstance(part, dict) and str(part.get("text") or "").strip():
|
|
1062
|
+
return str(part.get("text") or "")
|
|
1063
|
+
return ""
|
|
1064
|
+
return ""
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _request_json(
|
|
1068
|
+
url: str,
|
|
1069
|
+
*,
|
|
1070
|
+
method: str = "GET",
|
|
1071
|
+
headers: dict[str, str] | None = None,
|
|
1072
|
+
payload: dict[str, Any] | None = None,
|
|
1073
|
+
timeout: int = REQUEST_TIMEOUT_SECONDS,
|
|
1074
|
+
) -> tuple[int, dict[str, Any], str]:
|
|
1075
|
+
# SSRF guard at the outbound chokepoint: every provider fetch/invoke goes
|
|
1076
|
+
# through here, so this defends the stored-then-fetched flow even when a
|
|
1077
|
+
# malicious base_url slipped past add-time validation. The opener below
|
|
1078
|
+
# re-validates every redirect hop (see _SSRFGuardedRedirectHandler).
|
|
1079
|
+
_assert_public_base_url(url)
|
|
1080
|
+
body = None
|
|
1081
|
+
if payload is not None:
|
|
1082
|
+
body = json.dumps(payload).encode("utf-8")
|
|
1083
|
+
req = urllib.request.Request(url, data=body, method=method)
|
|
1084
|
+
final_headers = dict(DEFAULT_HEADERS)
|
|
1085
|
+
final_headers.update(headers or {})
|
|
1086
|
+
if body is not None:
|
|
1087
|
+
final_headers.setdefault("Content-Type", "application/json")
|
|
1088
|
+
for key, value in final_headers.items():
|
|
1089
|
+
req.add_header(key, value)
|
|
1090
|
+
try:
|
|
1091
|
+
with _SSRF_OPENER.open(req, timeout=timeout) as response:
|
|
1092
|
+
raw = response.read().decode("utf-8")
|
|
1093
|
+
parsed = json.loads(raw) if raw else {}
|
|
1094
|
+
return int(response.status), parsed if isinstance(parsed, dict) else {}, raw
|
|
1095
|
+
except urllib.error.HTTPError as exc:
|
|
1096
|
+
raw = exc.read().decode("utf-8", errors="replace")
|
|
1097
|
+
try:
|
|
1098
|
+
parsed = json.loads(raw) if raw else {}
|
|
1099
|
+
except json.JSONDecodeError:
|
|
1100
|
+
parsed = {}
|
|
1101
|
+
return int(exc.code), parsed if isinstance(parsed, dict) else {}, raw
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def _auth_headers_for_profile(profile: dict[str, Any], secret_value: str) -> dict[str, str]:
|
|
1105
|
+
provider_kind = str(profile.get("provider_kind") or "")
|
|
1106
|
+
headers = dict(profile.get("headers") or {})
|
|
1107
|
+
if provider_kind == "anthropic_compatible":
|
|
1108
|
+
headers.update(
|
|
1109
|
+
{
|
|
1110
|
+
"x-api-key": secret_value,
|
|
1111
|
+
"anthropic-version": "2023-06-01",
|
|
1112
|
+
}
|
|
1113
|
+
)
|
|
1114
|
+
elif provider_kind == "openai_compatible":
|
|
1115
|
+
headers.update({"Authorization": f"Bearer {secret_value}"})
|
|
1116
|
+
elif provider_kind == "google_ai":
|
|
1117
|
+
headers.update(
|
|
1118
|
+
{
|
|
1119
|
+
"x-goog-api-key": secret_value,
|
|
1120
|
+
"x-goog-api-client": GOOGLE_API_CLIENT,
|
|
1121
|
+
}
|
|
1122
|
+
)
|
|
1123
|
+
return headers
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _extract_model_ids(provider_kind: str, payload: dict[str, Any]) -> list[str]:
|
|
1127
|
+
if provider_kind in {"anthropic_compatible", "openai_compatible"}:
|
|
1128
|
+
data = payload.get("data")
|
|
1129
|
+
if not isinstance(data, list):
|
|
1130
|
+
return []
|
|
1131
|
+
models: list[str] = []
|
|
1132
|
+
for item in data:
|
|
1133
|
+
if isinstance(item, dict):
|
|
1134
|
+
model_id = str(item.get("id") or "").strip()
|
|
1135
|
+
if model_id:
|
|
1136
|
+
models.append(model_id)
|
|
1137
|
+
return sorted(set(models))
|
|
1138
|
+
if provider_kind == "google_ai":
|
|
1139
|
+
data = payload.get("models")
|
|
1140
|
+
if not isinstance(data, list):
|
|
1141
|
+
return []
|
|
1142
|
+
models = []
|
|
1143
|
+
for item in data:
|
|
1144
|
+
if isinstance(item, dict):
|
|
1145
|
+
model_id = str(item.get("name") or "").strip()
|
|
1146
|
+
if model_id.startswith("models/"):
|
|
1147
|
+
model_id = model_id[len("models/") :]
|
|
1148
|
+
if model_id:
|
|
1149
|
+
models.append(model_id)
|
|
1150
|
+
return sorted(set(models))
|
|
1151
|
+
return []
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def fetch_profile_capabilities(profile: dict[str, Any]) -> dict[str, Any]:
|
|
1155
|
+
provider_kind = str(profile.get("provider_kind") or "")
|
|
1156
|
+
secret_key = str(profile.get("secret_env_key") or "")
|
|
1157
|
+
secret_value = _secret_value(profile)
|
|
1158
|
+
if not secret_value:
|
|
1159
|
+
raise ValueError(f"profile {profile.get('id')} is missing {secret_key}")
|
|
1160
|
+
base_url = _invoke_base_url(provider_kind, str(profile.get("base_url") or ""))
|
|
1161
|
+
if not base_url:
|
|
1162
|
+
raise ValueError(f"profile {profile.get('id')} has no base_url")
|
|
1163
|
+
headers = _auth_headers_for_profile(profile, secret_value)
|
|
1164
|
+
result: dict[str, Any] = {
|
|
1165
|
+
"fetched_at": _now(),
|
|
1166
|
+
"available_models": [],
|
|
1167
|
+
"model_catalog": {},
|
|
1168
|
+
"quota": {},
|
|
1169
|
+
"key_quota": {},
|
|
1170
|
+
"errors": [],
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
if provider_kind == "google_ai":
|
|
1174
|
+
models_url = f"{base_url}/v1beta/models"
|
|
1175
|
+
else:
|
|
1176
|
+
models_url = f"{base_url}/v1/models"
|
|
1177
|
+
status, payload, raw = _request_json(models_url, headers=headers)
|
|
1178
|
+
if status >= 200 and status < 300:
|
|
1179
|
+
models = _extract_model_ids(provider_kind, payload)
|
|
1180
|
+
result["available_models"] = models
|
|
1181
|
+
result["model_catalog"] = {
|
|
1182
|
+
"source": models_url,
|
|
1183
|
+
"updated_at": result["fetched_at"],
|
|
1184
|
+
"count": len(models),
|
|
1185
|
+
}
|
|
1186
|
+
else:
|
|
1187
|
+
result["errors"].append(
|
|
1188
|
+
{
|
|
1189
|
+
"kind": "models",
|
|
1190
|
+
"status": status,
|
|
1191
|
+
"source": models_url,
|
|
1192
|
+
"message": raw[:500],
|
|
1193
|
+
}
|
|
1194
|
+
)
|
|
1195
|
+
|
|
1196
|
+
if provider_kind == "openai_compatible" and "openrouter.ai" in base_url:
|
|
1197
|
+
quota_url = f"{base_url}/v1/key"
|
|
1198
|
+
status, payload, raw = _request_json(quota_url, headers=headers)
|
|
1199
|
+
if status >= 200 and status < 300 and isinstance(payload.get("data"), dict):
|
|
1200
|
+
quota = _quota_summary(payload["data"])
|
|
1201
|
+
result["quota"] = quota
|
|
1202
|
+
result["key_quota"] = {
|
|
1203
|
+
"source": quota_url,
|
|
1204
|
+
"updated_at": result["fetched_at"],
|
|
1205
|
+
"data": quota,
|
|
1206
|
+
}
|
|
1207
|
+
else:
|
|
1208
|
+
result["errors"].append(
|
|
1209
|
+
{
|
|
1210
|
+
"kind": "quota",
|
|
1211
|
+
"status": status,
|
|
1212
|
+
"source": quota_url,
|
|
1213
|
+
"message": raw[:500],
|
|
1214
|
+
}
|
|
1215
|
+
)
|
|
1216
|
+
return result
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def cmd_profile_refresh(args: argparse.Namespace) -> int:
|
|
1220
|
+
profile = _find_profile(args.profile_id)
|
|
1221
|
+
if not profile:
|
|
1222
|
+
raise SystemExit(f"profile not found: {args.profile_id}")
|
|
1223
|
+
try:
|
|
1224
|
+
snapshot = fetch_profile_capabilities(profile)
|
|
1225
|
+
except Exception as exc: # noqa: BLE001 — CLI top-level guard: network fetch failure → exit 1
|
|
1226
|
+
payload = {"ok": False, "error": str(exc)}
|
|
1227
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1228
|
+
return 1
|
|
1229
|
+
|
|
1230
|
+
profiles_payload = _load_profiles()
|
|
1231
|
+
for item in profiles_payload.get("profiles", []):
|
|
1232
|
+
if not isinstance(item, dict) or str(item.get("id") or "") != args.profile_id:
|
|
1233
|
+
continue
|
|
1234
|
+
if snapshot.get("available_models"):
|
|
1235
|
+
item["available_models"] = snapshot["available_models"]
|
|
1236
|
+
item["model_catalog"] = snapshot["model_catalog"]
|
|
1237
|
+
if snapshot.get("quota"):
|
|
1238
|
+
item["quota"] = snapshot["quota"]
|
|
1239
|
+
item["key_quota"] = snapshot["key_quota"]
|
|
1240
|
+
item["capability_refreshed_at"] = snapshot["fetched_at"]
|
|
1241
|
+
errors = snapshot.get("errors") if isinstance(snapshot.get("errors"), list) else []
|
|
1242
|
+
if errors:
|
|
1243
|
+
item["capability_errors"] = errors
|
|
1244
|
+
else:
|
|
1245
|
+
item.pop("capability_errors", None)
|
|
1246
|
+
item["updated_at"] = _now()
|
|
1247
|
+
_save_profiles(profiles_payload)
|
|
1248
|
+
|
|
1249
|
+
payload = {
|
|
1250
|
+
"ok": not bool(snapshot.get("errors")),
|
|
1251
|
+
"profile_id": args.profile_id,
|
|
1252
|
+
"available_model_count": len(snapshot.get("available_models") or []),
|
|
1253
|
+
"quota_present": bool(snapshot.get("quota")),
|
|
1254
|
+
"errors": snapshot.get("errors") or [],
|
|
1255
|
+
}
|
|
1256
|
+
if args.json:
|
|
1257
|
+
payload["available_models"] = snapshot.get("available_models") or []
|
|
1258
|
+
payload["quota"] = snapshot.get("quota") or {}
|
|
1259
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1260
|
+
return 0 if payload["ok"] else 1
|
|
1261
|
+
print(
|
|
1262
|
+
f"refreshed {args.profile_id}: "
|
|
1263
|
+
f"models={payload['available_model_count']} "
|
|
1264
|
+
f"quota={'yes' if payload['quota_present'] else 'no'}"
|
|
1265
|
+
)
|
|
1266
|
+
if payload["errors"]:
|
|
1267
|
+
print(json.dumps({"errors": payload["errors"]}, indent=2, ensure_ascii=False))
|
|
1268
|
+
return 1
|
|
1269
|
+
return 0
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
def invoke_profile(
|
|
1273
|
+
profile: dict[str, Any],
|
|
1274
|
+
*,
|
|
1275
|
+
prompt: str,
|
|
1276
|
+
model: str = "",
|
|
1277
|
+
max_tokens: int = 128,
|
|
1278
|
+
) -> dict[str, Any]:
|
|
1279
|
+
provider_kind = str(profile.get("provider_kind") or "")
|
|
1280
|
+
auth_mode = str(profile.get("auth_mode") or "")
|
|
1281
|
+
if auth_mode == "native_subscription":
|
|
1282
|
+
raise ValueError("provider invoke does not support native_subscription profiles")
|
|
1283
|
+
secret_key = str(profile.get("secret_env_key") or "")
|
|
1284
|
+
secret_value = _secret_value(profile)
|
|
1285
|
+
if not secret_value:
|
|
1286
|
+
raise ValueError(f"profile {profile.get('id')} is missing {secret_key}")
|
|
1287
|
+
headers = dict(profile.get("headers") or {})
|
|
1288
|
+
model_name = str(model or profile.get("default_model") or "").strip()
|
|
1289
|
+
base_url = _invoke_base_url(provider_kind, str(profile.get("base_url") or ""))
|
|
1290
|
+
if provider_kind == "anthropic_compatible":
|
|
1291
|
+
headers.update(
|
|
1292
|
+
{
|
|
1293
|
+
"x-api-key": secret_value,
|
|
1294
|
+
"anthropic-version": "2023-06-01",
|
|
1295
|
+
}
|
|
1296
|
+
)
|
|
1297
|
+
status, payload, raw = _request_json(
|
|
1298
|
+
f"{base_url}/v1/messages",
|
|
1299
|
+
method="POST",
|
|
1300
|
+
headers=headers,
|
|
1301
|
+
payload={
|
|
1302
|
+
"model": model_name,
|
|
1303
|
+
"max_tokens": max_tokens,
|
|
1304
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
1305
|
+
},
|
|
1306
|
+
)
|
|
1307
|
+
elif provider_kind == "openai_compatible":
|
|
1308
|
+
headers.update({"Authorization": f"Bearer {secret_value}"})
|
|
1309
|
+
status, payload, raw = _request_json(
|
|
1310
|
+
f"{base_url}/v1/chat/completions",
|
|
1311
|
+
method="POST",
|
|
1312
|
+
headers=headers,
|
|
1313
|
+
payload={
|
|
1314
|
+
"model": model_name,
|
|
1315
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
1316
|
+
"max_tokens": max_tokens,
|
|
1317
|
+
},
|
|
1318
|
+
)
|
|
1319
|
+
elif provider_kind == "google_ai":
|
|
1320
|
+
headers.update(
|
|
1321
|
+
{
|
|
1322
|
+
"x-goog-api-key": secret_value,
|
|
1323
|
+
"x-goog-api-client": GOOGLE_API_CLIENT,
|
|
1324
|
+
}
|
|
1325
|
+
)
|
|
1326
|
+
status, payload, raw = _request_json(
|
|
1327
|
+
f"{base_url}/v1beta/models/{model_name}:generateContent",
|
|
1328
|
+
method="POST",
|
|
1329
|
+
headers=headers,
|
|
1330
|
+
payload={
|
|
1331
|
+
"contents": [
|
|
1332
|
+
{
|
|
1333
|
+
"parts": [{"text": prompt}],
|
|
1334
|
+
}
|
|
1335
|
+
]
|
|
1336
|
+
},
|
|
1337
|
+
)
|
|
1338
|
+
else:
|
|
1339
|
+
raise ValueError(f"unsupported provider_kind: {provider_kind}")
|
|
1340
|
+
return {
|
|
1341
|
+
"status": status,
|
|
1342
|
+
"payload": payload,
|
|
1343
|
+
"raw": raw,
|
|
1344
|
+
"text": _extract_text(provider_kind, payload),
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def cmd_profile_test(args: argparse.Namespace) -> int:
|
|
1349
|
+
profile = _find_profile(args.profile_id)
|
|
1350
|
+
if not profile:
|
|
1351
|
+
raise SystemExit(f"profile not found: {args.profile_id}")
|
|
1352
|
+
try:
|
|
1353
|
+
result = invoke_profile(
|
|
1354
|
+
profile,
|
|
1355
|
+
prompt=DEFAULT_TEST_PROMPT,
|
|
1356
|
+
model=args.model or str(profile.get("default_model") or ""),
|
|
1357
|
+
max_tokens=24,
|
|
1358
|
+
)
|
|
1359
|
+
except Exception as exc: # noqa: BLE001 — CLI top-level guard: network invoke failure → exit 1
|
|
1360
|
+
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
|
1361
|
+
return 1
|
|
1362
|
+
ok = result["status"] >= 200 and result["status"] < 300 and str(result.get("text") or "").strip()
|
|
1363
|
+
payload = {
|
|
1364
|
+
"ok": ok,
|
|
1365
|
+
"status": result["status"],
|
|
1366
|
+
"text": str(result.get("text") or "").strip(),
|
|
1367
|
+
"payload": result["payload"] if args.json else {},
|
|
1368
|
+
}
|
|
1369
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1370
|
+
profiles_payload = _load_profiles()
|
|
1371
|
+
for item in profiles_payload.get("profiles", []):
|
|
1372
|
+
if isinstance(item, dict) and str(item.get("id") or "") == args.profile_id:
|
|
1373
|
+
item["last_tested_at"] = _now()
|
|
1374
|
+
item["last_test_status"] = "ok" if ok else "failed"
|
|
1375
|
+
_save_profiles(profiles_payload)
|
|
1376
|
+
return 0 if ok else 1
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def cmd_bind(args: argparse.Namespace) -> int:
|
|
1380
|
+
profile = _find_profile(args.profile_id)
|
|
1381
|
+
if not profile:
|
|
1382
|
+
raise SystemExit(f"profile not found: {args.profile_id}")
|
|
1383
|
+
agent = _canonical_agent_name(args.agent)
|
|
1384
|
+
if agent not in SUPPORTED_AGENTS:
|
|
1385
|
+
raise SystemExit(f"unsupported agent: {args.agent}")
|
|
1386
|
+
_validate_profile_shape(profile)
|
|
1387
|
+
_validate_profile_for_agent(profile, agent)
|
|
1388
|
+
bindings = _load_bindings()
|
|
1389
|
+
if args.default or not args.target:
|
|
1390
|
+
defaults = bindings.setdefault("defaults", {})
|
|
1391
|
+
if not isinstance(defaults, dict):
|
|
1392
|
+
defaults = {}
|
|
1393
|
+
bindings["defaults"] = defaults
|
|
1394
|
+
defaults[agent] = args.profile_id
|
|
1395
|
+
scope = "account default"
|
|
1396
|
+
else:
|
|
1397
|
+
projects = bindings.setdefault("projects", {})
|
|
1398
|
+
if not isinstance(projects, dict):
|
|
1399
|
+
projects = {}
|
|
1400
|
+
bindings["projects"] = projects
|
|
1401
|
+
key = _normalize_path(pathlib.Path(args.target))
|
|
1402
|
+
entry = projects.setdefault(key, {})
|
|
1403
|
+
if not isinstance(entry, dict):
|
|
1404
|
+
entry = {}
|
|
1405
|
+
projects[key] = entry
|
|
1406
|
+
entry[agent] = args.profile_id
|
|
1407
|
+
scope = key
|
|
1408
|
+
_save_bindings(bindings)
|
|
1409
|
+
ensure_wrappers()
|
|
1410
|
+
print(f"bound {agent} -> {args.profile_id} ({scope})")
|
|
1411
|
+
return 0
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def cmd_bindings(args: argparse.Namespace) -> int:
|
|
1415
|
+
target = pathlib.Path(args.target).resolve() if args.target else None
|
|
1416
|
+
bindings = _load_bindings()
|
|
1417
|
+
defaults = bindings.get("defaults") if isinstance(bindings.get("defaults"), dict) else {}
|
|
1418
|
+
projects = bindings.get("projects") if isinstance(bindings.get("projects"), dict) else {}
|
|
1419
|
+
payload = {
|
|
1420
|
+
"defaults": defaults,
|
|
1421
|
+
"resolved": {},
|
|
1422
|
+
"project": _normalize_path(target) if target else "",
|
|
1423
|
+
"project_overrides": projects.get(_normalize_path(target)) if target else {},
|
|
1424
|
+
}
|
|
1425
|
+
if target is not None:
|
|
1426
|
+
payload["resolved"] = {
|
|
1427
|
+
agent: resolve_bound_profile_id(target, agent)
|
|
1428
|
+
for agent in sorted(SUPPORTED_AGENTS)
|
|
1429
|
+
}
|
|
1430
|
+
if args.json:
|
|
1431
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1432
|
+
return 0
|
|
1433
|
+
print("Provider bindings")
|
|
1434
|
+
if defaults:
|
|
1435
|
+
print(" defaults:")
|
|
1436
|
+
for agent, profile_id in sorted(defaults.items()):
|
|
1437
|
+
print(f" {agent}: {profile_id}")
|
|
1438
|
+
else:
|
|
1439
|
+
print(" defaults: none")
|
|
1440
|
+
if target is not None:
|
|
1441
|
+
print(f" project: {_normalize_path(target)}")
|
|
1442
|
+
project_overrides = payload.get("project_overrides") or {}
|
|
1443
|
+
if project_overrides:
|
|
1444
|
+
for agent, profile_id in sorted(project_overrides.items()):
|
|
1445
|
+
print(f" override {agent}: {profile_id}")
|
|
1446
|
+
print(" resolved:")
|
|
1447
|
+
for agent, profile_id in sorted((payload.get("resolved") or {}).items()):
|
|
1448
|
+
print(f" {agent}: {profile_id or '—'}")
|
|
1449
|
+
return 0
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def cmd_invoke(args: argparse.Namespace) -> int:
|
|
1453
|
+
profile: dict[str, Any] | None = None
|
|
1454
|
+
if args.profile_id:
|
|
1455
|
+
profile = _find_profile(args.profile_id)
|
|
1456
|
+
if not profile:
|
|
1457
|
+
raise SystemExit(f"profile not found: {args.profile_id}")
|
|
1458
|
+
elif args.agent and args.target:
|
|
1459
|
+
profile = resolve_profile(pathlib.Path(args.target).resolve(), args.agent)
|
|
1460
|
+
if not profile:
|
|
1461
|
+
raise SystemExit(f"no bound profile for {args.agent} in {args.target}")
|
|
1462
|
+
else:
|
|
1463
|
+
raise SystemExit("provide --profile or (--agent and --target)")
|
|
1464
|
+
try:
|
|
1465
|
+
result = invoke_profile(
|
|
1466
|
+
profile,
|
|
1467
|
+
prompt=args.prompt,
|
|
1468
|
+
model=args.model or str(profile.get("default_model") or ""),
|
|
1469
|
+
max_tokens=args.max_tokens,
|
|
1470
|
+
)
|
|
1471
|
+
except Exception as exc: # noqa: BLE001 — CLI top-level guard: network invoke failure → exit 1
|
|
1472
|
+
print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
|
|
1473
|
+
return 1
|
|
1474
|
+
payload = {
|
|
1475
|
+
"ok": result["status"] >= 200 and result["status"] < 300,
|
|
1476
|
+
"status": result["status"],
|
|
1477
|
+
"text": result["text"],
|
|
1478
|
+
}
|
|
1479
|
+
if args.json:
|
|
1480
|
+
payload["payload"] = result["payload"]
|
|
1481
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1482
|
+
return 0 if payload["ok"] else 1
|
|
1483
|
+
if payload["ok"]:
|
|
1484
|
+
print(str(payload["text"]).strip())
|
|
1485
|
+
return 0
|
|
1486
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1487
|
+
return 1
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
1491
|
+
target = pathlib.Path(args.target).resolve()
|
|
1492
|
+
payload = {
|
|
1493
|
+
"target": _normalize_path(target),
|
|
1494
|
+
"bindings": {
|
|
1495
|
+
agent: resolve_bound_profile_id(target, agent)
|
|
1496
|
+
for agent in sorted(SUPPORTED_AGENTS)
|
|
1497
|
+
},
|
|
1498
|
+
"profiles": [
|
|
1499
|
+
{
|
|
1500
|
+
"id": profile.get("id"),
|
|
1501
|
+
"name": profile.get("name"),
|
|
1502
|
+
"provider_kind": profile.get("provider_kind"),
|
|
1503
|
+
"auth_mode": profile.get("auth_mode"),
|
|
1504
|
+
"targets": profile.get("cli_targets"),
|
|
1505
|
+
"available_model_count": len(_profile_models(profile)),
|
|
1506
|
+
"quota": profile.get("quota") or {},
|
|
1507
|
+
"last_test_status": profile.get("last_test_status"),
|
|
1508
|
+
}
|
|
1509
|
+
for profile in _iter_profiles()
|
|
1510
|
+
],
|
|
1511
|
+
}
|
|
1512
|
+
if args.json:
|
|
1513
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
1514
|
+
return 0
|
|
1515
|
+
print(f"Provider status for {payload['target']}")
|
|
1516
|
+
for agent, profile_id in sorted(payload["bindings"].items()):
|
|
1517
|
+
print(f" {agent}: {profile_id or '—'}")
|
|
1518
|
+
return 0
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1522
|
+
parser = argparse.ArgumentParser(prog="0dai provider")
|
|
1523
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
1524
|
+
|
|
1525
|
+
profile = sub.add_parser("profile", help="Manage provider profiles")
|
|
1526
|
+
profile_sub = profile.add_subparsers(dest="profile_command", required=True)
|
|
1527
|
+
|
|
1528
|
+
add = profile_sub.add_parser("add", help="Add a local provider profile")
|
|
1529
|
+
add.add_argument("--name", required=True)
|
|
1530
|
+
add.add_argument("--provider-kind", required=True, choices=sorted(PROVIDER_KINDS))
|
|
1531
|
+
add.add_argument("--auth-mode", required=True, choices=sorted(AUTH_MODES))
|
|
1532
|
+
add.add_argument("--base-url", default="")
|
|
1533
|
+
add.add_argument("--model", default="")
|
|
1534
|
+
add.add_argument("--available-model", action="append", default=[])
|
|
1535
|
+
add.add_argument("--quota-json", default="")
|
|
1536
|
+
add.add_argument("--api-key-file", default="")
|
|
1537
|
+
add.add_argument("--api-key-env", default="")
|
|
1538
|
+
add.add_argument("--secret-env-key", default="")
|
|
1539
|
+
add.add_argument("--cli-target", action="append", default=[])
|
|
1540
|
+
add.add_argument("--header", action="append", default=[])
|
|
1541
|
+
|
|
1542
|
+
list_cmd = profile_sub.add_parser("list", help="List provider profiles")
|
|
1543
|
+
list_cmd.add_argument("--json", action="store_true")
|
|
1544
|
+
|
|
1545
|
+
show = profile_sub.add_parser("show", help="Show one provider profile")
|
|
1546
|
+
show.add_argument("profile_id")
|
|
1547
|
+
show.add_argument("--json", action="store_true")
|
|
1548
|
+
|
|
1549
|
+
test = profile_sub.add_parser("test", help="Run a small live provider probe")
|
|
1550
|
+
test.add_argument("profile_id")
|
|
1551
|
+
test.add_argument("--model", default="")
|
|
1552
|
+
test.add_argument("--json", action="store_true")
|
|
1553
|
+
|
|
1554
|
+
refresh = profile_sub.add_parser("refresh", help="Refresh available models and key quota metadata")
|
|
1555
|
+
refresh.add_argument("profile_id")
|
|
1556
|
+
refresh.add_argument("--json", action="store_true")
|
|
1557
|
+
|
|
1558
|
+
bind = sub.add_parser("bind", help="Bind a provider profile to an agent")
|
|
1559
|
+
bind.add_argument("--profile", dest="profile_id", required=True)
|
|
1560
|
+
bind.add_argument("--agent", required=True)
|
|
1561
|
+
bind.add_argument("--target", default="")
|
|
1562
|
+
bind.add_argument("--default", action="store_true")
|
|
1563
|
+
|
|
1564
|
+
bindings = sub.add_parser("bindings", help="Show current provider bindings")
|
|
1565
|
+
bindings.add_argument("--target", default="")
|
|
1566
|
+
bindings.add_argument("--json", action="store_true")
|
|
1567
|
+
|
|
1568
|
+
invoke = sub.add_parser("invoke", help="Run a non-interactive provider request")
|
|
1569
|
+
invoke.add_argument("--profile", dest="profile_id", default="")
|
|
1570
|
+
invoke.add_argument("--agent", default="")
|
|
1571
|
+
invoke.add_argument("--target", default="")
|
|
1572
|
+
invoke.add_argument("--prompt", required=True)
|
|
1573
|
+
invoke.add_argument("--model", default="")
|
|
1574
|
+
invoke.add_argument("--max-tokens", type=int, default=128)
|
|
1575
|
+
invoke.add_argument("--json", action="store_true")
|
|
1576
|
+
|
|
1577
|
+
status = sub.add_parser("status", help="Show provider status for a target")
|
|
1578
|
+
status.add_argument("--target", required=True)
|
|
1579
|
+
status.add_argument("--json", action="store_true")
|
|
1580
|
+
|
|
1581
|
+
exec_cmd = sub.add_parser("exec", help=argparse.SUPPRESS)
|
|
1582
|
+
exec_cmd.add_argument("--agent", required=True)
|
|
1583
|
+
exec_cmd.add_argument("--cwd", default="")
|
|
1584
|
+
exec_cmd.add_argument("argv", nargs=argparse.REMAINDER)
|
|
1585
|
+
|
|
1586
|
+
return parser
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1590
|
+
parser = build_parser()
|
|
1591
|
+
args = parser.parse_args(argv)
|
|
1592
|
+
if args.command == "profile":
|
|
1593
|
+
if args.profile_command == "add":
|
|
1594
|
+
return cmd_profile_add(args)
|
|
1595
|
+
if args.profile_command == "list":
|
|
1596
|
+
return cmd_profile_list(args)
|
|
1597
|
+
if args.profile_command == "show":
|
|
1598
|
+
return cmd_profile_show(args)
|
|
1599
|
+
if args.profile_command == "test":
|
|
1600
|
+
return cmd_profile_test(args)
|
|
1601
|
+
if args.profile_command == "refresh":
|
|
1602
|
+
return cmd_profile_refresh(args)
|
|
1603
|
+
if args.command == "bind":
|
|
1604
|
+
return cmd_bind(args)
|
|
1605
|
+
if args.command == "bindings":
|
|
1606
|
+
return cmd_bindings(args)
|
|
1607
|
+
if args.command == "invoke":
|
|
1608
|
+
return cmd_invoke(args)
|
|
1609
|
+
if args.command == "status":
|
|
1610
|
+
return cmd_status(args)
|
|
1611
|
+
if args.command == "exec":
|
|
1612
|
+
return cmd_exec(args)
|
|
1613
|
+
parser.error("unknown command")
|
|
1614
|
+
return 2
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
if __name__ == "__main__":
|
|
1618
|
+
raise SystemExit(main())
|