celltype-cli 0.1.0__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.
- celltype_cli-0.1.0.dist-info/METADATA +267 -0
- celltype_cli-0.1.0.dist-info/RECORD +89 -0
- celltype_cli-0.1.0.dist-info/WHEEL +4 -0
- celltype_cli-0.1.0.dist-info/entry_points.txt +2 -0
- celltype_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- ct/__init__.py +3 -0
- ct/agent/__init__.py +0 -0
- ct/agent/case_studies.py +426 -0
- ct/agent/config.py +523 -0
- ct/agent/doctor.py +544 -0
- ct/agent/knowledge.py +523 -0
- ct/agent/loop.py +99 -0
- ct/agent/mcp_server.py +478 -0
- ct/agent/orchestrator.py +733 -0
- ct/agent/runner.py +656 -0
- ct/agent/sandbox.py +481 -0
- ct/agent/session.py +145 -0
- ct/agent/system_prompt.py +186 -0
- ct/agent/trace_store.py +228 -0
- ct/agent/trajectory.py +169 -0
- ct/agent/types.py +182 -0
- ct/agent/workflows.py +462 -0
- ct/api/__init__.py +1 -0
- ct/api/app.py +211 -0
- ct/api/config.py +120 -0
- ct/api/engine.py +124 -0
- ct/cli.py +1448 -0
- ct/data/__init__.py +0 -0
- ct/data/compute_providers.json +59 -0
- ct/data/cro_database.json +395 -0
- ct/data/downloader.py +238 -0
- ct/data/loaders.py +252 -0
- ct/kb/__init__.py +5 -0
- ct/kb/benchmarks.py +147 -0
- ct/kb/governance.py +106 -0
- ct/kb/ingest.py +415 -0
- ct/kb/reasoning.py +129 -0
- ct/kb/schema_monitor.py +162 -0
- ct/kb/substrate.py +387 -0
- ct/models/__init__.py +0 -0
- ct/models/llm.py +370 -0
- ct/tools/__init__.py +195 -0
- ct/tools/_compound_resolver.py +297 -0
- ct/tools/biomarker.py +368 -0
- ct/tools/cellxgene.py +282 -0
- ct/tools/chemistry.py +1371 -0
- ct/tools/claude.py +390 -0
- ct/tools/clinical.py +1153 -0
- ct/tools/clue.py +249 -0
- ct/tools/code.py +1069 -0
- ct/tools/combination.py +397 -0
- ct/tools/compute.py +402 -0
- ct/tools/cro.py +413 -0
- ct/tools/data_api.py +2114 -0
- ct/tools/design.py +295 -0
- ct/tools/dna.py +575 -0
- ct/tools/experiment.py +604 -0
- ct/tools/expression.py +655 -0
- ct/tools/files.py +957 -0
- ct/tools/genomics.py +1387 -0
- ct/tools/http_client.py +146 -0
- ct/tools/imaging.py +319 -0
- ct/tools/intel.py +223 -0
- ct/tools/literature.py +743 -0
- ct/tools/network.py +422 -0
- ct/tools/notification.py +111 -0
- ct/tools/omics.py +3330 -0
- ct/tools/ops.py +1230 -0
- ct/tools/parity.py +649 -0
- ct/tools/pk.py +245 -0
- ct/tools/protein.py +678 -0
- ct/tools/regulatory.py +643 -0
- ct/tools/remote_data.py +179 -0
- ct/tools/report.py +181 -0
- ct/tools/repurposing.py +376 -0
- ct/tools/safety.py +1280 -0
- ct/tools/shell.py +178 -0
- ct/tools/singlecell.py +533 -0
- ct/tools/statistics.py +552 -0
- ct/tools/structure.py +882 -0
- ct/tools/target.py +901 -0
- ct/tools/translational.py +123 -0
- ct/tools/viability.py +218 -0
- ct/ui/__init__.py +0 -0
- ct/ui/markdown.py +31 -0
- ct/ui/status.py +258 -0
- ct/ui/suggestions.py +567 -0
- ct/ui/terminal.py +1456 -0
- ct/ui/traces.py +112 -0
ct/agent/config.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for ct.
|
|
3
|
+
|
|
4
|
+
Config is stored at ~/.ct/config.json and manages:
|
|
5
|
+
- LLM provider settings (Anthropic, OpenAI, local models)
|
|
6
|
+
- Data directory paths (DepMap, PRISM, etc.)
|
|
7
|
+
- Output preferences
|
|
8
|
+
- Tool-specific settings
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import logging
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from dotenv import load_dotenv
|
|
18
|
+
|
|
19
|
+
# Load .env from current dir and project root
|
|
20
|
+
load_dotenv()
|
|
21
|
+
load_dotenv(Path(__file__).resolve().parents[3] / ".env") # repo root
|
|
22
|
+
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
|
|
25
|
+
CONFIG_DIR = Path.home() / ".ct"
|
|
26
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
27
|
+
VALID_LLM_PROVIDERS = frozenset({"anthropic", "openai", "local", "gluelm"})
|
|
28
|
+
logger = logging.getLogger("ct.config")
|
|
29
|
+
|
|
30
|
+
DEFAULTS = {
|
|
31
|
+
"llm.provider": "anthropic",
|
|
32
|
+
"llm.model": "claude-sonnet-4-5-20250929",
|
|
33
|
+
"llm.api_key": None,
|
|
34
|
+
"llm.openai_api_key": None,
|
|
35
|
+
"llm.temperature": 0.1,
|
|
36
|
+
|
|
37
|
+
"data.base": str(CONFIG_DIR / "data"),
|
|
38
|
+
"data.depmap": None,
|
|
39
|
+
"data.prism": None,
|
|
40
|
+
"data.l1000": None,
|
|
41
|
+
"data.msigdb": None,
|
|
42
|
+
"data.alphafold": None,
|
|
43
|
+
"data.string": None,
|
|
44
|
+
"data.proteomics": None,
|
|
45
|
+
|
|
46
|
+
"api.data_endpoint": None,
|
|
47
|
+
"api.clue_key": None,
|
|
48
|
+
|
|
49
|
+
"output.format": "markdown",
|
|
50
|
+
"output.verbose": False,
|
|
51
|
+
"output.auto_publish_html_interactive": True,
|
|
52
|
+
"output.auto_publish_html_batch": False,
|
|
53
|
+
|
|
54
|
+
"ui.spinner": "benzene_breathing",
|
|
55
|
+
|
|
56
|
+
"models.gluelm": None,
|
|
57
|
+
"models.deepternary": None,
|
|
58
|
+
"models.boltz2": None,
|
|
59
|
+
|
|
60
|
+
"api.ibm_rxn_key": None,
|
|
61
|
+
"api.lens_key": None,
|
|
62
|
+
|
|
63
|
+
"notification.sendgrid_api_key": None,
|
|
64
|
+
"notification.from_email": None,
|
|
65
|
+
"notification.auto_send": False,
|
|
66
|
+
|
|
67
|
+
"compute.lambda_api_key": None,
|
|
68
|
+
"compute.runpod_api_key": None,
|
|
69
|
+
"compute.default_provider": "lambda",
|
|
70
|
+
|
|
71
|
+
"sandbox.timeout": 30,
|
|
72
|
+
"sandbox.output_dir": str(Path.cwd() / "outputs"),
|
|
73
|
+
"sandbox.max_retries": 2,
|
|
74
|
+
|
|
75
|
+
"agent.max_iterations": 3,
|
|
76
|
+
"agent.enable_experimental_tools": False,
|
|
77
|
+
"agent.observer_model": None,
|
|
78
|
+
"agent.executor_max_retries": 2,
|
|
79
|
+
"agent.executor_loop_limit": 50,
|
|
80
|
+
"agent.observer_confidence_threshold": 0.8,
|
|
81
|
+
"agent.synthesis_max_tokens": 8192,
|
|
82
|
+
"agent.enforce_grounded_synthesis": True,
|
|
83
|
+
"agent.enforce_claim_content_validation": True,
|
|
84
|
+
"agent.confidence_scoring_enabled": True,
|
|
85
|
+
"agent.min_step_success_rate": 0.5,
|
|
86
|
+
"agent.require_key_evidence_section": True,
|
|
87
|
+
"agent.allow_creative_hypotheses": True,
|
|
88
|
+
"agent.max_hypotheses": 3,
|
|
89
|
+
"agent.grounding_repair_retries": 1,
|
|
90
|
+
"agent.log_evidence_store": True,
|
|
91
|
+
"agent.memory_retrieval_enabled": True,
|
|
92
|
+
"agent.memory_retrieval_limit": 3,
|
|
93
|
+
"agent.verifier_model": None,
|
|
94
|
+
"agent.verifier_provider": None,
|
|
95
|
+
"agent.verifier_repair_retries": 1,
|
|
96
|
+
"agent.quality_gate_enabled": True,
|
|
97
|
+
"agent.quality_gate_strict": False,
|
|
98
|
+
"agent.quality_gate_repair_retries": 1,
|
|
99
|
+
"agent.quality_gate_repair_non_strict": False,
|
|
100
|
+
"agent.quality_gate_min_next_steps": 2,
|
|
101
|
+
"agent.quality_gate_max_next_steps": 3,
|
|
102
|
+
"agent.synthesis_style": "standard",
|
|
103
|
+
"agent.profile": "research",
|
|
104
|
+
"agent.enable_claude_code_tool": False,
|
|
105
|
+
"agent.parallel_default_count": 3,
|
|
106
|
+
"agent.parallel_auto_suggest": True,
|
|
107
|
+
"agent.parallel_max_threads": 5,
|
|
108
|
+
"agent.planner_max_tools": 90,
|
|
109
|
+
"agent.planner_compact_tool_descriptions": True,
|
|
110
|
+
"agent.tool_health_enabled": True,
|
|
111
|
+
"agent.tool_health_fail_threshold": 2,
|
|
112
|
+
"agent.tool_health_failure_window_s": 1800,
|
|
113
|
+
"agent.tool_health_suppress_seconds": 900,
|
|
114
|
+
"agent.preflight_validation_enabled": True,
|
|
115
|
+
|
|
116
|
+
"enterprise.enforce_policy": False,
|
|
117
|
+
"enterprise.audit_enabled": True,
|
|
118
|
+
"enterprise.audit_dir": str(Path.home() / ".ct" / "audit"),
|
|
119
|
+
"enterprise.blocked_tools": "",
|
|
120
|
+
"enterprise.blocked_categories": "",
|
|
121
|
+
"enterprise.require_tool_allowlist": False,
|
|
122
|
+
"enterprise.tool_allowlist": "",
|
|
123
|
+
"enterprise.max_cost_usd_per_query": 0.0,
|
|
124
|
+
|
|
125
|
+
"knowledge.enable_substrate": True,
|
|
126
|
+
"knowledge.auto_ingest_evidence": True,
|
|
127
|
+
"knowledge.substrate_path": str(Path.home() / ".ct" / "knowledge" / "substrate.json"),
|
|
128
|
+
"knowledge.schema_monitor_enabled": False,
|
|
129
|
+
|
|
130
|
+
"ops.base_dir": str(Path.home() / ".ct" / "ops"),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
AGENT_PROFILE_PRESETS = {
|
|
134
|
+
"research": {
|
|
135
|
+
"agent.enforce_grounded_synthesis": True,
|
|
136
|
+
"agent.enforce_claim_content_validation": True,
|
|
137
|
+
"agent.require_key_evidence_section": True,
|
|
138
|
+
"agent.allow_creative_hypotheses": True,
|
|
139
|
+
"agent.quality_gate_enabled": True,
|
|
140
|
+
"agent.quality_gate_strict": False,
|
|
141
|
+
"agent.quality_gate_repair_retries": 1,
|
|
142
|
+
"agent.quality_gate_repair_non_strict": False,
|
|
143
|
+
"agent.synthesis_style": "standard",
|
|
144
|
+
"agent.memory_retrieval_enabled": True,
|
|
145
|
+
"agent.enable_claude_code_tool": False,
|
|
146
|
+
"enterprise.enforce_policy": False,
|
|
147
|
+
"enterprise.blocked_tools": "",
|
|
148
|
+
"enterprise.blocked_categories": "",
|
|
149
|
+
"enterprise.require_tool_allowlist": False,
|
|
150
|
+
},
|
|
151
|
+
"enterprise": {
|
|
152
|
+
"agent.enforce_grounded_synthesis": True,
|
|
153
|
+
"agent.enforce_claim_content_validation": True,
|
|
154
|
+
"agent.require_key_evidence_section": True,
|
|
155
|
+
"agent.allow_creative_hypotheses": False,
|
|
156
|
+
"agent.quality_gate_enabled": True,
|
|
157
|
+
"agent.quality_gate_strict": True,
|
|
158
|
+
"agent.quality_gate_repair_retries": 2,
|
|
159
|
+
"agent.quality_gate_repair_non_strict": True,
|
|
160
|
+
"agent.synthesis_style": "standard",
|
|
161
|
+
"agent.memory_retrieval_enabled": True,
|
|
162
|
+
"agent.enable_claude_code_tool": False,
|
|
163
|
+
"enterprise.enforce_policy": True,
|
|
164
|
+
"enterprise.blocked_tools": "shell.run,files.delete_file,claude.code",
|
|
165
|
+
"enterprise.blocked_categories": "",
|
|
166
|
+
"enterprise.require_tool_allowlist": False,
|
|
167
|
+
},
|
|
168
|
+
"pharma": {
|
|
169
|
+
"agent.enforce_grounded_synthesis": True,
|
|
170
|
+
"agent.enforce_claim_content_validation": True,
|
|
171
|
+
"agent.require_key_evidence_section": True,
|
|
172
|
+
"agent.allow_creative_hypotheses": False,
|
|
173
|
+
"agent.quality_gate_enabled": True,
|
|
174
|
+
"agent.quality_gate_strict": True,
|
|
175
|
+
"agent.quality_gate_repair_retries": 2,
|
|
176
|
+
"agent.quality_gate_repair_non_strict": True,
|
|
177
|
+
"agent.quality_gate_min_next_steps": 3,
|
|
178
|
+
"agent.quality_gate_max_next_steps": 3,
|
|
179
|
+
"agent.synthesis_style": "pharma",
|
|
180
|
+
"agent.memory_retrieval_enabled": True,
|
|
181
|
+
"agent.enable_claude_code_tool": False,
|
|
182
|
+
"enterprise.enforce_policy": False,
|
|
183
|
+
"enterprise.blocked_tools": "",
|
|
184
|
+
"enterprise.blocked_categories": "",
|
|
185
|
+
"enterprise.require_tool_allowlist": False,
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
API_KEYS = {
|
|
191
|
+
"api.ibm_rxn_key": {
|
|
192
|
+
"name": "IBM RXN",
|
|
193
|
+
"env_var": "IBM_RXN_API_KEY",
|
|
194
|
+
"description": "AI-powered retrosynthesis (chemistry.retrosynthesis)",
|
|
195
|
+
"url": "https://rxn.res.ibm.com",
|
|
196
|
+
"free": True,
|
|
197
|
+
},
|
|
198
|
+
"api.lens_key": {
|
|
199
|
+
"name": "Lens.org",
|
|
200
|
+
"env_var": "LENS_API_KEY",
|
|
201
|
+
"description": "Patent search (literature.patent_search)",
|
|
202
|
+
"url": "https://www.lens.org/lens/user/subscriptions",
|
|
203
|
+
"free": True,
|
|
204
|
+
},
|
|
205
|
+
"notification.sendgrid_api_key": {
|
|
206
|
+
"name": "SendGrid",
|
|
207
|
+
"env_var": "SENDGRID_API_KEY",
|
|
208
|
+
"description": "Email sending (notification.send_email)",
|
|
209
|
+
"url": "https://sendgrid.com",
|
|
210
|
+
"free": True,
|
|
211
|
+
},
|
|
212
|
+
"compute.lambda_api_key": {
|
|
213
|
+
"name": "Lambda Labs",
|
|
214
|
+
"env_var": "LAMBDA_API_KEY",
|
|
215
|
+
"description": "GPU compute jobs (compute.submit_job)",
|
|
216
|
+
"url": "https://cloud.lambdalabs.com",
|
|
217
|
+
"free": False,
|
|
218
|
+
},
|
|
219
|
+
"compute.runpod_api_key": {
|
|
220
|
+
"name": "RunPod",
|
|
221
|
+
"env_var": "RUNPOD_API_KEY",
|
|
222
|
+
"description": "GPU compute jobs (compute.submit_job)",
|
|
223
|
+
"url": "https://www.runpod.io",
|
|
224
|
+
"free": False,
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _validate_config(config_dict: dict) -> list[str]:
|
|
230
|
+
"""Validate a config dict and return a list of warning/error messages.
|
|
231
|
+
|
|
232
|
+
Checks:
|
|
233
|
+
- Type correctness (numeric, bool, string)
|
|
234
|
+
- Range validity (positive integers, minimums)
|
|
235
|
+
- Interdependency warnings (pharma + quality_gate_strict)
|
|
236
|
+
- Unknown keys (possible typos)
|
|
237
|
+
"""
|
|
238
|
+
warnings: list[str] = []
|
|
239
|
+
|
|
240
|
+
# --- Unknown keys ---
|
|
241
|
+
known_keys = set(DEFAULTS.keys())
|
|
242
|
+
for key in config_dict:
|
|
243
|
+
if key not in known_keys:
|
|
244
|
+
warnings.append(f"Unknown config key '{key}' (possible typo)")
|
|
245
|
+
|
|
246
|
+
# --- Type checks ---
|
|
247
|
+
for key, value in config_dict.items():
|
|
248
|
+
if key not in DEFAULTS or value is None:
|
|
249
|
+
continue
|
|
250
|
+
default = DEFAULTS[key]
|
|
251
|
+
if default is None:
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
expected_type = type(default)
|
|
255
|
+
if expected_type == bool:
|
|
256
|
+
if not isinstance(value, bool):
|
|
257
|
+
warnings.append(
|
|
258
|
+
f"Type error: '{key}' should be bool, got {type(value).__name__} ({value!r})"
|
|
259
|
+
)
|
|
260
|
+
elif expected_type == int:
|
|
261
|
+
if not isinstance(value, (int, float)):
|
|
262
|
+
warnings.append(
|
|
263
|
+
f"Type error: '{key}' should be int, got {type(value).__name__} ({value!r})"
|
|
264
|
+
)
|
|
265
|
+
elif expected_type == float:
|
|
266
|
+
if not isinstance(value, (int, float)):
|
|
267
|
+
warnings.append(
|
|
268
|
+
f"Type error: '{key}' should be float, got {type(value).__name__} ({value!r})"
|
|
269
|
+
)
|
|
270
|
+
elif expected_type == str:
|
|
271
|
+
if not isinstance(value, str):
|
|
272
|
+
warnings.append(
|
|
273
|
+
f"Type error: '{key}' should be str, got {type(value).__name__} ({value!r})"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# --- Range checks ---
|
|
277
|
+
def _check_positive_int(key: str, label: str):
|
|
278
|
+
val = config_dict.get(key)
|
|
279
|
+
if val is not None and isinstance(val, (int, float)) and val <= 0:
|
|
280
|
+
warnings.append(f"Range error: '{key}' ({label}) must be > 0, got {val}")
|
|
281
|
+
|
|
282
|
+
def _check_min(key: str, minimum: int, label: str):
|
|
283
|
+
val = config_dict.get(key)
|
|
284
|
+
if val is not None and isinstance(val, (int, float)) and val < minimum:
|
|
285
|
+
warnings.append(
|
|
286
|
+
f"Range error: '{key}' ({label}) must be >= {minimum}, got {val}"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
_check_positive_int("agent.max_iterations", "max iterations")
|
|
290
|
+
_check_positive_int("agent.executor_max_retries", "executor max retries")
|
|
291
|
+
_check_positive_int("agent.executor_loop_limit", "executor loop limit")
|
|
292
|
+
_check_positive_int("agent.parallel_max_threads", "parallel max threads")
|
|
293
|
+
_check_min("agent.synthesis_max_tokens", 512, "synthesis max tokens")
|
|
294
|
+
_check_min("sandbox.timeout", 1, "sandbox timeout")
|
|
295
|
+
|
|
296
|
+
# --- Interdependency checks ---
|
|
297
|
+
profile = config_dict.get("agent.profile")
|
|
298
|
+
if profile == "pharma":
|
|
299
|
+
qg_strict = config_dict.get(
|
|
300
|
+
"agent.quality_gate_strict",
|
|
301
|
+
DEFAULTS.get("agent.quality_gate_strict"),
|
|
302
|
+
)
|
|
303
|
+
if qg_strict is False or qg_strict == 0:
|
|
304
|
+
warnings.append(
|
|
305
|
+
"Interdependency warning: profile is 'pharma' but "
|
|
306
|
+
"agent.quality_gate_strict is false (recommended: true)"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
return warnings
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class Config:
|
|
313
|
+
"""ct configuration manager."""
|
|
314
|
+
|
|
315
|
+
def __init__(self, data: dict = None):
|
|
316
|
+
self._data = data or {}
|
|
317
|
+
self._env_loaded_keys: set[str] = set()
|
|
318
|
+
|
|
319
|
+
def __repr__(self) -> str:
|
|
320
|
+
"""Safe repr that masks API keys and secrets."""
|
|
321
|
+
safe = {}
|
|
322
|
+
for k, v in self._data.items():
|
|
323
|
+
if ("api_key" in k or "secret" in k or k.startswith("api.")) and v:
|
|
324
|
+
safe[k] = str(v)[:4] + "..." if len(str(v)) > 4 else "***"
|
|
325
|
+
else:
|
|
326
|
+
safe[k] = v
|
|
327
|
+
return f"Config({safe})"
|
|
328
|
+
|
|
329
|
+
@classmethod
|
|
330
|
+
def load(cls) -> "Config":
|
|
331
|
+
"""Load config from disk, creating defaults if needed."""
|
|
332
|
+
if CONFIG_FILE.exists():
|
|
333
|
+
try:
|
|
334
|
+
with open(CONFIG_FILE) as f:
|
|
335
|
+
data = json.load(f)
|
|
336
|
+
if not isinstance(data, dict):
|
|
337
|
+
logger.warning(
|
|
338
|
+
"Invalid config format in %s (expected JSON object), ignoring file",
|
|
339
|
+
CONFIG_FILE,
|
|
340
|
+
)
|
|
341
|
+
data = {}
|
|
342
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
343
|
+
logger.warning("Failed to read config file %s: %s", CONFIG_FILE, exc)
|
|
344
|
+
data = {}
|
|
345
|
+
else:
|
|
346
|
+
data = {}
|
|
347
|
+
|
|
348
|
+
# Migrate legacy global output dir default to workspace-local output dir.
|
|
349
|
+
legacy_output_dir = str(Path.home() / ".ct" / "outputs")
|
|
350
|
+
if data.get("sandbox.output_dir") == legacy_output_dir:
|
|
351
|
+
data["sandbox.output_dir"] = str(Path.cwd() / "outputs")
|
|
352
|
+
|
|
353
|
+
# Check environment variables
|
|
354
|
+
env_mappings = {
|
|
355
|
+
"ANTHROPIC_API_KEY": "llm.api_key",
|
|
356
|
+
"OPENAI_API_KEY": "llm.openai_api_key",
|
|
357
|
+
"CT_DATA_DIR": "data.base",
|
|
358
|
+
"CT_LLM_PROVIDER": "llm.provider",
|
|
359
|
+
"CT_LLM_MODEL": "llm.model",
|
|
360
|
+
"IBM_RXN_API_KEY": "api.ibm_rxn_key",
|
|
361
|
+
"LENS_API_KEY": "api.lens_key",
|
|
362
|
+
"SENDGRID_API_KEY": "notification.sendgrid_api_key",
|
|
363
|
+
"LAMBDA_API_KEY": "compute.lambda_api_key",
|
|
364
|
+
"RUNPOD_API_KEY": "compute.runpod_api_key",
|
|
365
|
+
"CT_DATA_ENDPOINT": "api.data_endpoint",
|
|
366
|
+
"CLUE_API_KEY": "api.clue_key",
|
|
367
|
+
}
|
|
368
|
+
for env_var, config_key in env_mappings.items():
|
|
369
|
+
val = os.environ.get(env_var)
|
|
370
|
+
if val and config_key not in data:
|
|
371
|
+
data[config_key] = val
|
|
372
|
+
|
|
373
|
+
cfg = cls(data)
|
|
374
|
+
# Track keys loaded from environment so they're masked in __repr__/logs
|
|
375
|
+
cfg._env_loaded_keys = {
|
|
376
|
+
config_key for env_var, config_key in env_mappings.items()
|
|
377
|
+
if os.environ.get(env_var) and config_key in data
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
# Run validation and log warnings (never crash)
|
|
381
|
+
issues = _validate_config(data)
|
|
382
|
+
for issue in issues:
|
|
383
|
+
logger.warning("Config validation: %s", issue)
|
|
384
|
+
|
|
385
|
+
return cfg
|
|
386
|
+
|
|
387
|
+
def validate(self) -> list[str]:
|
|
388
|
+
"""Run schema validation on current config data. Returns list of issues."""
|
|
389
|
+
return _validate_config(self._data)
|
|
390
|
+
|
|
391
|
+
def save(self):
|
|
392
|
+
"""Save config to disk."""
|
|
393
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
394
|
+
with open(CONFIG_FILE, "w") as f:
|
|
395
|
+
json.dump(self._data, f, indent=2)
|
|
396
|
+
|
|
397
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
398
|
+
"""Get a config value, falling back to defaults."""
|
|
399
|
+
return self._data.get(key, DEFAULTS.get(key, default))
|
|
400
|
+
|
|
401
|
+
def set(self, key: str, value: Any):
|
|
402
|
+
"""Set a config value."""
|
|
403
|
+
if key == "agent.profile":
|
|
404
|
+
profile = str(value).strip().lower()
|
|
405
|
+
if profile not in AGENT_PROFILE_PRESETS:
|
|
406
|
+
valid = ", ".join(sorted(AGENT_PROFILE_PRESETS.keys()))
|
|
407
|
+
raise ValueError(
|
|
408
|
+
f"Invalid agent.profile '{value}'. Valid profiles: {valid}"
|
|
409
|
+
)
|
|
410
|
+
for preset_key, preset_val in AGENT_PROFILE_PRESETS[profile].items():
|
|
411
|
+
self._data[preset_key] = preset_val
|
|
412
|
+
self._data["agent.profile"] = profile
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
# Type coercion
|
|
416
|
+
if key in DEFAULTS and DEFAULTS[key] is not None:
|
|
417
|
+
expected_type = type(DEFAULTS[key])
|
|
418
|
+
if expected_type == bool:
|
|
419
|
+
value = value.lower() in ("true", "1", "yes") if isinstance(value, str) else bool(value)
|
|
420
|
+
elif expected_type == float:
|
|
421
|
+
value = float(value)
|
|
422
|
+
elif expected_type == int:
|
|
423
|
+
value = int(value)
|
|
424
|
+
|
|
425
|
+
self._data[key] = value
|
|
426
|
+
|
|
427
|
+
def llm_api_key(self, provider: Optional[str] = None) -> Optional[str]:
|
|
428
|
+
"""Get the best API key for the selected provider."""
|
|
429
|
+
provider = (provider or self.get("llm.provider", "anthropic")).lower()
|
|
430
|
+
if provider == "openai":
|
|
431
|
+
return self.get("llm.openai_api_key") or self.get("llm.api_key")
|
|
432
|
+
return self.get("llm.api_key")
|
|
433
|
+
|
|
434
|
+
def llm_preflight_issue(self) -> Optional[str]:
|
|
435
|
+
"""Return a human-readable LLM config issue, or None when ready."""
|
|
436
|
+
provider_raw = self.get("llm.provider", "anthropic")
|
|
437
|
+
provider = str(provider_raw or "").strip().lower()
|
|
438
|
+
if not provider:
|
|
439
|
+
return "llm.provider is empty. Set it with: ct config set llm.provider anthropic"
|
|
440
|
+
|
|
441
|
+
if provider not in VALID_LLM_PROVIDERS:
|
|
442
|
+
valid = ", ".join(sorted(VALID_LLM_PROVIDERS))
|
|
443
|
+
return (
|
|
444
|
+
f"Unsupported llm.provider '{provider}'. "
|
|
445
|
+
f"Valid providers: {valid}. Set it with: ct config set llm.provider <provider>"
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
if provider in {"local", "gluelm"}:
|
|
449
|
+
if not self.get("llm.model"):
|
|
450
|
+
return (
|
|
451
|
+
f"llm.model is required for provider '{provider}'. "
|
|
452
|
+
"Set it with: ct config set llm.model <model-id-or-path>"
|
|
453
|
+
)
|
|
454
|
+
return None
|
|
455
|
+
|
|
456
|
+
if self.llm_api_key(provider):
|
|
457
|
+
return None
|
|
458
|
+
|
|
459
|
+
if provider == "openai":
|
|
460
|
+
return (
|
|
461
|
+
"OpenAI API key not configured. Set OPENAI_API_KEY or run:\n"
|
|
462
|
+
" ct config set llm.openai_api_key <key>"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
"Anthropic API key not configured. Set ANTHROPIC_API_KEY or run:\n"
|
|
467
|
+
" ct config set llm.api_key <key>"
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
def keys_table(self) -> Table:
|
|
471
|
+
"""Render API key status as a rich table."""
|
|
472
|
+
table = Table(title="API Keys", caption="Set: ct config set <key> <value> | Or: export ENV_VAR=<value>")
|
|
473
|
+
table.add_column("Service", style="bold")
|
|
474
|
+
table.add_column("Status")
|
|
475
|
+
table.add_column("Unlocks", style="dim")
|
|
476
|
+
table.add_column("Config Key", style="cyan dim")
|
|
477
|
+
table.add_column("Sign Up", style="dim")
|
|
478
|
+
|
|
479
|
+
for config_key, info in API_KEYS.items():
|
|
480
|
+
val = self.get(config_key)
|
|
481
|
+
if val:
|
|
482
|
+
status = "[green]configured[/green]"
|
|
483
|
+
else:
|
|
484
|
+
status = "[red]not set[/red]"
|
|
485
|
+
|
|
486
|
+
free_tag = " (free)" if info["free"] else ""
|
|
487
|
+
table.add_row(
|
|
488
|
+
info["name"],
|
|
489
|
+
status,
|
|
490
|
+
info["description"],
|
|
491
|
+
config_key,
|
|
492
|
+
info["url"] + free_tag,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
return table
|
|
496
|
+
|
|
497
|
+
def to_table(self) -> Table:
|
|
498
|
+
"""Render config as a rich table."""
|
|
499
|
+
table = Table(title="ct Configuration")
|
|
500
|
+
table.add_column("Key", style="cyan")
|
|
501
|
+
table.add_column("Value", style="green")
|
|
502
|
+
table.add_column("Source", style="dim")
|
|
503
|
+
|
|
504
|
+
all_keys = sorted(set(list(DEFAULTS.keys()) + list(self._data.keys())))
|
|
505
|
+
for key in all_keys:
|
|
506
|
+
if key in self._data:
|
|
507
|
+
val = self._data[key]
|
|
508
|
+
source = "config"
|
|
509
|
+
elif key in DEFAULTS:
|
|
510
|
+
val = DEFAULTS[key]
|
|
511
|
+
source = "default"
|
|
512
|
+
else:
|
|
513
|
+
continue
|
|
514
|
+
|
|
515
|
+
# Mask sensitive values (API keys, secrets)
|
|
516
|
+
display_val = str(val)
|
|
517
|
+
is_sensitive = "api_key" in key or "secret" in key or key.startswith("api.")
|
|
518
|
+
if is_sensitive and val and len(str(val)) > 8:
|
|
519
|
+
display_val = str(val)[:4] + "..." + str(val)[-4:]
|
|
520
|
+
|
|
521
|
+
table.add_row(key, display_val, source)
|
|
522
|
+
|
|
523
|
+
return table
|