snodo-core 0.7.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- snodo/config.py +546 -0
- snodo/core/__init__.py +1 -0
- snodo/core/interfaces.py +132 -0
- snodo/paths.py +107 -0
- snodo/predicates/__init__.py +11 -0
- snodo/predicates/base.py +57 -0
- snodo/predicates/registry.py +53 -0
- snodo/predicates/scope.py +53 -0
- snodo/predicates/secrets.py +92 -0
- snodo/predicates/tests.py +78 -0
- snodo/project.py +180 -0
- snodo/py.typed +0 -0
- snodo/sandbox/__init__.py +39 -0
- snodo/sandbox/base.py +96 -0
- snodo/sandbox/docker_sandbox.py +195 -0
- snodo/sandbox/local_sandbox.py +101 -0
- snodo_core-0.7.2.dist-info/METADATA +10 -0
- snodo_core-0.7.2.dist-info/RECORD +20 -0
- snodo_core-0.7.2.dist-info/WHEEL +5 -0
- snodo_core-0.7.2.dist-info/top_level.txt +1 -0
snodo/config.py
ADDED
|
@@ -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:]
|
snodo/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
""""""
|
snodo/core/interfaces.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Core interfaces for the Snodo protocol engine.
|
|
2
|
+
|
|
3
|
+
All other modules implement against these contracts.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from typing import Any, Dict, List, Literal, Optional
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuditError(Exception):
|
|
13
|
+
"""Audit log operation failed (e.g., I/O write failure)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExecutionError(Exception):
|
|
17
|
+
"""Task execution produced no usable artifacts."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Coder(ABC):
|
|
21
|
+
"""Implements tasks. Can be LLM or human or traditional tooling.
|
|
22
|
+
|
|
23
|
+
The engine offers every adapter several optional capabilities (a progress
|
|
24
|
+
sink, a workspace, a job/task id for correlation, and two behavioural
|
|
25
|
+
switches). These are DECLARED here, with defaults, so that "this adapter
|
|
26
|
+
does not support X" is a visible fact rather than a silently skipped
|
|
27
|
+
``hasattr`` line (docs/architecture/coder-adapter-contract.md §3.1, #68).
|
|
28
|
+
An adapter that does not override a capability inherits the default; the
|
|
29
|
+
engine sets these attributes unconditionally, never behind a guard.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
#: Workspace the coder reads/writes, injected by the engine when the task
|
|
33
|
+
#: runs under a workspace. None for adapters that do not use one.
|
|
34
|
+
workspace_mcp: Optional[Any] = None
|
|
35
|
+
#: Progress sink handed to the coder by the engine; an adapter that wants
|
|
36
|
+
#: per-turn progress emits here. None = the adapter reports no progress.
|
|
37
|
+
progress_callback: Optional[Any] = None
|
|
38
|
+
#: When True, the coder writes its changes to the working tree directly
|
|
39
|
+
#: and the executor must NOT replay the returned artifacts through
|
|
40
|
+
#: WorkspaceMCP (e.g. in-place adapters). Default False: the executor
|
|
41
|
+
#: writes the artifacts.
|
|
42
|
+
skip_workspace_write: bool = False
|
|
43
|
+
#: When True, the coder (or its base class) owns the commit and the
|
|
44
|
+
#: executor must NOT stage/commit. This does NOT waive the obligation that
|
|
45
|
+
#: produced work be observable and attributable — "coder produced nothing"
|
|
46
|
+
#: is a fault regardless of who commits. Default False: the executor
|
|
47
|
+
#: commits.
|
|
48
|
+
skip_engine_commit: bool = False
|
|
49
|
+
#: Correlation ids the engine injects so adapter-side logging/telemetry
|
|
50
|
+
#: can be attributed to a job and task. Empty when not set.
|
|
51
|
+
_job_id: str = ""
|
|
52
|
+
_task_id: str = ""
|
|
53
|
+
#: Recovery depth and attempt number (1-based) of the task being executed,
|
|
54
|
+
#: injected by the engine so per-turn telemetry can be grouped by depth.
|
|
55
|
+
_depth: int = 0
|
|
56
|
+
_attempt: int = 1
|
|
57
|
+
#: Model identifier the adapter is bound to. Used for default-model
|
|
58
|
+
#: resolution and coder-respawn checks; may be empty on simple adapters.
|
|
59
|
+
model: str = ""
|
|
60
|
+
#: When True, the coder has access to and observes test execution feedback
|
|
61
|
+
#: during implementation (e.g. LiteLLMAdapter with test runner access).
|
|
62
|
+
#: Default False: the coder does not observe tests.
|
|
63
|
+
observes_tests: bool = False
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
def implement(self, spec: 'TaskSpec') -> 'CodeArtifact':
|
|
67
|
+
"""Generate code from specification."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MCPServer(ABC):
|
|
71
|
+
"""Tool boundary enforcement."""
|
|
72
|
+
|
|
73
|
+
@abstractmethod
|
|
74
|
+
def execute_tool(self, tool_name: str, params: Dict[str, Any]) -> Any:
|
|
75
|
+
"""Execute tool within capability boundary."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Task(BaseModel):
|
|
79
|
+
"""A unit of work."""
|
|
80
|
+
id: str
|
|
81
|
+
spec: str
|
|
82
|
+
parent_task_ref: Optional[str] = None
|
|
83
|
+
# The original task at the root of a recovery chain. Recovery subtasks
|
|
84
|
+
# derive their id (``<root>_fix_N``) and their spec (original intent +
|
|
85
|
+
# accumulated failures) from the root, never from the immediately previous
|
|
86
|
+
# attempt — see ADR 021.
|
|
87
|
+
root_task_ref: Optional[str] = None
|
|
88
|
+
root_spec: Optional[str] = None
|
|
89
|
+
prior_failures: List[Dict[str, Any]] = Field(default_factory=list)
|
|
90
|
+
# Recovery provenance: files earlier attempts in the same recovery chain
|
|
91
|
+
# wrote in the cumulative worktree. This is ownership context, not a
|
|
92
|
+
# rewrite request.
|
|
93
|
+
attempt_provenance: List[Dict[str, Any]] = Field(default_factory=list)
|
|
94
|
+
# Recovery read-set: paths earlier attempts inspected (files read,
|
|
95
|
+
# directories listed). Paths only, never contents — the tree changes
|
|
96
|
+
# between attempts and a cached version must not become authoritative.
|
|
97
|
+
attempt_reads: List[Dict[str, Any]] = Field(default_factory=list)
|
|
98
|
+
depth: int = 0
|
|
99
|
+
flow_type: Optional[str] = None
|
|
100
|
+
wave_id: Optional[str] = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ValidatorResult(BaseModel):
|
|
104
|
+
"""Output from a single validator."""
|
|
105
|
+
validator_id: str
|
|
106
|
+
severity: Literal["pass", "warn", "blocker"]
|
|
107
|
+
justification: str
|
|
108
|
+
error: bool = False
|
|
109
|
+
cited_criteria: Optional[List[str]] = None
|
|
110
|
+
#: Pre-cap severity when a severity_cap downgraded this result; None otherwise.
|
|
111
|
+
severity_original: Optional[str] = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TaskSpec(BaseModel):
|
|
115
|
+
"""Specification for code generation."""
|
|
116
|
+
description: str
|
|
117
|
+
constraints: List[str]
|
|
118
|
+
memory_summary: str = ""
|
|
119
|
+
project_context: Dict[str, Any] = Field(default_factory=dict)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class FileArtifact(BaseModel):
|
|
123
|
+
"""A file operation emitted by the coder."""
|
|
124
|
+
path: str
|
|
125
|
+
content: str = ""
|
|
126
|
+
action: str = "write" # "write" | "delete"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class CodeArtifact(BaseModel):
|
|
130
|
+
"""Generated code output — list of file operations."""
|
|
131
|
+
files: List[FileArtifact] = Field(default_factory=list)
|
|
132
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|