sim_lib_openai_server/runtime/
federation.rs1use std::{
2 collections::BTreeMap,
3 sync::{Arc, Mutex},
4};
5
6use serde_json::{Map, Number, Value as JsonValue};
7use sim_kernel::{
8 Consistency, Cx, Error, EvalFabricRef, EvalMode, EvalRequest, Expr, Result, Symbol,
9};
10use sim_lib_agent_runner_core::{ModelRequest, ModelResponse, ModelUsage};
11
12use sim_codec_chat::text_part;
13use sim_codec_json::json_number_to_u64;
14
15use crate::{
16 capabilities::openai_gateway_federate_capability, objects::GatewayResponseValue,
17 plan::fixtures::request_text,
18};
19
20#[derive(Clone, Default)]
22pub struct OpenAiFederation {
23 gateways: Arc<Mutex<BTreeMap<String, OpenAiFederatedGateway>>>,
24}
25
26#[derive(Clone)]
29pub struct OpenAiFederatedGateway {
30 address: String,
31 default_model: String,
32 fabric: EvalFabricRef,
33}
34
35#[derive(Clone, Debug, Default, PartialEq)]
37pub struct OpenAiFederationPolicy {
38 privacy: Option<String>,
39 budget: Vec<(String, Expr)>,
40}
41
42impl OpenAiFederation {
43 pub fn new() -> Self {
45 Self::default()
46 }
47
48 pub fn insert_gateway(
50 &self,
51 address: impl AsRef<str>,
52 default_model: impl Into<String>,
53 fabric: EvalFabricRef,
54 ) -> Result<()> {
55 let gateway = OpenAiFederatedGateway::new(address.as_ref(), default_model, fabric)?;
56 self.gateways
57 .lock()
58 .map_err(|_| Error::PoisonedLock("openai federation registry"))?
59 .insert(gateway.address.clone(), gateway);
60 Ok(())
61 }
62
63 pub fn infer(
70 &self,
71 cx: &mut Cx,
72 address: &str,
73 request: &ModelRequest,
74 policy: &OpenAiFederationPolicy,
75 ) -> Result<ModelResponse> {
76 cx.require(&openai_gateway_federate_capability())?;
77 let gateway = self.gateway(address)?;
78 let body = federated_request_body(&gateway.default_model, request, policy)?;
79 let reply = gateway.fabric.realize(
80 cx,
81 EvalRequest {
82 expr: Expr::Bytes(body),
83 result_shape: None,
84 required_capabilities: Vec::new(),
85 deadline: None,
86 consistency: Consistency::LocalFirst,
87 mode: EvalMode::Eval,
88 answer_limit: None,
89 stream_buffer: None,
90 stream: false,
91 trace: false,
92 },
93 )?;
94 let Some(response) = reply.value.object().downcast_ref::<GatewayResponseValue>() else {
95 return Err(Error::Eval(format!(
96 "federated gateway {address} returned a non-response value"
97 )));
98 };
99 if response.response().status() != 200 {
100 return Err(Error::Eval(format!(
101 "federated gateway {address} returned status {}",
102 response.response().status()
103 )));
104 }
105 responses_body_to_model_response(address, response.response().body())
106 }
107
108 fn gateway(&self, address: &str) -> Result<OpenAiFederatedGateway> {
109 let address = normalize_gateway_address(address)?;
110 self.gateways
111 .lock()
112 .map_err(|_| Error::PoisonedLock("openai federation registry"))?
113 .get(&address)
114 .cloned()
115 .ok_or_else(|| Error::Eval(format!("model_not_found: {address}")))
116 }
117}
118
119impl OpenAiFederatedGateway {
120 pub fn new(
122 address: impl AsRef<str>,
123 default_model: impl Into<String>,
124 fabric: EvalFabricRef,
125 ) -> Result<Self> {
126 Ok(Self {
127 address: normalize_gateway_address(address.as_ref())?,
128 default_model: default_model.into(),
129 fabric,
130 })
131 }
132}
133
134impl OpenAiFederationPolicy {
135 pub fn new(privacy: Option<String>, budget: Vec<(String, Expr)>) -> Self {
137 Self { privacy, budget }
138 }
139
140 pub fn with_budget_entries(&self, entries: Vec<(String, Expr)>) -> Self {
142 let mut budget = self.budget.clone();
143 budget.extend(entries);
144 Self {
145 privacy: self.privacy.clone(),
146 budget,
147 }
148 }
149}
150
151fn normalize_gateway_address(address: &str) -> Result<String> {
152 let trimmed = address.trim();
153 let Some(target) = trimmed.strip_prefix("gateway/") else {
154 return Err(Error::Eval(format!(
155 "federated gateway address must start with gateway/, found {address}"
156 )));
157 };
158 if target.is_empty() {
159 return Err(Error::Eval("federated gateway address is empty".to_owned()));
160 }
161 Ok(format!("gateway/{target}"))
162}
163
164fn federated_request_body(
165 model: &str,
166 request: &ModelRequest,
167 policy: &OpenAiFederationPolicy,
168) -> Result<Vec<u8>> {
169 let mut object = Map::new();
170 object.insert("model".to_owned(), JsonValue::String(model.to_owned()));
171 object.insert("input".to_owned(), JsonValue::String(request_text(request)));
172 object.insert("store".to_owned(), JsonValue::Bool(false));
173 if let Some(privacy) = &policy.privacy {
174 object.insert("privacy".to_owned(), JsonValue::String(privacy.clone()));
175 }
176 let budget = budget_json(&policy.budget);
177 if !budget.is_empty() {
178 object.insert("budget".to_owned(), JsonValue::Object(budget));
179 }
180 serde_json::to_vec(&JsonValue::Object(object))
181 .map_err(|err| Error::Eval(format!("failed to encode federated gateway request: {err}")))
182}
183
184fn budget_json(entries: &[(String, Expr)]) -> Map<String, JsonValue> {
185 let mut object = Map::new();
186 for (key, value) in entries {
187 object.insert(key.clone(), expr_to_json(value));
188 }
189 object
190}
191
192fn expr_to_json(expr: &Expr) -> JsonValue {
196 match expr {
197 Expr::Nil => JsonValue::Null,
198 Expr::Bool(value) => JsonValue::Bool(*value),
199 Expr::String(value) => JsonValue::String(value.clone()),
200 Expr::Symbol(value) if value.namespace.is_none() => {
201 JsonValue::String(value.name.as_ref().to_owned())
202 }
203 Expr::Number(value) => number_literal_json(&value.canonical),
204 Expr::List(items) => match items.as_slice() {
205 [Expr::Symbol(head), Expr::String(value)]
206 if head.namespace.is_none() && head.name.as_ref() == "plan/atom" =>
207 {
208 atom_literal_json(value)
209 }
210 _ => JsonValue::Array(items.iter().map(expr_to_json).collect()),
211 },
212 Expr::Map(entries) => JsonValue::Object(
213 entries
214 .iter()
215 .map(|(key, value)| (json_key(key), expr_to_json(value)))
216 .collect(),
217 ),
218 Expr::Bytes(bytes) => JsonValue::String(bytes_to_hex(bytes)),
219 _ => JsonValue::String(format!("{expr:?}")),
220 }
221}
222
223fn json_key(expr: &Expr) -> String {
224 match expr {
225 Expr::String(value) => value.clone(),
226 Expr::Symbol(value) if value.namespace.is_none() => value.name.as_ref().to_owned(),
227 Expr::Symbol(value) => format!(
228 "{}/{}",
229 value.namespace.as_deref().unwrap_or(""),
230 value.name
231 ),
232 _ => format!("{expr:?}"),
233 }
234}
235
236fn atom_literal_json(value: &str) -> JsonValue {
237 match value {
238 "true" => JsonValue::Bool(true),
239 "false" => JsonValue::Bool(false),
240 _ => number_literal_json(value),
241 }
242}
243
244fn number_literal_json(value: &str) -> JsonValue {
245 if let Ok(unsigned) = value.parse::<u64>() {
246 return JsonValue::Number(Number::from(unsigned));
247 }
248 value
249 .parse::<f64>()
250 .ok()
251 .and_then(Number::from_f64)
252 .map(JsonValue::Number)
253 .unwrap_or_else(|| JsonValue::String(value.to_owned()))
254}
255
256fn responses_body_to_model_response(address: &str, body: &[u8]) -> Result<ModelResponse> {
257 let value: JsonValue = serde_json::from_slice(body)
258 .map_err(|err| Error::Eval(format!("federated gateway returned invalid json: {err}")))?;
259 let object = value
260 .as_object()
261 .ok_or_else(|| Error::Eval("federated gateway response must be an object".to_owned()))?;
262 let output_text = object
263 .get("output_text")
264 .and_then(JsonValue::as_str)
265 .ok_or_else(|| Error::Eval("federated gateway response missing output_text".to_owned()))?;
266 let mut response = ModelResponse::new(
267 Symbol::new(address.to_owned()),
268 address,
269 vec![text_part(output_text)],
270 Symbol::new("stop"),
271 );
272 response.usage = object.get("usage").map(usage_from_json).transpose()?;
273 if let Some(model) = object.get("model").and_then(JsonValue::as_str) {
274 response.extra.push((
275 Expr::Symbol(Symbol::new("federated-model")),
276 Expr::String(model.to_owned()),
277 ));
278 }
279 Ok(response)
280}
281
282fn usage_from_json(value: &JsonValue) -> Result<ModelUsage> {
283 let object = value
284 .as_object()
285 .ok_or_else(|| Error::Eval("federated gateway usage must be an object".to_owned()))?;
286 Ok(ModelUsage {
287 input_tokens: object.get("prompt_tokens").and_then(json_number_to_u64),
288 output_tokens: object.get("completion_tokens").and_then(json_number_to_u64),
289 latency_ms: None,
290 cost_usd: None,
291 extra: Vec::new(),
292 })
293}
294
295fn bytes_to_hex(bytes: &[u8]) -> String {
296 const HEX: &[u8; 16] = b"0123456789abcdef";
297 let mut output = String::with_capacity(bytes.len() * 2);
298 for byte in bytes {
299 output.push(HEX[(byte >> 4) as usize] as char);
300 output.push(HEX[(byte & 0x0f) as usize] as char);
301 }
302 output
303}