snodo-core 0.7.2__tar.gz
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.
- snodo_core-0.7.2/PKG-INFO +10 -0
- snodo_core-0.7.2/pyproject.toml +25 -0
- snodo_core-0.7.2/setup.cfg +4 -0
- snodo_core-0.7.2/src/snodo/config.py +546 -0
- snodo_core-0.7.2/src/snodo/core/__init__.py +1 -0
- snodo_core-0.7.2/src/snodo/core/interfaces.py +132 -0
- snodo_core-0.7.2/src/snodo/paths.py +107 -0
- snodo_core-0.7.2/src/snodo/predicates/__init__.py +11 -0
- snodo_core-0.7.2/src/snodo/predicates/base.py +57 -0
- snodo_core-0.7.2/src/snodo/predicates/registry.py +53 -0
- snodo_core-0.7.2/src/snodo/predicates/scope.py +53 -0
- snodo_core-0.7.2/src/snodo/predicates/secrets.py +92 -0
- snodo_core-0.7.2/src/snodo/predicates/tests.py +78 -0
- snodo_core-0.7.2/src/snodo/project.py +180 -0
- snodo_core-0.7.2/src/snodo/py.typed +0 -0
- snodo_core-0.7.2/src/snodo/sandbox/__init__.py +39 -0
- snodo_core-0.7.2/src/snodo/sandbox/base.py +96 -0
- snodo_core-0.7.2/src/snodo/sandbox/docker_sandbox.py +195 -0
- snodo_core-0.7.2/src/snodo/sandbox/local_sandbox.py +101 -0
- snodo_core-0.7.2/src/snodo_core.egg-info/PKG-INFO +10 -0
- snodo_core-0.7.2/src/snodo_core.egg-info/SOURCES.txt +22 -0
- snodo_core-0.7.2/src/snodo_core.egg-info/dependency_links.txt +1 -0
- snodo_core-0.7.2/src/snodo_core.egg-info/requires.txt +1 -0
- snodo_core-0.7.2/src/snodo_core.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: snodo-core
|
|
3
|
+
Version: 0.7.2
|
|
4
|
+
Summary: Snodo kernel — core abstractions, config, paths, predicates, sandbox
|
|
5
|
+
Author-email: The Snodo Authors <noreply@snodo.dev>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://snodo.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/snodo-dev/snodo
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: pydantic>=2.12.0
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "snodo-core"
|
|
7
|
+
version = "0.7.2"
|
|
8
|
+
description = "Snodo kernel — core abstractions, config, paths, predicates, sandbox"
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
authors = [{ name = "The Snodo Authors", email = "noreply@snodo.dev" }]
|
|
12
|
+
dependencies = [
|
|
13
|
+
"pydantic>=2.12.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://snodo.dev"
|
|
18
|
+
Repository = "https://github.com/snodo-dev/snodo"
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src"]
|
|
22
|
+
include = ["snodo*"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.package-data]
|
|
25
|
+
snodo = ["py.typed"]
|
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""Configuration and API key management for Snodo.
|
|
2
|
+
|
|
3
|
+
FILE: snodo/config.py
|
|
4
|
+
|
|
5
|
+
Manages user configuration stored at ~/.snodo/config.yml:
|
|
6
|
+
- API key storage with secure file permissions (600)
|
|
7
|
+
- Model selection defaults
|
|
8
|
+
- Key validation via liteLLM
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
from snodo.paths import resolve_home
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProviderConfig(BaseModel):
|
|
24
|
+
"""Provider configuration with API credential env var and /models endpoint."""
|
|
25
|
+
api_key: str = ""
|
|
26
|
+
api_key_env: str = ""
|
|
27
|
+
models_endpoint: str = ""
|
|
28
|
+
account_id: str = ""
|
|
29
|
+
account_id_env: str = ""
|
|
30
|
+
base_url: str = ""
|
|
31
|
+
litellm_provider: str = ""
|
|
32
|
+
extra_headers: Dict[str, str] = Field(default_factory=dict)
|
|
33
|
+
probe_model: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
DEFAULT_PROVIDER_CATALOG: Dict[str, ProviderConfig] = {
|
|
37
|
+
"anthropic": ProviderConfig(
|
|
38
|
+
api_key_env="ANTHROPIC_API_KEY",
|
|
39
|
+
models_endpoint="https://api.anthropic.com/v1/models",
|
|
40
|
+
probe_model="claude-3-haiku-20240307",
|
|
41
|
+
),
|
|
42
|
+
"openai": ProviderConfig(
|
|
43
|
+
api_key_env="OPENAI_API_KEY",
|
|
44
|
+
models_endpoint="https://api.openai.com/v1/models",
|
|
45
|
+
probe_model="gpt-4o-mini",
|
|
46
|
+
),
|
|
47
|
+
"openrouter": ProviderConfig(
|
|
48
|
+
api_key_env="OPENROUTER_API_KEY",
|
|
49
|
+
models_endpoint="https://openrouter.ai/api/v1/models",
|
|
50
|
+
probe_model="openai/gpt-4o-mini",
|
|
51
|
+
),
|
|
52
|
+
"google": ProviderConfig(
|
|
53
|
+
api_key_env="GEMINI_API_KEY",
|
|
54
|
+
models_endpoint="https://generativelanguage.googleapis.com/v1beta/models",
|
|
55
|
+
probe_model="gemini/gemini-2.0-flash",
|
|
56
|
+
),
|
|
57
|
+
"cloudflare": ProviderConfig(
|
|
58
|
+
api_key_env="CLOUDFLARE_API_KEY",
|
|
59
|
+
account_id_env="CLOUDFLARE_ACCOUNT_ID",
|
|
60
|
+
models_endpoint="https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1/models",
|
|
61
|
+
extra_headers={"x-session-affinity": "{task_id}"},
|
|
62
|
+
probe_model="@cf/meta/llama-3.1-8b-instruct",
|
|
63
|
+
),
|
|
64
|
+
"deepseek": ProviderConfig(
|
|
65
|
+
api_key_env="DEEPSEEK_API_KEY",
|
|
66
|
+
models_endpoint="https://api.deepseek.com/models",
|
|
67
|
+
probe_model="deepseek/deepseek-chat",
|
|
68
|
+
),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
DEFAULT_MODEL = "claude-sonnet-4-20250514"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Provider-to-model prefix mapping for key resolution
|
|
76
|
+
PROVIDER_MODEL_PREFIXES = {
|
|
77
|
+
"openai": ["gpt-", "o1-", "o3-"],
|
|
78
|
+
"anthropic": ["claude-"],
|
|
79
|
+
"google": ["gemini/", "gemini-"],
|
|
80
|
+
"cloudflare": ["cloudflare/", "openai/@cf/", "@cf/"],
|
|
81
|
+
"deepseek": ["deepseek/"],
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Alias map: litellm provider name → snodo config key.
|
|
85
|
+
# litellm.get_llm_provider() returns "gemini" but snodo's config
|
|
86
|
+
# key is "google". Other names pass through unchanged.
|
|
87
|
+
_PROVIDER_ALIASES: dict[str, str] = {
|
|
88
|
+
"gemini": "google",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _set_api_key_env(mgr: "ConfigManager", model: str) -> None:
|
|
93
|
+
"""Set API key in environment if available from config."""
|
|
94
|
+
api_key = mgr.get_key_for_model(model)
|
|
95
|
+
if api_key:
|
|
96
|
+
provider_name = ConfigManager._provider_for_model(model)
|
|
97
|
+
if provider_name:
|
|
98
|
+
providers = mgr.get_providers()
|
|
99
|
+
pc = providers.get(provider_name)
|
|
100
|
+
if pc and pc.api_key_env:
|
|
101
|
+
os.environ[pc.api_key_env] = api_key
|
|
102
|
+
if pc and pc.account_id_env and pc.account_id:
|
|
103
|
+
os.environ[pc.account_id_env] = pc.account_id
|
|
104
|
+
if pc and pc.litellm_provider:
|
|
105
|
+
target_pc = DEFAULT_PROVIDER_CATALOG.get(pc.litellm_provider)
|
|
106
|
+
if target_pc and target_pc.api_key_env:
|
|
107
|
+
os.environ[target_pc.api_key_env] = api_key
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@contextmanager
|
|
111
|
+
def provider_env(model: str):
|
|
112
|
+
"""Injects provider API keys for model into os.environ."""
|
|
113
|
+
mgr = ConfigManager()
|
|
114
|
+
_set_api_key_env(mgr, model)
|
|
115
|
+
try:
|
|
116
|
+
yield mgr
|
|
117
|
+
finally:
|
|
118
|
+
pass # env vars intentionally left set
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ConfigError(Exception):
|
|
122
|
+
"""Configuration error."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ConfigManager:
|
|
126
|
+
"""Manages Snodo user configuration.
|
|
127
|
+
|
|
128
|
+
Config file: ~/.snodo/config.yml
|
|
129
|
+
Enforces 600 permissions on the config file.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, config_dir: Optional[Path] = None):
|
|
133
|
+
"""Initialize config manager.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
config_dir: Override config directory (default: ~/.snodo)
|
|
137
|
+
"""
|
|
138
|
+
self.config_dir = config_dir or resolve_home()
|
|
139
|
+
self.config_path = self.config_dir / "config.yml"
|
|
140
|
+
|
|
141
|
+
def load(self) -> dict:
|
|
142
|
+
"""Load configuration from disk.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Configuration dict (empty dict if file doesn't exist)
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ConfigError: If config.yml uses the legacy ``api_keys`` format
|
|
149
|
+
instead of the ``providers`` section.
|
|
150
|
+
"""
|
|
151
|
+
if not self.config_path.exists():
|
|
152
|
+
return self._default_config()
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
with open(self.config_path) as f:
|
|
156
|
+
data = yaml.safe_load(f) or {}
|
|
157
|
+
except yaml.YAMLError as e:
|
|
158
|
+
raise ConfigError(f"Invalid config file: {e}") from e
|
|
159
|
+
|
|
160
|
+
# Reject legacy api_keys-only configs — providers is the only schema
|
|
161
|
+
if data.get("api_keys") and not data.get("providers"):
|
|
162
|
+
raise ConfigError(
|
|
163
|
+
"Legacy api_keys config detected. "
|
|
164
|
+
"Migrate to the providers section. See docs."
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Ensure required keys exist
|
|
168
|
+
data.setdefault("model", DEFAULT_MODEL)
|
|
169
|
+
data.setdefault("engine", {"max_subtask_depth": 3, "max_session_age_days": 30, "token_ttl_seconds": 600})
|
|
170
|
+
return data
|
|
171
|
+
|
|
172
|
+
def _default_config(self) -> dict:
|
|
173
|
+
return {
|
|
174
|
+
"model": DEFAULT_MODEL,
|
|
175
|
+
"engine": {"max_subtask_depth": 3, "max_session_age_days": 30, "token_ttl_seconds": 600},
|
|
176
|
+
"cloud": {
|
|
177
|
+
"api_key": "",
|
|
178
|
+
"api_url": "https://api.snodo.dev",
|
|
179
|
+
"sync_enabled": False,
|
|
180
|
+
},
|
|
181
|
+
"mcp": {
|
|
182
|
+
"port": 55441,
|
|
183
|
+
},
|
|
184
|
+
"opencode": {
|
|
185
|
+
"session_token_warning": 150000,
|
|
186
|
+
"session_reset_on_model_change": False,
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
def get_providers(self) -> Dict[str, ProviderConfig]:
|
|
191
|
+
"""Return configured providers, merged with defaults.
|
|
192
|
+
|
|
193
|
+
Config.yml ``providers`` section overrides default catalog entries.
|
|
194
|
+
Unlisted providers get their default values.
|
|
195
|
+
"""
|
|
196
|
+
config = self.load()
|
|
197
|
+
providers_raw = config.get("providers", {})
|
|
198
|
+
|
|
199
|
+
result: Dict[str, ProviderConfig] = {}
|
|
200
|
+
if isinstance(providers_raw, dict):
|
|
201
|
+
for name, raw in providers_raw.items():
|
|
202
|
+
if isinstance(raw, dict):
|
|
203
|
+
result[name] = ProviderConfig(**raw)
|
|
204
|
+
|
|
205
|
+
# Merge in defaults for providers not in config
|
|
206
|
+
for name, pc in DEFAULT_PROVIDER_CATALOG.items():
|
|
207
|
+
if name not in result:
|
|
208
|
+
result[name] = pc
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def resolve_api_base(model: str) -> Optional[str]:
|
|
213
|
+
"""Return the API base URL for *model*'s provider, or ``None``.
|
|
214
|
+
|
|
215
|
+
Checks the provider's ``base_url`` in the merged provider config.
|
|
216
|
+
"""
|
|
217
|
+
provider = ConfigManager._provider_for_model(model)
|
|
218
|
+
if provider is None:
|
|
219
|
+
return None
|
|
220
|
+
pc = ConfigManager().get_providers().get(provider)
|
|
221
|
+
if pc and pc.base_url:
|
|
222
|
+
return pc.base_url
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def resolve_litellm_provider(model: str) -> Optional[str]:
|
|
227
|
+
"""Return the provider string litellm should route as for *model*."""
|
|
228
|
+
provider_key = ConfigManager._provider_for_model(model)
|
|
229
|
+
if not provider_key:
|
|
230
|
+
return None
|
|
231
|
+
pc = ConfigManager().get_providers().get(provider_key)
|
|
232
|
+
if pc and pc.litellm_provider:
|
|
233
|
+
return pc.litellm_provider
|
|
234
|
+
return provider_key
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def resolve_litellm_model(model: str) -> str:
|
|
238
|
+
"""Return the model string formatted for litellm completion.
|
|
239
|
+
|
|
240
|
+
If a provider specifies a litellm_provider (e.g. litellm_provider="openai"
|
|
241
|
+
for provider block "ollama"), formats model as "openai/<model_id>" so litellm
|
|
242
|
+
routes it properly to the OpenAI-compatible endpoint.
|
|
243
|
+
"""
|
|
244
|
+
provider_key = ConfigManager._provider_for_model(model)
|
|
245
|
+
if not provider_key:
|
|
246
|
+
return model
|
|
247
|
+
pc = ConfigManager().get_providers().get(provider_key)
|
|
248
|
+
if pc and pc.litellm_provider:
|
|
249
|
+
if model.startswith(f"{provider_key}/"):
|
|
250
|
+
raw_id = model[len(provider_key) + 1:]
|
|
251
|
+
else:
|
|
252
|
+
raw_id = model
|
|
253
|
+
return f"{pc.litellm_provider}/{raw_id}"
|
|
254
|
+
return model
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def resolve_extra_headers(model: str, task_id: Optional[str] = None) -> Optional[Dict[str, str]]:
|
|
258
|
+
"""Return extra headers configured for model's provider.
|
|
259
|
+
|
|
260
|
+
Evaluates template variables like ``{task_id}`` in header values.
|
|
261
|
+
"""
|
|
262
|
+
provider_key = ConfigManager._provider_for_model(model)
|
|
263
|
+
if not provider_key:
|
|
264
|
+
return None
|
|
265
|
+
pc = ConfigManager().get_providers().get(provider_key)
|
|
266
|
+
if not pc or not pc.extra_headers:
|
|
267
|
+
return None
|
|
268
|
+
tid = task_id or "unknown"
|
|
269
|
+
headers = {}
|
|
270
|
+
for k, v in pc.extra_headers.items():
|
|
271
|
+
headers[k] = v.replace("{task_id}", tid)
|
|
272
|
+
return headers or None
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _provider_for_model(model: str) -> Optional[str]:
|
|
276
|
+
"""Return the provider (snodo config key) for a model string.
|
|
277
|
+
|
|
278
|
+
Checks PROVIDER_MODEL_PREFIXES first, then configured provider block
|
|
279
|
+
names (e.g. "ollama/llama3" → "ollama"), and falls back to litellm's
|
|
280
|
+
get_llm_provider().
|
|
281
|
+
"""
|
|
282
|
+
if not model:
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
# 1. Match against PROVIDER_MODEL_PREFIXES
|
|
286
|
+
for provider, prefixes in PROVIDER_MODEL_PREFIXES.items():
|
|
287
|
+
for prefix in prefixes:
|
|
288
|
+
if model.startswith(prefix):
|
|
289
|
+
return provider
|
|
290
|
+
|
|
291
|
+
# 2. Check if model starts with a configured provider key + "/"
|
|
292
|
+
providers = ConfigManager().get_providers()
|
|
293
|
+
if "/" in model:
|
|
294
|
+
prefix = model.split("/")[0]
|
|
295
|
+
if prefix in providers:
|
|
296
|
+
return prefix
|
|
297
|
+
|
|
298
|
+
# 3. litellm.get_llm_provider()
|
|
299
|
+
try:
|
|
300
|
+
from litellm import get_llm_provider
|
|
301
|
+
_model, provider_name, _, _ = get_llm_provider(model)
|
|
302
|
+
except Exception:
|
|
303
|
+
provider_name = None
|
|
304
|
+
|
|
305
|
+
if provider_name:
|
|
306
|
+
aliased = _PROVIDER_ALIASES.get(provider_name, provider_name)
|
|
307
|
+
if aliased in providers:
|
|
308
|
+
return aliased
|
|
309
|
+
return provider_name
|
|
310
|
+
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
def save(self, config: dict) -> None:
|
|
314
|
+
"""Save configuration to disk with secure permissions.
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
config: Configuration dict to save
|
|
318
|
+
"""
|
|
319
|
+
self.config_dir.mkdir(parents=True, exist_ok=True)
|
|
320
|
+
|
|
321
|
+
with open(self.config_path, "w") as f:
|
|
322
|
+
yaml.dump(config, f, default_flow_style=False)
|
|
323
|
+
|
|
324
|
+
# Set file permissions to 600 (owner read/write only)
|
|
325
|
+
os.chmod(self.config_path, stat.S_IRUSR | stat.S_IWUSR)
|
|
326
|
+
|
|
327
|
+
def add_key(self, provider: str, key: str) -> None:
|
|
328
|
+
"""Store an API key for a provider.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
provider: Provider name (e.g., "openai", "anthropic", "google")
|
|
332
|
+
key: API key string
|
|
333
|
+
"""
|
|
334
|
+
if not provider:
|
|
335
|
+
raise ConfigError("Provider name cannot be empty")
|
|
336
|
+
if not key:
|
|
337
|
+
raise ConfigError("API key cannot be empty")
|
|
338
|
+
|
|
339
|
+
config = self.load()
|
|
340
|
+
providers = config.setdefault("providers", {})
|
|
341
|
+
provider_data = providers.setdefault(provider, {})
|
|
342
|
+
provider_data["api_key"] = key
|
|
343
|
+
self.save(config)
|
|
344
|
+
|
|
345
|
+
def get_key(self, provider: str) -> Optional[str]:
|
|
346
|
+
"""Get an API key for a provider.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
provider: Provider name
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
API key string, or None if not configured
|
|
353
|
+
"""
|
|
354
|
+
pc = self.get_providers().get(provider)
|
|
355
|
+
if pc and pc.api_key:
|
|
356
|
+
return pc.api_key
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
def remove_key(self, provider: str) -> bool:
|
|
360
|
+
"""Remove an API key for a provider.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
provider: Provider name
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
True if key was removed, False if it didn't exist
|
|
367
|
+
"""
|
|
368
|
+
config = self.load()
|
|
369
|
+
providers_raw = config.get("providers", {})
|
|
370
|
+
if isinstance(providers_raw, dict):
|
|
371
|
+
entry = providers_raw.get(provider, {})
|
|
372
|
+
if isinstance(entry, dict) and "api_key" in entry:
|
|
373
|
+
del entry["api_key"]
|
|
374
|
+
self.save(config)
|
|
375
|
+
return True
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
def get_key_for_model(self, model: str) -> Optional[str]:
|
|
379
|
+
"""Resolve the API key needed for a given model.
|
|
380
|
+
|
|
381
|
+
Args:
|
|
382
|
+
model: Model identifier (e.g., "claude-sonnet-4-20250514", "gpt-4o")
|
|
383
|
+
|
|
384
|
+
Returns:
|
|
385
|
+
API key string, or None if no matching key found
|
|
386
|
+
"""
|
|
387
|
+
provider = self._provider_for_model(model)
|
|
388
|
+
if provider is None:
|
|
389
|
+
return None
|
|
390
|
+
return self.get_key(provider)
|
|
391
|
+
|
|
392
|
+
def set_model(self, model: str) -> None:
|
|
393
|
+
"""Set the default model.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
model: Model identifier
|
|
397
|
+
"""
|
|
398
|
+
config = self.load()
|
|
399
|
+
config["model"] = model
|
|
400
|
+
self.save(config)
|
|
401
|
+
|
|
402
|
+
def get_model(self) -> str:
|
|
403
|
+
"""Get the configured default model.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Model identifier
|
|
407
|
+
"""
|
|
408
|
+
config = self.load()
|
|
409
|
+
return config.get("default_model") or config.get("model", DEFAULT_MODEL)
|
|
410
|
+
|
|
411
|
+
def get_engine_value(self, key: str, default: Any = None) -> Any:
|
|
412
|
+
"""Get an engine configuration value.
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
key: Engine config key (e.g., "max_subtask_depth")
|
|
416
|
+
default: Default value if key not found
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
Config value
|
|
420
|
+
"""
|
|
421
|
+
config = self.load()
|
|
422
|
+
return config.get("engine", {}).get(key, default)
|
|
423
|
+
|
|
424
|
+
def set_engine_value(self, key: str, value: Any) -> None:
|
|
425
|
+
"""Set an engine configuration value.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
key: Engine config key
|
|
429
|
+
value: Value to set
|
|
430
|
+
|
|
431
|
+
Raises:
|
|
432
|
+
ValueError: If value fails validation
|
|
433
|
+
"""
|
|
434
|
+
if key == "max_subtask_depth":
|
|
435
|
+
if not isinstance(value, int) or value < 1 or value > 10:
|
|
436
|
+
raise ValueError(f"max_subtask_depth must be an integer between 1 and 10, got {value}")
|
|
437
|
+
elif key == "max_session_age_days":
|
|
438
|
+
if not isinstance(value, int) or value < 1 or value > 365:
|
|
439
|
+
raise ValueError(f"max_session_age_days must be an integer between 1 and 365, got {value}")
|
|
440
|
+
elif key == "token_ttl_seconds":
|
|
441
|
+
if not isinstance(value, int) or value < 60 or value > 86400:
|
|
442
|
+
raise ValueError(f"token_ttl_seconds must be an integer between 60 and 86400, got {value}")
|
|
443
|
+
|
|
444
|
+
config = self.load()
|
|
445
|
+
engine = config.setdefault("engine", {})
|
|
446
|
+
engine[key] = value
|
|
447
|
+
self.save(config)
|
|
448
|
+
|
|
449
|
+
def test_keys(self) -> Dict[str, str]:
|
|
450
|
+
"""Test all configured API keys via liteLLM.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
Dict of provider -> result status ("valid", "invalid", "untestable")
|
|
454
|
+
"""
|
|
455
|
+
results = {}
|
|
456
|
+
providers = self.get_providers()
|
|
457
|
+
|
|
458
|
+
for name, pc in providers.items():
|
|
459
|
+
key = self.get_key(name)
|
|
460
|
+
if not key:
|
|
461
|
+
continue
|
|
462
|
+
results[name] = self._test_single_key(name, key, pc)
|
|
463
|
+
|
|
464
|
+
return results
|
|
465
|
+
|
|
466
|
+
def _test_single_key(self, provider: str, key: str, pc: Optional[ProviderConfig] = None) -> str:
|
|
467
|
+
"""Test a single API key by making a minimal LLM call.
|
|
468
|
+
|
|
469
|
+
Args:
|
|
470
|
+
provider: Provider name
|
|
471
|
+
key: API key to test
|
|
472
|
+
pc: ProviderConfig with api_key_env and probe_model
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
"valid", "invalid", or "untestable"
|
|
476
|
+
"""
|
|
477
|
+
if pc is None:
|
|
478
|
+
pc = self.get_providers().get(provider) or DEFAULT_PROVIDER_CATALOG.get(provider)
|
|
479
|
+
|
|
480
|
+
if pc is None or not pc.probe_model:
|
|
481
|
+
return "untestable"
|
|
482
|
+
|
|
483
|
+
model = pc.probe_model
|
|
484
|
+
|
|
485
|
+
env_var = pc.api_key_env
|
|
486
|
+
if not env_var and not pc.litellm_provider:
|
|
487
|
+
return "untestable"
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
from litellm import completion
|
|
491
|
+
except (ImportError, Exception):
|
|
492
|
+
return "untestable"
|
|
493
|
+
|
|
494
|
+
target_env_vars = []
|
|
495
|
+
if env_var:
|
|
496
|
+
target_env_vars.append((env_var, key))
|
|
497
|
+
if pc.litellm_provider:
|
|
498
|
+
target_pc = DEFAULT_PROVIDER_CATALOG.get(pc.litellm_provider)
|
|
499
|
+
if target_pc and target_pc.api_key_env:
|
|
500
|
+
target_env_vars.append((target_pc.api_key_env, key))
|
|
501
|
+
if pc.account_id_env and pc.account_id:
|
|
502
|
+
target_env_vars.append((pc.account_id_env, pc.account_id))
|
|
503
|
+
|
|
504
|
+
saved_env = {}
|
|
505
|
+
for ev, val in target_env_vars:
|
|
506
|
+
saved_env[ev] = os.environ.get(ev)
|
|
507
|
+
os.environ[ev] = val
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
kwargs: dict[str, Any] = {
|
|
511
|
+
"model": ConfigManager.resolve_litellm_model(model),
|
|
512
|
+
"messages": [{"role": "user", "content": "hi"}],
|
|
513
|
+
"max_tokens": 1,
|
|
514
|
+
}
|
|
515
|
+
api_base = ConfigManager.resolve_api_base(model) or pc.base_url
|
|
516
|
+
if api_base:
|
|
517
|
+
kwargs["api_base"] = api_base
|
|
518
|
+
extra_headers = ConfigManager.resolve_extra_headers(model) or (pc.extra_headers if pc.extra_headers else None)
|
|
519
|
+
if extra_headers:
|
|
520
|
+
kwargs["extra_headers"] = extra_headers
|
|
521
|
+
|
|
522
|
+
completion(**kwargs)
|
|
523
|
+
return "valid"
|
|
524
|
+
except Exception:
|
|
525
|
+
return "invalid"
|
|
526
|
+
finally:
|
|
527
|
+
for ev, _ in target_env_vars:
|
|
528
|
+
old = saved_env.get(ev)
|
|
529
|
+
if old is not None:
|
|
530
|
+
os.environ[ev] = old
|
|
531
|
+
elif ev in os.environ:
|
|
532
|
+
del os.environ[ev]
|
|
533
|
+
|
|
534
|
+
@staticmethod
|
|
535
|
+
def mask_key(key: str) -> str:
|
|
536
|
+
"""Mask an API key for display, showing only prefix and suffix.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
key: Full API key
|
|
540
|
+
|
|
541
|
+
Returns:
|
|
542
|
+
Masked key (e.g., "sk-ab...xyz")
|
|
543
|
+
"""
|
|
544
|
+
if len(key) <= 8:
|
|
545
|
+
return key[:2] + "***"
|
|
546
|
+
return key[:5] + "***" + key[-3:]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
""""""
|