blob: a29748f961d2f5c8ee5d4ceeafdd5295793784d0 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use std::collections::HashSet;
use maka_agent_facts::configuration::ConnectionEffectFailureClass;
use maka_agent_facts::configuration::ModelCapabilities;
use maka_agent_facts::configuration::ModelInfo;
use serde_json::Value;
type Failure = ConnectionEffectFailureClass;
/// Provider-specific JSON ends here; catalog normalization operates on typed rows.
pub(super) fn from_rows<'a>(
provider: &str,
rows: impl IntoIterator<Item = &'a Value>,
) -> Result<Vec<ModelInfo>, Failure> {
let models = rows
.into_iter()
.map(|row| model_info(provider, row))
.collect::<Result<Vec<_>, _>>()?
.into_iter()
.flatten()
.collect();
normalize(models)
}
pub(super) fn normalize(models: Vec<ModelInfo>) -> Result<Vec<ModelInfo>, Failure> {
let mut seen = HashSet::new();
let mut normalized = Vec::new();
for mut model in models {
let id = model.id.trim_matches(model_whitespace);
if id.is_empty()
|| id.chars().any(|c| c <= '\u{1f}' || c == '\u{7f}')
|| !seen.insert(id.to_owned())
{
continue;
}
model.id = id.to_owned();
model.validate().map_err(|_| Failure::InvalidResponse)?;
normalized.push(model);
}
Ok(normalized)
}
fn optional_array(value: Option<&Value>) -> Result<&[Value], Failure> {
match value {
None => Ok(&[]),
Some(Value::Array(values)) => Ok(values),
_ => Err(Failure::InvalidResponse),
}
}
fn object_array(value: Option<&Value>) -> Result<&[Value], Failure> {
let values = optional_array(value)?;
if values.iter().any(|v| !v.is_object()) {
return Err(Failure::InvalidResponse);
}
Ok(values)
}
fn contains(values: &[Value], expected: &str) -> bool {
values.iter().any(|v| v.as_str() == Some(expected))
}
fn model_info(provider: &str, row: &Value) -> Result<Option<ModelInfo>, Failure> {
let Some(id) = row
.get("id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
else {
return Ok(None);
};
if provider == "openrouter" {
return crate::catalog::openrouter::model(row)
.map(Some)
.map_err(|_| Failure::InvalidResponse);
}
let input = optional_array(row.get("input_modalities"))?;
let output = optional_array(row.get("output_modalities"))?;
let tags = optional_array(row.get("tags"))?;
let providers = object_array(row.get("providers"))?;
let mut capabilities = ModelCapabilities {
vision: row
.get("supports_image_in")
.and_then(Value::as_bool)
.or_else(|| contains(input, "image").then_some(true)),
reasoning: row
.get("supports_reasoning")
.and_then(Value::as_bool)
.or_else(|| {
row.pointer("/capabilities/reasoning")
.and_then(Value::as_bool)
}),
..Default::default()
};
if contains(tags, "vision") {
capabilities.vision = Some(true);
}
if contains(tags, "reasoning") {
capabilities.reasoning = Some(true);
}
if contains(tags, "tool-use") {
capabilities.function_calling = Some(true);
}
if row.get("providers").is_some() {
capabilities.function_calling = Some(providers.iter().any(|provider| {
provider.get("status").and_then(Value::as_str) == Some("live")
&& provider.get("supports_tools").and_then(Value::as_bool) == Some(true)
}));
}
let known_output = ["text", "image", "audio", "pdf", "video"]
.iter()
.any(|kind| contains(output, kind));
if known_output && !contains(output, "text") {
capabilities.chat = Some(false);
capabilities.image_generation = contains(output, "image").then_some(true);
}
let mut model = ModelInfo::new(id);
model.display_name = match row
.get("display_name")
.filter(|value| !value.is_null())
.or(row.get("name"))
{
None | Some(Value::Null) => None,
Some(Value::String(value)) => Some(value.clone()),
_ => return Err(Failure::InvalidResponse),
};
model.context_window = row
.get("context_length")
.and_then(token_limit)
.or_else(|| row.get("context_window").and_then(token_limit));
model.max_output_tokens = row.get("max_tokens").and_then(token_limit);
if capabilities != ModelCapabilities::default() {
model.capabilities = Some(capabilities);
}
Ok(Some(model))
}
fn token_limit(value: &Value) -> Option<u64> {
value
.as_f64()
.filter(|n| (1.0..=9_007_199_254_740_991.0).contains(n) && n.fract() == 0.0)
.map(|n| n as u64)
}
fn model_whitespace(c: char) -> bool {
matches!(c, '\u{9}'..='\u{d}' | '\u{20}' | '\u{a0}' | '\u{1680}'
| '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}'
| '\u{205f}' | '\u{3000}' | '\u{feff}')
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn decode(body: &[u8]) -> Result<Vec<Value>, Failure> {
let text = String::from_utf8_lossy(body);
let root: Value = serde_json::from_str(text.trim_start_matches('\u{feff}'))
.map_err(|_| Failure::InvalidResponse)?;
if !root.is_object() && !root.is_array() {
return Err(Failure::InvalidResponse);
}
super::from_rows("fixture", object_array(root.get("data"))?).map(|models| {
models
.into_iter()
.map(|model| serde_json::to_value(model).unwrap())
.collect()
})
}
#[test]
fn metadata_precedence_and_unknown_fields() {
let rows = decode(
br#"{"data":[{"id":" x ","display_name":"","name":"fallback",
"context_length":0,"context_window":4096.0,"max_tokens":1e3,
"input_modalities":["image"],"output_modalities":[null,"Text","image"],
"supports_image_in":false,"capabilities":{"reasoning":true},
"supports_reasoning":false,"tags":["reasoning","tool-use"],"providers":[],
"unknown":true}]}"#,
)
.unwrap();
assert_eq!(
rows,
vec![json!({"id":"x","displayName":"","contextWindow":4096,
"maxOutputTokens":1000,"capabilities":{"vision":false,"reasoning":true,
"functionCalling":false,"chat":false,"imageGeneration":true}})]
);
}
#[test]
fn first_duplicate_wins_and_long_ids_preserve_complete_identity() {
let body = json!({"data":[{"id":"\u{feff}x\u{feff}","name":"first"},
{"id":"x","name":"second"},{"id":"a\u{7f}"},{"id":"😀".repeat(257)},
{"id":"😀".repeat(256)},{"id":"\u{85}y\u{85}"}]})
.to_string();
let rows = decode(body.as_bytes()).unwrap();
assert_eq!(rows.len(), 4);
assert_eq!(rows[0], json!({"id":"x","displayName":"first"}));
assert_eq!(rows[1]["id"], "😀".repeat(257));
assert_eq!(rows[3]["id"], "\u{85}y\u{85}");
}
#[test]
fn envelope_and_metadata_structure_failures() {
for body in [
"null",
"true",
"{",
r#"{"data":null}"#,
r#"{"data":[1]}"#,
r#"{"data":[{"id":"x","providers":[null]}]}"#,
r#"{"data":[{"id":"x","tags":null}]}"#,
r#"{"data":[{"id":"x","name":42}]}"#,
r#"{"data":[{"id":"x"},{"id":"x","input_modalities":null}]}"#,
] {
assert_eq!(
decode(body.as_bytes()),
Err(Failure::InvalidResponse),
"{body}"
);
}
for body in [
"{}",
"[]",
r#"{"data":[{"name":"missing id","tags":null}]}"#,
] {
assert!(decode(body.as_bytes()).unwrap().is_empty());
}
}
#[test]
fn complete_inventory_preserves_additional_models_after_deduplication() {
let mut rows: Vec<Value> = (0..2048).map(|i| json!({"id":i.to_string()})).collect();
rows.push(json!({"id":"0"}));
assert_eq!(
decode(json!({"data":rows}).to_string().as_bytes())
.unwrap()
.len(),
2048
);
rows.push(json!({"id":"extra"}));
let models = decode(json!({"data":rows}).to_string().as_bytes()).unwrap();
assert_eq!(models.len(), 2049);
assert_eq!(models.last().unwrap()["id"], "extra");
}
#[test]
fn response_text_uses_lossy_utf8_and_strips_bom() {
assert_eq!(
decode(b"\xef\xbb\xbf{\"data\":[{\"id\":\"\xff\"}]}").unwrap(),
vec![json!({"id":"\u{fffd}"})]
);
}
}