1use std::collections::BTreeMap;
8
9use anyhow::Context;
10use serde::{Deserialize, Serialize};
11use tracing::warn;
12
13#[derive(Debug, Clone, PartialEq, Serialize)]
17pub struct SetupQuestionOut {
18 pub name: String,
19 pub title: String,
20 pub kind: String, pub required: bool,
22 pub secret: bool, #[serde(skip_serializing_if = "Option::is_none")]
24 pub help: Option<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub group: Option<String>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub docs_url: Option<String>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub placeholder: Option<String>,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize)]
34pub struct SetupSpecOut {
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub title: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub description: Option<String>,
39 pub questions: Vec<SetupQuestionOut>,
40}
41
42impl SetupSpecOut {
43 pub fn to_yaml(&self) -> anyhow::Result<String> {
44 serde_yaml_bw::to_string(self).map_err(Into::into)
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize)]
51pub struct SecretRequirementOut {
52 pub key: String,
53 #[serde(skip_serializing_if = "is_true")]
54 pub required: bool,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub description: Option<String>,
57}
58
59fn is_true(b: &bool) -> bool {
60 *b
61}
62
63#[derive(Debug, Clone, PartialEq)]
67pub struct ProviderOverlay {
68 pub label: String,
69 pub docs_url: String,
70 pub placeholder: String,
71}
72
73fn overlay(label: &str, docs_url: &str, placeholder: &str) -> ProviderOverlay {
74 ProviderOverlay {
75 label: label.to_string(),
76 docs_url: docs_url.to_string(),
77 placeholder: placeholder.to_string(),
78 }
79}
80
81pub fn llm_overlay(provider: &str) -> Option<ProviderOverlay> {
86 Some(match provider {
87 "openai" => overlay("OpenAI", "https://platform.openai.com/api-keys", "sk-..."),
88 "anthropic" => overlay(
89 "Anthropic",
90 "https://console.anthropic.com/settings/keys",
91 "sk-ant-...",
92 ),
93 "deepseek" => overlay("DeepSeek", "https://platform.deepseek.com", "sk-..."),
94 "gemini" => overlay(
95 "Google Gemini",
96 "https://aistudio.google.com/app/apikey",
97 "AIza...",
98 ),
99 "cohere" => overlay("Cohere", "https://dashboard.cohere.com/api-keys", "..."),
100 "groq" => overlay("Groq", "https://console.groq.com/keys", "gsk_..."),
101 "perplexity" => overlay(
102 "Perplexity",
103 "https://www.perplexity.ai/settings/api",
104 "pplx-...",
105 ),
106 "xai" => overlay("xAI", "https://console.x.ai", "xai-..."),
107 "mistral" => overlay("Mistral", "https://console.mistral.ai/api-keys", "..."),
108 "openrouter" => overlay("OpenRouter", "https://openrouter.ai/keys", "sk-or-..."),
109 _ => return None,
110 })
111}
112
113#[derive(Debug, Clone, PartialEq)]
115pub struct GeneratedSetup {
116 pub setup_yaml: String,
117 pub secret_requirements_json: String,
118}
119
120#[derive(Debug, Clone, PartialEq, Deserialize)]
123pub struct ToolSecretReq {
124 pub key: String,
125 #[serde(default = "default_required")]
126 pub required: bool,
127 #[serde(default)]
128 pub description: Option<String>,
129 #[serde(default)]
130 pub format: Option<String>,
131}
132
133fn default_required() -> bool {
134 true
135}
136
137#[derive(Deserialize, Default)]
139struct DescribeMinimal {
140 #[serde(default)]
141 contributions: DescribeContributions,
142}
143
144#[derive(Deserialize, Default)]
145struct DescribeContributions {
146 #[serde(default)]
147 tools: Vec<DescribeTool>,
148}
149
150#[derive(Deserialize)]
151struct DescribeTool {
152 #[serde(default)]
153 name: String,
154 #[serde(default)]
155 secret_requirements: Vec<ToolSecretReq>,
156}
157
158pub fn extract_tool_secret_requirements(
161 describe_json: &[u8],
162 used_tool_names: &[String],
163) -> anyhow::Result<Vec<ToolSecretReq>> {
164 let describe: DescribeMinimal =
165 serde_json::from_slice(describe_json).context("parse extension describe.json")?;
166 let mut seen = std::collections::BTreeSet::new();
167 let mut out = Vec::new();
168 for tool in &describe.contributions.tools {
169 if !used_tool_names.iter().any(|t| t == &tool.name) {
170 continue;
171 }
172 for req in &tool.secret_requirements {
173 if seen.insert(req.key.clone()) {
174 out.push(req.clone());
175 }
176 }
177 }
178 Ok(out)
179}
180
181struct Pending {
183 secret_key: String, provider: String, last_segment: String, question: SetupQuestionOut,
187 requirement: SecretRequirementOut,
188}
189
190fn last_segment(key: &str) -> &str {
191 key.rsplit('/').next().unwrap_or(key)
192}
193
194fn llm_question(provider: &str, credential_ref: &str) -> Pending {
195 let secret_key = format!("llm/{credential_ref}");
196 let (title, help, docs_url, placeholder) = match llm_overlay(provider) {
197 Some(o) => (
198 format!("{} API key", o.label),
199 Some("LLM API key for the agentic worker's reasoning loop.".to_string()),
200 Some(o.docs_url),
201 Some(o.placeholder),
202 ),
203 None => {
204 warn!(
205 provider,
206 "no LLM overlay; emitting a minimal credential question"
207 );
208 (
209 format!("{provider} API key"),
210 Some("LLM API key for the agentic worker's reasoning loop.".to_string()),
211 None,
212 None,
213 )
214 }
215 };
216 Pending {
217 secret_key: secret_key.clone(),
218 provider: "llm".to_string(),
219 last_segment: credential_ref.to_string(),
220 question: SetupQuestionOut {
221 name: credential_ref.to_string(),
222 title,
223 kind: "string".to_string(),
224 required: true,
225 secret: true,
226 help,
227 group: Some("LLM".to_string()),
228 docs_url,
229 placeholder,
230 },
231 requirement: SecretRequirementOut {
232 key: secret_key,
233 required: true,
234 description: None,
235 },
236 }
237}
238
239fn tool_question(req: &ToolSecretReq) -> Pending {
240 let provider = req.key.split('/').next().unwrap_or("").to_string();
241 let seg = last_segment(&req.key).to_string();
242 Pending {
243 secret_key: req.key.clone(),
244 provider,
245 last_segment: seg.clone(),
246 question: SetupQuestionOut {
247 name: seg.clone(),
248 title: title_for_secret_segment(&seg),
249 kind: "string".to_string(),
250 required: req.required,
251 secret: true,
252 help: req.description.clone(),
253 group: Some("Tools".to_string()),
254 docs_url: None,
255 placeholder: None,
256 },
257 requirement: SecretRequirementOut {
258 key: req.key.clone(),
259 required: req.required,
260 description: req.description.clone(),
261 },
262 }
263}
264
265fn titleize(s: &str) -> String {
266 s.split(['_', '-', ' '])
267 .filter(|w| !w.is_empty())
268 .map(|w| {
269 let mut c = w.chars();
270 match c.next() {
271 Some(f) => f.to_uppercase().chain(c).collect::<String>(),
272 None => String::new(),
273 }
274 })
275 .collect::<Vec<_>>()
276 .join(" ")
277}
278
279fn title_for_secret_segment(segment: &str) -> String {
284 let titled = titleize(segment);
285 if titled.to_ascii_lowercase().ends_with("key") {
286 titled
287 } else {
288 format!("{titled} key")
289 }
290}
291
292pub fn generate(
296 pack_id: &str,
297 agents: &BTreeMap<String, serde_json::Value>,
298 tool_reqs_by_ext: &BTreeMap<String, Vec<ToolSecretReq>>,
299 component_reqs: &[SecretRequirementOut],
300) -> anyhow::Result<Option<GeneratedSetup>> {
301 let mut pending: Vec<Pending> = Vec::new();
302 let mut seen_keys = std::collections::BTreeSet::new();
303
304 for agent in agents.values() {
305 if let Some(cred) = agent
307 .get("llm")
308 .and_then(|l| l.get("credential_ref"))
309 .and_then(|c| c.as_str())
310 {
311 let provider = agent["llm"]
312 .get("provider")
313 .and_then(|p| p.as_str())
314 .unwrap_or("");
315 let p = llm_question(provider, cred);
316 if seen_keys.insert(p.secret_key.clone()) {
317 pending.push(p);
318 }
319 }
320 if let Some(tools) = agent.get("tools").and_then(|t| t.as_array()) {
322 for tool in tools {
323 let ext_id = tool
324 .get("extension_id")
325 .and_then(|e| e.as_str())
326 .unwrap_or("");
327 let Some(reqs) = tool_reqs_by_ext.get(ext_id) else {
328 continue;
329 };
330 for req in reqs {
331 if seen_keys.insert(req.key.clone()) {
333 pending.push(tool_question(req));
334 }
335 }
336 }
337 }
338 }
339
340 if pending.is_empty() && component_reqs.is_empty() {
341 return Ok(None);
342 }
343
344 let mut name_counts: BTreeMap<String, usize> = BTreeMap::new();
347 for p in &pending {
348 *name_counts.entry(p.question.name.clone()).or_default() += 1;
349 }
350 for p in &mut pending {
351 if name_counts.get(&p.question.name).copied().unwrap_or(0) > 1 {
352 p.question.name = format!("{}_{}", p.provider, p.last_segment);
353 }
354 }
355
356 let questions: Vec<SetupQuestionOut> = pending.iter().map(|p| p.question.clone()).collect();
357 let mut requirements: Vec<SecretRequirementOut> =
358 pending.iter().map(|p| p.requirement.clone()).collect();
359 for cr in component_reqs {
361 if !requirements.iter().any(|r| r.key == cr.key) {
362 requirements.push(cr.clone());
363 }
364 }
365
366 let spec = SetupSpecOut {
367 title: Some(format!("{pack_id} — credentials")),
368 description: Some("API keys for the agentic worker and its tools.".to_string()),
369 questions,
370 };
371 Ok(Some(GeneratedSetup {
372 setup_yaml: spec.to_yaml()?,
373 secret_requirements_json: serde_json::to_string_pretty(&requirements)?,
374 }))
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use std::collections::BTreeMap;
381
382 fn tavily_agents() -> BTreeMap<String, serde_json::Value> {
383 let agent = serde_json::json!({
384 "agent_id": "demo_assistant",
385 "llm": {"provider": "deepseek", "model": "deepseek-chat", "credential_ref": "deepseek"},
386 "tools": [
387 {"extension_id": "greentic.tavily", "tool_name": "tavily_search"},
388 {"extension_id": "greentic.tavily", "tool_name": "tavily_extract"}
389 ]
390 });
391 BTreeMap::from([("demo_assistant".to_string(), agent)])
392 }
393
394 fn tavily_tool_reqs() -> BTreeMap<String, Vec<ToolSecretReq>> {
395 BTreeMap::from([(
396 "greentic.tavily".to_string(),
397 vec![ToolSecretReq {
398 key: "tavily/api_key".to_string(),
399 required: true,
400 description: Some("Tavily web-search API key.".to_string()),
401 format: Some("text".to_string()),
402 }],
403 )])
404 }
405
406 #[test]
407 fn generate_produces_llm_and_tool_questions() {
408 let output = generate(
409 "agentic-research-tavily-demo",
410 &tavily_agents(),
411 &tavily_tool_reqs(),
412 &[],
413 )
414 .unwrap()
415 .expect("some output");
416
417 let spec: serde_json::Value = serde_yaml_bw::from_str(&output.setup_yaml).unwrap();
418 let q = spec["questions"].as_array().unwrap();
419 assert_eq!(q.len(), 2, "one LLM + one tool (deduped)");
420
421 let llm = q.iter().find(|x| x["name"] == "deepseek").unwrap();
422 assert_eq!(llm["group"], "LLM");
423 assert_eq!(llm["title"], "DeepSeek API key");
424 assert_eq!(llm["docs_url"], "https://platform.deepseek.com");
425 assert_eq!(llm["secret"], true);
426
427 let tool = q.iter().find(|x| x["name"] == "api_key").unwrap();
428 assert_eq!(tool["group"], "Tools");
429 assert_eq!(tool["help"], "Tavily web-search API key.");
430 assert_eq!(tool["title"], "Api Key");
433
434 let reqs: Vec<serde_json::Value> =
435 serde_json::from_str(&output.secret_requirements_json).unwrap();
436 let keys: Vec<&str> = reqs.iter().map(|r| r["key"].as_str().unwrap()).collect();
437 assert!(keys.contains(&"llm/deepseek"));
438 assert!(keys.contains(&"tavily/api_key"));
439 }
440
441 #[test]
442 fn tool_title_appends_key_only_when_segment_lacks_it() {
443 assert_eq!(title_for_secret_segment("api_key"), "Api Key");
445 assert_eq!(title_for_secret_segment("token"), "Token key");
447 }
448
449 #[test]
450 fn generate_disambiguates_colliding_tool_names() {
451 let mut tool_reqs = tavily_tool_reqs();
452 tool_reqs.insert(
453 "other.search".to_string(),
454 vec![ToolSecretReq {
455 key: "other/api_key".to_string(),
456 required: true,
457 description: None,
458 format: None,
459 }],
460 );
461 let mut agents = tavily_agents();
462 agents.insert(
464 "a2".to_string(),
465 serde_json::json!({
466 "agent_id": "a2",
467 "llm": {"provider": "openai", "credential_ref": "openai"},
468 "tools": [{"extension_id": "other.search", "tool_name": "search"}]
469 }),
470 );
471 let output = generate("p", &agents, &tool_reqs, &[]).unwrap().unwrap();
472 let spec: serde_json::Value = serde_yaml_bw::from_str(&output.setup_yaml).unwrap();
473 let names: Vec<&str> = spec["questions"]
474 .as_array()
475 .unwrap()
476 .iter()
477 .map(|q| q["name"].as_str().unwrap())
478 .collect();
479 assert!(names.contains(&"tavily_api_key"));
480 assert!(names.contains(&"other_api_key"));
481 }
482
483 #[test]
484 fn llm_overlay_known_and_unknown() {
485 let d = llm_overlay("deepseek").expect("deepseek known");
486 assert_eq!(d.label, "DeepSeek");
487 assert!(d.docs_url.starts_with("https://"));
488 assert!(d.placeholder.starts_with("sk-"));
489 assert!(llm_overlay("totally-unknown-provider").is_none());
490 }
491
492 const TAVILY_DESCRIBE: &str = r#"{
493 "contributions": {
494 "tools": [
495 {"name": "tavily_search", "secret_requirements": [
496 {"key": "tavily/api_key", "required": true, "description": "Search key", "format": "text"}]},
497 {"name": "tavily_extract", "secret_requirements": [
498 {"key": "tavily/api_key", "required": true, "description": "Extract key", "format": "text"}]}
499 ]
500 }
501 }"#;
502
503 #[test]
504 fn extracts_and_dedupes_tool_secrets_for_used_tools() {
505 let used = vec!["tavily_search".to_string(), "tavily_extract".to_string()];
506 let reqs = extract_tool_secret_requirements(TAVILY_DESCRIBE.as_bytes(), &used).unwrap();
507 assert_eq!(reqs.len(), 1, "same key on two tools dedupes to one");
508 assert_eq!(reqs[0].key, "tavily/api_key");
509 assert_eq!(reqs[0].description.as_deref(), Some("Search key"));
510 }
511
512 #[test]
513 fn ignores_secrets_of_unused_tools() {
514 let used = vec!["tavily_extract".to_string()];
515 let reqs = extract_tool_secret_requirements(TAVILY_DESCRIBE.as_bytes(), &used).unwrap();
516 assert_eq!(reqs.len(), 1);
517 assert_eq!(reqs[0].description.as_deref(), Some("Extract key"));
518 }
519
520 #[test]
521 fn setup_spec_serializes_and_round_trips_with_optional_fields_omitted() {
522 let spec = SetupSpecOut {
523 title: Some("Demo — credentials".to_string()),
524 description: None,
525 questions: vec![SetupQuestionOut {
526 name: "deepseek".to_string(),
527 title: "DeepSeek API key".to_string(),
528 kind: "string".to_string(),
529 required: true,
530 secret: true,
531 help: Some("LLM key".to_string()),
532 group: Some("LLM".to_string()),
533 docs_url: Some("https://platform.deepseek.com".to_string()),
534 placeholder: Some("sk-...".to_string()),
535 }],
536 };
537 let yaml = spec.to_yaml().expect("serialize");
538 assert!(!yaml.contains("description"));
540 assert!(yaml.contains("name: deepseek"));
541 let v: serde_json::Value = serde_yaml_bw::from_str(&yaml).expect("parse");
543 assert_eq!(v["questions"][0]["group"], "LLM");
544 assert!(v["questions"][0].get("default").is_none());
545 }
546}