blob: 33f4c308ad87cd6178e5651a9f712cc999152736 [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 super::*;
use futures_util::future::BoxFuture;
use maka_agent_facts::provider::Credential;
use maka_agent_ports::model::Connect;
use maka_agent_ports::model::Error as ModelError;
use maka_agent_ports::model::Socket;
use maka_agent_ports::model::Transport;
use maka_agent_ports::provider::Context;
use maka_agent_ports::provider::Discovery;
use maka_agent_ports::provider::Verification;
use maka_capabilities::http;
use std::sync::{Arc, Mutex};
use tokio_util::sync::CancellationToken;
#[derive(Default)]
struct Exchange(Mutex<Vec<http::Request>>);
impl Transport for Exchange {
fn identity(&self) -> u64 {
1
}
fn request(&self, request: http::Request) -> BoxFuture<'_, Result<http::Response, ModelError>> {
Box::pin(async move {
let url = request.url.clone();
let read_allowed = matches!(request.method, http::Method::Get);
self.0.lock().unwrap().push(request);
Ok(http::Response {
head: http::Head {
status: 200,
url,
headers: vec![],
},
body: Arc::new(Body {
read_allowed,
bytes: Mutex::new(Some(
serde_json::to_vec(&json!({"data":[
{"id":"gpt-5.2"}, {"id":"claude-sonnet-future"}
]}))
.unwrap(),
)),
}),
})
})
}
fn connect(&self, _: Connect) -> BoxFuture<'_, Result<Arc<dyn Socket>, ModelError>> {
Box::pin(async { Err(ModelError::Adapter("unexpected WebSocket".into())) })
}
}
struct Body {
bytes: Mutex<Option<Vec<u8>>>,
read_allowed: bool,
}
impl http::Body for Body {
fn next(&self) -> BoxFuture<'_, Result<Option<Vec<u8>>, http::Error>> {
Box::pin(async {
assert!(
self.read_allowed,
"verification must settle from status without waiting for a body"
);
Ok(self.bytes.lock().unwrap().take())
})
}
fn cancel(&self) {}
fn close(&self) -> BoxFuture<'_, Result<(), http::Error>> {
Box::pin(async { Ok(()) })
}
}
fn credential() -> Option<Credential> {
Some(Credential {
secret: "test-key".into(),
refresh_at: None,
})
}
#[tokio::test]
async fn verification_keeps_protocol_fields_and_rejects_conflicting_customization_before_io() {
for (name, protocol, suffix, path) in [
(
"openai-compatible",
ApiProtocol::OpenaiChatCompletions,
"/v1/",
"/v1/chat/completions",
),
(
"openai-responses-compatible",
ApiProtocol::OpenaiResponses,
"/v1/responses/",
"/v1/responses",
),
(
"anthropic-compatible",
ApiProtocol::AnthropicMessages,
"/v1/",
"/v1/messages",
),
] {
let binding = binding(name);
let mut input = request(
&binding,
"future",
ModelOverride {
api_protocol: Some(protocol),
..Default::default()
},
);
input.connection.configuration = json!({"baseUrl":format!("https://relay.test{suffix}")});
let verification = || Verification {
connection: input.connection.clone(),
model: input.model.clone(),
overrides: input.overrides.clone(),
credential: credential(),
request_headers: [("X-Route".into(), "private".into())].into(),
request_body_overlay: Some(json!({"temperature":0.25})),
};
let transport = Arc::new(Exchange::default());
let context = Context {
transport: transport.clone(),
cancellation: CancellationToken::new(),
interaction: None,
};
binding
.verify(verification(), context.clone())
.await
.unwrap();
{
let requests = transport.0.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].url, format!("https://relay.test{path}"));
let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap();
assert_eq!(body["model"], "future");
assert_eq!(body["temperature"], 0.25);
if protocol == ApiProtocol::OpenaiResponses {
assert_eq!(body["store"], false);
assert_eq!(body["max_output_tokens"], 16);
assert!(body["input"].is_array());
} else {
assert_eq!(body["max_tokens"], 16);
assert!(body["messages"].is_array());
}
let authentication = if protocol == ApiProtocol::AnthropicMessages {
"x-api-key"
} else {
"authorization"
};
assert!(
requests[0]
.headers
.iter()
.any(|(name, _)| name == authentication)
);
}
let mut body_conflict = verification();
body_conflict.request_body_overlay = Some(json!({"model":"different"}));
assert!(
binding
.verify(body_conflict, context.clone())
.await
.is_err()
);
let mut header_conflict = verification();
header_conflict.request_body_overlay = None;
header_conflict
.request_headers
.insert("Content-Type".into(), "text/plain".into());
assert!(binding.verify(header_conflict, context).await.is_err());
assert_eq!(transport.0.lock().unwrap().len(), 1);
}
}
#[tokio::test]
async fn discovery_survives_persistence_and_verification_does_not_require_streaming() {
let transport = Arc::new(Exchange::default());
let context = Context {
transport: transport.clone(),
cancellation: CancellationToken::new(),
interaction: None,
};
for (name, id) in [("openai", "gpt-5.2"), ("anthropic", "claude-sonnet-future")] {
let binding = binding(name);
let mut input = request(&binding, id, ModelOverride::default());
let inventory = binding
.discover(
Discovery {
connection: input.connection.clone(),
credential: credential(),
request_headers: Default::default(),
},
context.clone(),
)
.await
.unwrap();
let persisted: Vec<ModelInfo> =
serde_json::from_slice(&serde_json::to_vec(&inventory).unwrap()).unwrap();
input.model = persisted.into_iter().find(|model| model.id == id).unwrap();
assert_eq!(
serde_json::to_value(&input.model).unwrap(),
json!({"id":id})
);
let model = binding.prepare(input.clone()).await.unwrap();
if name == "openai" {
assert_eq!(
model.provider_options,
json!({"openai":{"store":false,"parallelToolCalls":true}})
);
} else {
assert_eq!(model.info.capabilities, None);
input.model.capabilities.get_or_insert_default().vision = Some(false);
assert_eq!(
binding
.prepare(input.clone())
.await
.unwrap()
.info
.capabilities
.unwrap()
.vision,
Some(false)
);
input.model.capabilities.as_mut().unwrap().vision = Some(true);
input.overrides.as_mut().unwrap().vision = Some(false);
assert_eq!(
binding
.prepare(input)
.await
.unwrap()
.info
.capabilities
.unwrap()
.vision,
Some(false)
);
}
}
let anonymous = binding("localai");
let input = request(&anonymous, "custom", ModelOverride::default());
anonymous
.discover(
Discovery {
connection: input.connection.clone(),
credential: None,
request_headers: Default::default(),
},
context.clone(),
)
.await
.unwrap();
anonymous
.verify(
Verification {
connection: input.connection,
model: input.model,
overrides: None,
credential: None,
request_headers: Default::default(),
request_body_overlay: None,
},
context.clone(),
)
.await
.unwrap();
{
let requests = transport.0.lock().unwrap();
for request in requests.iter().rev().take(2) {
assert!(
!request
.headers
.iter()
.any(|(name, _)| matches!(name.as_str(), "authorization" | "x-api-key")),
"anonymous operations must not invent credentials"
);
}
}
let binding = binding("ollama-cloud");
let input = request(&binding, "custom", ModelOverride::default());
assert!(binding.prepare(input.clone()).await.is_err());
binding
.verify(
Verification {
connection: input.connection,
model: input.model,
overrides: None,
credential: credential(),
request_headers: Default::default(),
request_body_overlay: None,
},
context,
)
.await
.unwrap();
let requests = transport.0.lock().unwrap();
assert_eq!(
requests.last().unwrap().url,
"https://relay.test/v1/chat/completions"
);
}
#[tokio::test]
async fn compiled_definition_authenticates_without_legacy_registration_or_secret_diagnostics() {
use maka_agent_ports::provider::authentication::Authenticate;
let definition = ApiProviders::definition("openai-compatible", snapshot()).unwrap();
let transport = Arc::new(Exchange::default());
let context = Context {
transport: transport.clone(),
cancellation: CancellationToken::new(),
interaction: None,
};
let connection = Connection {
id: "native-account".into(),
revision: 1,
configuration: json!({"baseUrl":"https://native.test/v1"}),
};
let key = "synthetic-private-native-key";
let result = definition
.authenticate(
Authenticate {
connection: connection.clone(),
method: "api-key".into(),
input: json!({"apiKey":format!(" {key} ")}),
},
context.clone(),
)
.await
.unwrap();
assert_eq!(result.secret, key);
assert_eq!(result.refresh_at, None);
for (method, input, configuration, error_kind) in [
(
"unknown",
json!({"apiKey":key}),
connection.configuration.clone(),
"invalid",
),
(
"api-key",
json!({"apiKey":key,"unknown":key}),
connection.configuration.clone(),
"invalid",
),
(
"api-key",
json!({"apiKey":format!("{key}\n{key}")}),
connection.configuration.clone(),
"invalid",
),
(
"api-key",
json!({"apiKey":key}),
json!({"unknown":key}),
"invalid_configuration",
),
] {
let error = match definition
.authenticate(
Authenticate {
connection: Connection {
configuration,
..connection.clone()
},
method: method.into(),
input,
},
context.clone(),
)
.await
{
Ok(_) => panic!("invalid authentication admitted"),
Err(error) => error,
};
assert_eq!(serde_json::to_value(&error).unwrap()["kind"], error_kind);
assert!(!error.to_string().contains(key));
assert!(!serde_json::to_string(&error).unwrap().contains(key));
}
assert!(
transport.0.lock().unwrap().is_empty(),
"API key validation must not issue an exchange"
);
}