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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ """"""