soma_application/providers/
remote.rs1use anyhow::{anyhow, Context, Result};
2use async_trait::async_trait;
3use serde_json::{json, Value};
4use soma_provider_core::{
5 CliOverlay, EnvRequirement, McpOverlay, ProviderCatalog, ProviderIdentity, ProviderKind,
6 ProviderManifest, ProviderPrompt, ProviderResource, ProviderTool, RestOverlay,
7};
8
9use crate::{
10 provider_errors::ProviderError,
11 provider_registry::{Provider, ProviderCall, ProviderOutput},
12 SomaService,
13};
14
15#[derive(Clone)]
18pub struct RemoteCatalogProvider {
19 service: SomaService,
20 catalog: ProviderCatalog,
21}
22
23impl RemoteCatalogProvider {
24 pub fn new(service: SomaService, catalog: ProviderCatalog) -> Self {
26 Self { service, catalog }
27 }
28}
29
30#[async_trait]
31impl Provider for RemoteCatalogProvider {
32 fn catalog(&self) -> ProviderCatalog {
33 self.catalog.clone()
34 }
35
36 async fn call(&self, call: ProviderCall) -> Result<ProviderOutput, ProviderError> {
37 self.service
38 .call_rest_action(&call.action, call.params)
39 .await
40 .map(ProviderOutput::json)
41 .map_err(|error| ProviderError::opaque_execution(&call.provider, call.action, error))
42 }
43}
44
45pub fn catalogs_from_inspection(report: &Value) -> Result<Vec<ProviderCatalog>> {
48 let providers = report
49 .get("providers")
50 .and_then(Value::as_array)
51 .ok_or_else(|| anyhow!("remote provider catalog missing providers array"))?;
52 providers.iter().map(catalog_from_provider).collect()
53}
54
55fn catalog_from_provider(provider: &Value) -> Result<ProviderCatalog> {
56 let name = string_field(provider, "name")?;
57 let kind = provider_kind(string_field(provider, "kind")?)?;
58 Ok(ProviderManifest {
59 schema_version: 1,
60 provider: ProviderIdentity {
61 name: name.to_owned(),
62 kind,
63 title: optional_string(provider, "title"),
64 description: optional_string(provider, "description"),
65 homepage: optional_string(provider, "homepage"),
66 source: optional_string(provider, "source"),
67 version: optional_string(provider, "version"),
68 enabled: provider.get("enabled").and_then(Value::as_bool),
69 },
70 tools: provider
71 .get("tools")
72 .and_then(Value::as_array)
73 .into_iter()
74 .flatten()
75 .map(tool_from_value)
76 .collect::<Result<Vec<_>>>()?,
77 prompts: provider
78 .get("prompts")
79 .and_then(Value::as_array)
80 .into_iter()
81 .flatten()
82 .map(prompt_from_value)
83 .collect::<Result<Vec<_>>>()?,
84 resources: provider
85 .get("resources")
86 .and_then(Value::as_array)
87 .into_iter()
88 .flatten()
89 .map(resource_from_value)
90 .collect::<Result<Vec<_>>>()?,
91 tasks: Vec::new(),
92 elicitation: Vec::new(),
93 env: Vec::new(),
94 capabilities: provider
95 .get("declared_capabilities")
96 .cloned()
97 .map(serde_json::from_value)
98 .transpose()
99 .context("remote provider declared_capabilities invalid")?
100 .unwrap_or_default(),
101 docs: None,
102 plugin: None,
103 ui: None,
104 meta: json!({ "remote_catalog": true }),
105 })
106}
107
108fn tool_from_value(tool: &Value) -> Result<ProviderTool> {
109 Ok(ProviderTool {
110 name: string_field(tool, "name")?.to_owned(),
111 description: optional_string(tool, "description").unwrap_or_default(),
112 title: optional_string(tool, "title"),
113 input_schema: tool
114 .get("input_schema")
115 .cloned()
116 .unwrap_or_else(|| json!({"type": "object", "properties": {}})),
117 output_schema: tool
118 .get("output_schema")
119 .cloned()
120 .filter(|value| !value.is_null()),
121 scope: optional_string(tool, "scope"),
122 destructive: tool
123 .get("destructive")
124 .and_then(Value::as_bool)
125 .unwrap_or(false),
126 requires_admin: tool
127 .get("requires_admin")
128 .and_then(Value::as_bool)
129 .unwrap_or(false),
130 cost: optional_string(tool, "cost"),
131 env: tool
132 .get("env")
133 .cloned()
134 .map(serde_json::from_value::<Vec<EnvRequirement>>)
135 .transpose()
136 .context("remote tool env invalid")?
137 .unwrap_or_default(),
138 limits: tool
139 .get("limits")
140 .cloned()
141 .filter(|value| !value.is_null())
142 .map(serde_json::from_value)
143 .transpose()
144 .context("remote tool limits invalid")?,
145 mcp: mcp_overlay(tool),
146 rest: rest_overlay(tool)?,
147 cli: tool
148 .get("cli")
149 .cloned()
150 .filter(|value| !value.is_null())
151 .map(serde_json::from_value::<CliOverlay>)
152 .transpose()
153 .context("remote tool cli overlay invalid")?,
154 palette: None,
155 ui: None,
156 examples: Vec::new(),
157 meta: json!({ "remote_catalog": true }),
158 })
159}
160
161fn prompt_from_value(prompt: &Value) -> Result<ProviderPrompt> {
162 Ok(ProviderPrompt {
163 name: string_field(prompt, "name")?.to_owned(),
164 description: optional_string(prompt, "description").unwrap_or_default(),
165 template: optional_string(prompt, "template"),
166 arguments_schema: prompt
167 .get("arguments_schema")
168 .cloned()
169 .filter(|value| !value.is_null()),
170 scope: optional_string(prompt, "scope"),
171 mcp: mcp_overlay(prompt),
172 examples: Vec::new(),
173 })
174}
175
176fn resource_from_value(resource: &Value) -> Result<ProviderResource> {
177 Ok(ProviderResource {
178 uri_template: string_field(resource, "uri_template")?.to_owned(),
179 name: string_field(resource, "name")?.to_owned(),
180 description: optional_string(resource, "description").unwrap_or_default(),
181 mime_type: optional_string(resource, "mime_type"),
182 scope: optional_string(resource, "scope"),
183 mcp: mcp_overlay(resource),
184 annotations: resource
185 .get("annotations")
186 .cloned()
187 .unwrap_or_else(|| json!({})),
188 })
189}
190
191fn mcp_overlay(value: &Value) -> Option<McpOverlay> {
192 let enabled = value
193 .get("surfaces")
194 .and_then(|surfaces| surfaces.get("mcp"))
195 .and_then(Value::as_bool)?;
196 Some(McpOverlay {
197 enabled,
198 title: None,
199 annotations: json!({}),
200 })
201}
202
203fn rest_overlay(tool: &Value) -> Result<Option<RestOverlay>> {
204 if let Some(rest) = tool.get("rest").filter(|value| !value.is_null()) {
205 return serde_json::from_value(rest.clone())
206 .map(Some)
207 .context("remote tool rest overlay invalid");
208 }
209 if tool
210 .get("surfaces")
211 .and_then(|surfaces| surfaces.get("rest"))
212 .and_then(Value::as_bool)
213 == Some(false)
214 {
215 return Ok(Some(RestOverlay {
216 enabled: false,
217 method: None,
218 path: None,
219 tags: Vec::new(),
220 summary: None,
221 description: None,
222 deprecated: false,
223 path_params: json!({}),
224 query_params: json!({}),
225 request_body_schema: None,
226 }));
227 }
228 Ok(None)
229}
230
231fn provider_kind(kind: &str) -> Result<ProviderKind> {
232 match kind {
233 "static-rust" => Ok(ProviderKind::StaticRust),
234 "openapi" => Ok(ProviderKind::Openapi),
235 "ai-sdk" => Ok(ProviderKind::AiSdk),
236 "wasm" => Ok(ProviderKind::Wasm),
237 "mcp" => Ok(ProviderKind::Mcp),
238 "python" => Ok(ProviderKind::Python),
239 "langchain" => Ok(ProviderKind::Langchain),
240 "llamaindex" => Ok(ProviderKind::Llamaindex),
241 other => Err(anyhow!("unknown remote provider kind `{other}`")),
242 }
243}
244
245fn string_field<'a>(value: &'a Value, field: &str) -> Result<&'a str> {
246 value
247 .get(field)
248 .and_then(Value::as_str)
249 .ok_or_else(|| anyhow!("remote catalog entry missing string field `{field}`"))
250}
251
252fn optional_string(value: &Value, field: &str) -> Option<String> {
253 value
254 .get(field)
255 .and_then(Value::as_str)
256 .map(ToOwned::to_owned)
257}
258
259#[cfg(test)]
260#[path = "remote_tests.rs"]
261mod tests;